diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b632748..823f045 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,19 +1,9 @@ -# The repo's one enforcement point (GH #87 §2). Everything it *checks* lives in `make ci`, not -# here — this file is only the environment that recipe needs. That split is deliberate: a check -# re-specified in YAML is a check that can drift from the one you run locally, and until this -# workflow existed CLAUDE.md's "what CI runs" was aspirational. Now `make ci` is literally it. +# The repo's one enforcement point (GH #87 §2). Every check lives in `make ci`; this file is +# only the environment it needs, so CI can't drift from a local run. # -# Runner: macos-latest. B2 is developed and shipped on macOS only — Metal is macOS-only, and the -# Xcode Command Line Tools that `b2-desktop`'s WebView links against are preinstalled there (and -# supply `make`, so no separate task-runner install step is needed either), so the whole -# workspace (including the Tauri host) builds with no platform setup step at all. A Linux -# runner would need the webkit2gtk/libsoup apt block and would red-build on portability breaks -# nobody ships against. +# macos-latest: B2 ships on macOS only, and the Xcode CLT there supply the WebView and `make`. # -# Model-free by construction, so no `b2 init` step and no model download: `b2-core` never links -# candle, and `b2-cli`'s tests spawn the binary under `B2_EMBEDDER=fake` (crates/b2-cli/tests/cli.rs). -# The real-model work — `make eval` and friends — is non-deterministic and stays out of CI on -# purpose (CLAUDE.md, "Keep `cargo test` fast, deterministic, and model-free"). +# Model-free: no `b2 init`, tests run under `B2_EMBEDDER=fake`. Real-model evals stay out of CI. name: CI @@ -23,8 +13,7 @@ on: pull_request: workflow_dispatch: -# A new push to a branch makes the in-flight run for that branch obsolete; cancel it rather -# than pay for both. `main` gets its own group so a merge is never cancelled by a PR push. +# A new push cancels the branch's in-flight run. `main` has its own group. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -42,40 +31,29 @@ jobs: steps: - uses: actions/checkout@v7 - # No toolchain action: rust-toolchain.toml is already the single source of the pin - # (1.96 + rustfmt + clippy), and the no-arg form installs exactly what it names — so CI - # can't silently run a different toolchain than a local `cargo` invocation resolves. + # No toolchain action: the no-arg form installs exactly what rust-toolchain.toml pins. - name: Install the pinned Rust toolchain run: rustup toolchain install - # Caches ~/.cargo and target/. Load-bearing here: the workspace builds every dependency - # at opt-level 3 (see Cargo.toml's profile block), and candle dominates a cold build. + # Dependencies build at opt-level 3 (Cargo.toml), so a cold build is slow. - uses: Swatinem/rust-cache@v2 - # Pinned rather than lts/* so a new LTS can't change the runner under us; 24 is what the - # repo is developed against. The floor is vite 6's engines field — Node 18, 20, or 22+. + # Pinned, not lts/*, so a new LTS can't change the runner under us. - uses: actions/setup-node@v7 with: node-version: '24' cache: npm cache-dependency-path: ui/package-lock.json - # `npm ci` — not `npm install` — because CI is where lockfile drift should be an error - # rather than a silent rewrite. `make ci`'s own `ui-install` prerequisite is a no-op after - # this, and the tree-clean assertion below catches it if it turns out not to be. + # `npm ci` so lockfile drift is an error, not a silent rewrite. - name: Install frontend dependencies run: npm --prefix ui ci - # The gate. Same command, same stages, same order as a local run. No separate task-runner - # install step: `make` is already on the runner (macos-latest ships Xcode CLT). + # The gate: the same command as a local run. - run: make ci - # Nothing above is allowed to edit a tracked file. This catches two real invariants at - # once: `ui-install` rewriting package-lock.json (drift between package.json and the - # committed lockfile), and the engine suite mutating fixtures/golden-vault/. The tests - # still copy the fixture into a tempdir first (CLAUDE.md, "Conventions"), but since - # GH #170 the deeper claim is that they wouldn't need to: B2 makes no unbidden writes, - # so indexing a vault leaves every byte of it alone. This is what stands behind that. + # Nothing above may edit a tracked file: catches lockfile drift and any write to + # fixtures/golden-vault/ (no unbidden writes, GH #170). - name: Assert the gate left the working tree clean run: | if ! git diff --exit-code; then diff --git a/Cargo.toml b/Cargo.toml index 64fdc5d..a52205f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,25 +8,13 @@ members = [ "crates/b2-desktop", ] -# B2 — local-first Markdown PKM with AI connection discovery. -# Design lives in docs/; this workspace is the index engine -# (docs/index-engine.md), built step 0→5 against the -# golden-vault fixtures in fixtures/golden-vault/ (docs/data-model.md §8). +# B2: local-first Markdown PKM with AI connection discovery. Design lives in docs/. -# Build DEPENDENCIES optimized even in dev/debug builds, while keeping our own `b2-*` -# crates at opt-level 0 so the TDD loop and `cargo test -p b2-core` stay fast. Candle's -# embedder forward pass (matmul + transformer ops, spread across candle *and* its gemm -# matmul backend) is ~13× slower unoptimized, so a plain `tauri dev` / `cargo run` -# reindex was painfully slow (a 16-chunk embed batch took ~35s instead of ~2.5s). That -# mattered doubly for the desktop app: a reindex cancel is only observed at each embed- -# batch boundary (docs/index-engine.md), so a slow batch made the -# **Cancel** button feel stuck. The `"*"` glob optimizes every dependency (targeting -# just the candle crates missed the matmul backend and left it ~4× slower); the per- -# crate opt-0 overrides below exclude our own crates so their rebuilds stay instant. -# Tradeoff: a *cold* build (or a cleared cache, e.g. fresh CI) now compiles every -# dependency at opt-3 — a larger one-time cost, dominated by candle. It's a one-off, -# amortized by the target cache and unmatched by any warm/incremental rebuild (ours stay -# opt-0); `cargo test -p b2-core` stays light regardless since b2-core pulls no candle. +# Optimize dependencies even in dev builds, but keep our own `b2-*` crates at opt-level 0 +# so rebuilds stay fast. Unoptimized candle (and its gemm backend) embeds ~13× slower, and +# a reindex cancel is only observed between embed batches (docs/index-engine.md), so a slow +# batch makes Cancel feel stuck. The `"*"` glob is needed to catch the matmul backend. +# Cost: a cold build compiles every dependency at opt-3. [profile.dev.package."*"] opt-level = 3 [profile.dev.package.b2-core] diff --git a/Makefile b/Makefile index 1e01937..31f4851 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,8 @@ -# B2 task runner. Ported from `just` to GNU Make — `make` needs no extra install on this repo's -# platforms (it ships with the Xcode Command Line Tools on macOS and build-essential on Linux, -# both of which `scripts/doctor.sh` already checks for the native build toolchain). Recipes wrap -# the cargo/npm commands documented in CLAUDE.md → "Commands"; this Makefile is the single place -# the multi-step ones are named. `.github/workflows/ci.yml` runs `make ci` rather than -# re-specifying its stages in YAML, so the gate can't drift between local and CI: what Actions -# enforces is exactly what you can run yourself. +# B2 task runner. CI runs `make ci` rather than re-specifying its stages in YAML, so the gate +# can't drift between local and CI. # -# `make` (no args, or `make help`) prints this listing grouped setup / dev / gates / coverage / -# model, built from the `##@ ` section markers and the `## ` trailing each target -# line below — the Make analogue of the `[group(…)]` + `[doc(…)]` recipe attributes the justfile -# used to carry. +# `make` (or `make help`) lists targets, built from the `##@ ` markers and the +# `## ` after each target. .DEFAULT_GOAL := help @@ -26,8 +19,8 @@ help: # Passed through to `stability` / `calibrate` / `compare-device`, e.g. `make stability ARGS=--verbose`. ARGS ?= -# Required by `calibrate` (deliberately no default — a wrong-vault default would silently -# calibrate against the wrong corpus); optional for `compare-device`, which defaults below. +# Required by `calibrate` (no default: a wrong vault would silently calibrate the wrong +# corpus); optional for `compare-device`. VAULT ?= ##@ Setup @@ -37,18 +30,15 @@ VAULT ?= doctor: ## Sanity-check the local toolchain and print the fix for anything missing — run this first. -@scripts/doctor.sh -# The recipe always passes --force itself (cargo would otherwise refuse a same-version -# reinstall, since this stays 0.1.0) — just re-run `make install` to update after code changes. +# --force because the version stays 0.1.0; re-run to update after code changes. install: ## Install the `b2` binary to ~/.cargo/bin (on PATH; no alias, works from any dir). cargo install --path crates/b2-cli --locked --force uninstall: ## Remove the installed `b2` binary. cargo uninstall b2-cli -# Every recipe that needs node_modules depends on this, so you rarely run it by hand. A no-op -# `npm install` costs ~0.3s against an already satisfied lockfile — nothing next to the Tauri -# build it precedes — and that price buys the invariant that a pull adding a frontend dep -# can't leave you at a Vite "failed to resolve import" with node_modules a commit behind. +# A prerequisite of every recipe that needs node_modules (~0.3s when satisfied), so a pull +# that adds a frontend dep can't leave node_modules a commit behind. ui-install: ## Install the frontend's npm dependencies (a prerequisite of every recipe that needs them). npm --prefix ui install @@ -68,16 +58,13 @@ ui-build: ui-install ## Type-check + build the frontend bundle into ui/dist (wha ui-dev: ui-install ## Vite dev server on :5173 (usually started automatically by `make app`). npm --prefix ui run dev -# Re-vendor the Bootstrap Icons subset into ui/src/icons.gen.ts (the names live in -# ui/scripts/gen-icons.ts). Run it after adding an icon to that manifest, or after bumping -# the bootstrap-icons devDependency — `make test-ui` runs the same generator in --check mode -# first, so a stale generated file fails the gate rather than shipping quietly. +# Run after adding an icon to ui/scripts/gen-icons.ts or bumping bootstrap-icons; +# `make test-ui` fails on a stale generated file. icons: ui-install ## Regenerate ui/src/icons.gen.ts from the bootstrap-icons package. npm --prefix ui run icons -# Embed on the Metal GPU by default on Apple Silicon (GH #40); CPU everywhere else. The `metal` -# feature is a compile-time switch, so this selects it for the dev build — the runtime still -# falls back to CPU if the GPU can't initialize. `make app-cpu` forces CPU. +# Metal on Apple Silicon (GH #40), CPU elsewhere. A compile-time switch; the runtime still +# falls back to CPU if the GPU can't initialize. UNAME_S := $(shell uname -s) UNAME_M := $(shell uname -m) METAL_FEATURE := @@ -87,10 +74,8 @@ METAL_FEATURE := --features metal endif endif -# Point it at a vault with B2_VAULT_PATH, e.g. `B2_VAULT_PATH=~/notes make app`. Settings (⌘,) -# shows a CPU/Metal badge; switching device re-embeds the vault (a `@metal` model swap). -# Depends on ui-install because Tauri's beforeDevCommand shells out to `npm run dev` itself, -# outside `make` — so the deps have to be there before cargo hands off. +# e.g. `B2_VAULT_PATH=~/notes make app`. Switching device re-embeds the vault. Needs +# ui-install because Tauri's beforeDevCommand runs `npm run dev` outside `make`. app: ui-install ## Run the desktop app in dev — auto-selects Metal on Apple Silicon; `make app-cpu` forces CPU. cd crates/b2-desktop && cargo tauri dev $(METAL_FEATURE) @@ -102,43 +87,21 @@ app-build: ui-install ## Bundle the desktop app (per-platform); builds the front ##@ Gates -# Two tiers, plus the pieces they compose from. -# -# `check` is the loop you run constantly; `ci` is the complete pass, and is literally what -# .github/workflows/ci.yml invokes. Everything below them is a building block — useful when -# you're working inside one crate and want that crate's stage alone. -# -# `-D warnings` on every clippy stage is what makes linting a gate at all: clippy exits 0 on -# warnings, so without it the stage caught only lints severe enough to be errors, and -# everything clippy actually exists to say slid through green. - -# Excludes b2-desktop from clippy: linting it embeds ui/dist (needs a frontend build), so it's -# a separate, heavier job here — `ci` picks it up, since `ui-build` runs there anyway. -# test-ui runs last, via a nested `$(MAKE)` call, rather than as a prerequisite: prerequisites -# run *before* the recipe body, and the cargo stages are the cheaper failures (0.1s fmt, 0.2s -# clippy) and the bulk of the code, so they should be what a broken commit trips on first. -# Nothing enforces this locally: there is no git hook, and none is wanted (.git/hooks isn't -# cloneable) — CI is the enforcement, and this is the fast subset you run before you get there. +# `check` is the constant loop; `ci` is the complete pass CI runs. The rest are building +# blocks. `-D warnings` is what makes clippy a gate: it exits 0 on warnings. + +# Excludes b2-desktop from clippy: linting it embeds ui/dist, so `ci` covers it. test-ui runs +# last via `$(MAKE)`, not as a prerequisite, so the cheaper cargo stages fail first. check: ## Fast gate (~3s) — fmt-check, lint, engine + frontend tests. The one you run while working. cargo fmt --check cargo clippy --workspace --exclude b2-desktop -- -D warnings cargo test -p b2-core $(MAKE) test-ui -# The union of `check` + `check-app` + every test in the repo, with the overlap removed: run -# back-to-back those re-ran the engine suite, `ui-build`, and the frontend suite twice over. -# Note the absent `--exclude b2-desktop` — the `ui-build` prerequisite has already satisfied the -# desktop crate's `ui/dist` embed, so one clippy pass covers the whole workspace. -# Stage order is failure order: the cheap mechanical checks first (`no-tokio`, then fmt, then -# lint), the bulk of the code next, then the frontend suite, and `audit` last — it is the only -# stage that *queries a remote service to do its job*, asking the npm advisory database on every -# run, so a slow or unreachable registry can't delay a real failure above it. That is a narrower -# claim than "the only stage that touches the network", which was never quite true and is less -# so now: `ui-install` (upstream of `ui-build`) and `no-tokio` both reach for a registry when -# their lockfile is out of date. The difference is that against a satisfied lockfile they are -# offline no-ops, where `audit` goes out every time. -# `no-tokio` leads because it is a manifest-level check: it reads the lockfile and never compiles -# anything, so it costs a fraction of a second and its failure is structural. +# `check` + `check-app` + every test, without the overlap. `ui-build` satisfies the desktop +# crate's ui/dist embed, so one clippy pass covers the workspace. Stage order is failure +# order, cheapest first; `audit` is last because it queries the npm advisory service every +# run, so a slow registry can't delay a real failure above it. ci: no-tokio ui-build ## Complete gate (~18s warm) — every mechanical check in one pass; exactly what CI runs. cargo fmt --check cargo clippy --workspace -- -D warnings @@ -146,33 +109,13 @@ ci: no-tokio ui-build ## Complete gate (~18s warm) — every mechanical check in $(MAKE) test-ui $(MAKE) audit -# GH #174. `hf-hub` shipped its async download backend (reqwest → hyper → h2 → tokio) under -# default features, so the whole async HTTP stack compiled into `b2` — at opt-3, per the -# workspace's dependency profile — to serve an API B2 never calls. Trimming it is what made -# "nothing in B2 is async" true of the *tree* and not just of the code. This is the guard on -# that, and it is the `-D warnings` lesson again in a new place: a dependency added with its -# defaults left on can put the stack back and nothing else in the gate would notice, because -# the result still builds and still passes — it just costs minutes of every cold build. -# Scoped to `b2-cli` because that is the shipped binary; `b2-desktop` legitimately links tokio, -# since **Tauri** does, and that is the host's runtime rather than one of ours. -# The obvious spelling of this check — `if cargo tree -i tokio; then fail; fi` — is wrong, and -# instructively so. `cargo tree -i ` exits non-zero when the package is *absent*, so the -# passing case is the command failing, and inverting an exit code silently promotes every OTHER -# failure to a pass. That is not hypothetical: `-i` resolves a package *specification*, which -# also errors on an AMBIGUOUS one ("specification `windows-sys` is ambiguous", exit 101) — so the -# day some crate drags in a tokio 0.x beside the 1.x, the check reporting on it would go green. -# So detection doesn't use `-i` at all: line 1 resolves the tree and fails loudly if it can't, -# and line 2 asks the same, already-proven command for a flat package list and greps it. The -# formatting flags can't introduce a failure mode of their own, so nothing here can fail open. -# `^tokio v` is anchored because this is now a text match — `tokio-util` must not trip it. -# `--locked` is what keeps the recipe side-effect-free: `cargo tree` would otherwise rewrite -# Cargo.lock to serve the query, and this is the first stage of `ci`, whose last CI step asserts -# no stage edited a tracked file. Drift now fails here, naming itself, instead of surfacing later -# as an unexplained dirty worktree. -# Edges are cargo's default (normal + build + dev), deliberately WIDER than the shipped binary: -# a dev-dependency dragging the stack back in wouldn't ship, but it would still be compiled by -# `cargo test`, which is the cold-build cost this exists to protect. It costs nothing to be -# strict here — the tree is clean under every edge kind *and* under `--target all`. +# GH #174: a dependency's default features once pulled a whole async HTTP stack into `b2`. +# Scoped to `b2-cli`; `b2-desktop` links tokio legitimately, via Tauri. +# Not `if cargo tree -i tokio`: `-i` exits non-zero both when tokio is absent and on other +# errors (e.g. an ambiguous spec), so inverting it would fail open. Line 1 fails loudly if +# the tree can't resolve; line 2 greps a flat list (`^tokio v`, so `tokio-util` won't match). +# `--locked` stops `cargo tree` rewriting Cargo.lock. Default edges include dev-deps, since +# `cargo test` compiles them too. no-tokio: ## Fail if tokio is back in the `b2` binary's dependency tree (GH #174). @cargo tree -p b2-cli --locked > /dev/null @if cargo tree -p b2-cli --locked --prefix none --format '{p}' | grep -Eq '^tokio v'; then \ @@ -185,37 +128,23 @@ no-tokio: ## Fail if tokio is back in the `b2` binary's dependency tree (GH #174 test: ## Fast, deterministic, model-free engine suite (b2-core only) — the bulk of the test weight. cargo test -p b2-core -# Defined here so the npm half of the repo is reachable from `make` like the cargo half. test-ui: ui-install ## The frontend's pure-logic suite (node's own test runner over ui/src/**/*.test.ts). npm --prefix ui test check-app: ui-build ## Lint the desktop crate alone (needs ui/dist, so the frontend builds first) — the heavy half of `check`. cargo clippy -p b2-desktop -- -D warnings -# The generalized version of the `-D warnings` lesson (GH #87 §3): a tool that reports a problem -# and exits 0 passes the gate green. `npm install` prints "1 high severity vulnerability" and -# exits 0 — which is how a high-severity postcss advisory (GHSA-r28c-9q8g-f849, path traversal -# via source-map auto-loading) rode along under vite through every recipe until it was noticed -# by hand. `npm audit` itself exits non-zero when the tree is vulnerable, so it gates directly. -# Deliberately NOT a prerequisite of `ui-install`: that is now upstream of `make app`, and a -# newly-published advisory in a transitive dep must not stop the app from launching. Equally not -# in `check` — this needs the network, and `check` is the 3s loop that has to work on a plane. -# `--audit-level=high` is an explicit threshold: low/moderate findings inform without blocking. +# `npm install` reports vulnerabilities but exits 0; `npm audit` gates (GH #87 §3). Not in +# `ui-install` (a new advisory must not stop the app launching) nor `check` (needs network). audit: ## Fail on a high-or-worse advisory in the frontend dep tree (needs network; runs as part of `make ci`). npm --prefix ui audit --audit-level=high ##@ Coverage -# cargo-llvm-cov (`cargo install cargo-llvm-cov` + `rustup component add llvm-tools-preview` — -# both, and the component is per-toolchain; `make doctor` checks for them) -# -# Source-based coverage over the same model-free suite CI runs — the numbers answer -# "which engine lines does the deterministic suite actually execute", so an untested -# branch shows up as a gap rather than as a hole nobody named. Real-model paths -# (b2-embed's candle code) are deliberately out: they are exercised by `make eval`, -# not by `cargo test`, so instrumenting them would report a permanent, meaningless 0%. +# Needs cargo-llvm-cov and the llvm-tools-preview component (`make doctor` checks both). +# Covers the model-free suite; b2-embed's candle code is exercised by `make eval`, not +# `cargo test`, so it is left out. -# Mirrors `make test` (b2-core only), so it is as fast as the suite itself and pulls in no ML deps. coverage: ## Engine line/region coverage — the daily number. cargo llvm-cov -p b2-core @@ -227,83 +156,61 @@ coverage-lcov: ## lcov.info for editor gutters (VS Code Coverage Gutters, etc.) cargo llvm-cov -p b2-core --lcov --output-path target/llvm-cov/lcov.info @echo "lcov: target/llvm-cov/lcov.info" -# The CLI suite spawns the real `b2` binary, which is instrumented too, so its process-level -# runs count. Heavier on a cold cache: b2-cli depends on b2-embed, so this compiles candle once -# (excluded crates are still built when a covered crate depends on them). +# Heavier on a cold cache: b2-cli depends on b2-embed, so candle still compiles. coverage-all: ## Engine + the CLI adapter — its tests spawn the instrumented binary, so those runs count. cargo llvm-cov --workspace --exclude b2-desktop --exclude b2-embed -# Separate and heavier for the same reason `check-app` is: it embeds ui/dist (so the frontend -# builds first) and needs the platform webview toolchain, exactly like `make app`. Expect a low -# number by design — b2-desktop is a dumb adapter, and the behaviour behind its commands is -# covered by the façade suite (crates/b2-desktop/CLAUDE.md, "inherited tests"). +# Expect a low number: b2-desktop is a dumb adapter, and its behaviour is covered by the +# façade suite. coverage-app: ui-build ## Coverage for the desktop host's own unit tests. cargo llvm-cov -p b2-desktop ##@ Model -# The eval suite. Never part of `cargo test` or CI — the scored runs need a provisioned -# model, append to gitignored results logs, and their numbers are a property of the machine's -# model/device/build (reproducible per machine, never across machines or devices; `eval-chat` -# is the genuinely non-deterministic one, since LLM output varies run to run). Run these on -# demand. `stability` is the exception that proves the group: it measures retrieval -# *sensitivity* rather than model quality, so it is machine-independent (fake embedder) and -# needs no model — but it is the other half of the same harness (GH #141). -# -# **docs/evals.md is the guide**: what each instrument measures, how to -# read every block it prints, the exit gate, and the process rules that bind any edit to the -# corpora, the labels, or the metrics. The recipe comments here stay operational only. +# The eval suite, never part of `cargo test` or CI: the scored runs need a real model and +# their numbers are per machine. `stability` uses the fake embedder and needs no model. +# docs/evals.md is the guide. init: ## Download + verify bge-base-en-v1.5 into the shared XDG cache (needed for the real embedder). cargo run -p b2-cli -- init -# Both labelled corpora through the real pipeline; appends rows to -# crates/b2-embed/evals/results.jsonl (gitignored); exits non-zero on an exit-gate regression. +# Appends to crates/b2-embed/evals/results.jsonl; exits non-zero on an exit-gate regression. eval: ## Semantic-retrieval + discovery quality eval (real model). cargo run -p b2-embed --example eval eval-sweep: ## `make eval` plus the in-process chunker A/B (ChunkConfig sweep) — the GH #44 gate. cargo run -p b2-embed --example eval -- --sweep -# One lever, isolated: chunks_fts rebuilt under each tokenizer over identical chunks and -# vectors, so every rank move is the tokenizer's alone. +# chunks_fts rebuilt under each tokenizer over identical chunks and vectors. eval-stemmer: ## `make eval` plus the FTS tokenizer ablation (shipped porter vs unstemmed unicode61) — the GH #157 instrument. cargo run -p b2-embed --example eval -- --stemmer -# What `make eval` mostly cannot see (GH #141): candidate width. Deterministic (fake -# embedder), so it needs no `make init`. Flags via ARGS: --verbose (show diverging rankings), -# --model (real bge magnitude, no baseline), --vault (any vault). +# Deterministic, no `make init` needed. ARGS: --verbose (diverging rankings), --model (real +# bge, no baseline), --vault . stability: ## Rank-stability probe on fixtures/test-vault: pool sensitivity + drift vs the blessed baseline (GH #141). Usage: make stability [ARGS="--verbose"] cargo run -p b2-embed --example stability -- $(ARGS) -# Only after a ranking change is the intended one — the snapshot is unlabelled, so it records -# what the ranking IS, never that it got better. +# Only after an intended ranking change: the snapshot records what the ranking is, not that +# it got better. stability-bless: ## Accept the current ranking as the committed rank-stability baseline. cargo run -p b2-embed --example stability -- --bless -# Process rule 5's instrument: a constant derived from a corpus's score *distribution* is -# invalid until transfer-checked on a real vault, and this is the check — a pure read over -# stored vectors, no labels needed. Flags via ARGS: --limit N, --leader-z/--member-z, -# --mutual-k N, --json; ARGS=--search adds the search-side transfer check (GH #201/#206), -# the one part that loads the real model (to embed the probe queries). --json carries both -# readings, so a sweep across vaults never scrapes the table (GH #219). +# Process rule 5's transfer check (docs/evals.md): a read over stored vectors, no labels. +# ARGS: --limit N, --leader-z/--member-z, --mutual-k N, --json; --search adds the search-side +# check (GH #201/#206), which loads the real model. calibrate: ## Calibration on any built vault: discovery pools/bands (GH #197) and, with ARGS=--search, the evidence bar (GH #201). Usage: make calibrate VAULT= [ARGS=--search] @if [ -z "$(VAULT)" ]; then echo "error: VAULT is required, e.g. make calibrate VAULT=fixtures/test-vault"; exit 1; fi cargo run -p b2-embed --example calibrate -- "$(VAULT)" $(ARGS) -# Compare its retrieval quality against `make eval` (CPU) — a device switch is a model swap -# (the recorded model id gains an `@metal` tag), so the vault re-embeds. +# Compare against `make eval` (CPU); a device switch is a model swap (`@metal`). eval-metal: ## The same eval, embedding on the Metal GPU (GH #40, macOS-only). cargo run -p b2-embed --example eval --features metal -# Reindexes an isolated copy on each device and reports chunks/s + speedup. Never mutates the -# committed fixture; artifacts are gitignored + cleaned up. +# Reindexes a temp copy on each device; never mutates the fixture. compare-device: ## CPU-vs-Metal embed throughput A/B on a vault (default fixtures/test-vault; GH #40, macOS-only). Usage: make compare-device [VAULT=] scripts/compare-embed-device.sh "$(if $(VAULT),$(VAULT),fixtures/test-vault)" -# The chat seam's out-of-CI half (GH #154): the labelled questions in -# crates/b2-llm/evals/questions.json through the real `Vault::ask`. Needs a model server -# (`ollama serve`, or point B2_LLM_URL at any OpenAI-compatible one; B2_LLM_MODEL picks the -# model). Appends rows to crates/b2-llm/evals/results.jsonl (gitignored). +# GH #154: crates/b2-llm/evals/questions.json through `Vault::ask`. Needs `ollama serve` or +# B2_LLM_URL; appends to crates/b2-llm/evals/results.jsonl. eval-chat: ## Grounded-chat quality eval: citation accuracy + refusal over the eval corpus (real model + model server). cargo run -p b2-llm --example groundedness diff --git a/crates/b2-cli/Cargo.toml b/crates/b2-cli/Cargo.toml index 99c7a09..496a814 100644 --- a/crates/b2-cli/Cargo.toml +++ b/crates/b2-cli/Cargo.toml @@ -14,28 +14,19 @@ b2-core = { path = "../b2-core" } b2-embed = { path = "../b2-embed" } b2-llm = { path = "../b2-llm" } clap = { version = "4.6.1", features = ["derive", "env"] } -# The `Serialize` bound on `print_json` — the one shared `--json` printer — plus the -# derive for `AskEvent`, the `ask --json` stream's own framing (the *events* are the -# CLI's, the `AnswerView` inside the last one is b2-core's and derives there). +# `print_json`'s bound, and the derive for the `ask --json` stream's `AskEvent`. serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" -# Foreground `reindex` Ctrl-C → cooperative cancel (GH #16): a SIGINT handler flips a -# flag the embed loop reads at each batch boundary (the shipped `ControlFlow` seam), so -# a cancel leaves a consistent, re-runnable partial index rather than a torn one. +# Ctrl-C as a cooperative cancel (GH #16). ctrlc = "3.4" -# `reindex --cancel` → the *same* SIGINT, sent to the pid the lock names (GH #55), so a -# run backgrounded with `b2 reindex &` (no controlling terminal, so Ctrl-C can't reach it) -# takes the shipped cooperative-cancel path rather than a new one. A safe `kill(2)` wrapper, -# so the signal costs no `unsafe`; already in the tree as ctrlc's own dependency. Unix-only — -# as is B2 (macOS-only, which is why CI runs `macos-latest`; CLAUDE.md). +# `reindex --cancel` sends the same SIGINT to the lock's pid (GH #55). A safe `kill(2)` +# wrapper, already in the tree via ctrlc. Unix-only, as is B2. nix = { version = "0.31", default-features = false, features = ["signal"] } -# The opt-in structured-log sink (B2_LOG/B2_DEBUG): the kernel's tracing events — -# per-query SQLite timings, façade-op spans — rendered as JSON Lines on stderr. +# The opt-in JSON Lines log sink (B2_LOG/B2_DEBUG). tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } -# Research build (GH #40, macOS-only): forward the Metal GPU embedder feature to b2-embed so -# `cargo run -p b2-cli --features metal -- reindex` embeds on the GPU. Default build is CPU. +# Metal GPU embedding (GH #40, macOS-only); the default build is CPU. [features] metal = ["b2-embed/metal"] diff --git a/crates/b2-cli/src/cancel.rs b/crates/b2-cli/src/cancel.rs index e17b7c1..559f146 100644 --- a/crates/b2-cli/src/cancel.rs +++ b/crates/b2-cli/src/cancel.rs @@ -4,33 +4,17 @@ use std::ops::ControlFlow; use std::sync::atomic::{AtomicBool, Ordering}; -/// Set by the SIGINT handler installed for a long-running command — by Ctrl-C in the -/// foreground, or (for a `reindex`) by another process's `b2 reindex --cancel` (GH #55), -/// which raises the *same* signal so both reach here. -/// -/// Two loops read it through the same [`ControlFlow`] seam: the reindex embed loop at -/// every batch boundary, stopping *after* the current batch so a cancel leaves a -/// consistent, re-runnable index; and the chat surfaces' token callback at every token, -/// where stopping renders the partial answer honestly. `chat` clears it before each turn. +/// Set on SIGINT, from Ctrl-C or `b2 reindex --cancel` (GH #55). The reindex loop stops +/// after the current batch, leaving a consistent index; the chat stream stops at the next +/// token. `chat` clears it before each turn. static CANCEL: AtomicBool = AtomicBool::new(false); -/// Whether `b2 chat` is streaming an answer right now — which is what decides -/// what Ctrl-C *means* in the REPL: cancel this answer, or leave. Only `chat` -/// maintains it ([`while_answering`]); every other command's SIGINT meaning is -/// unconditional. +/// Whether `b2 chat` is mid-answer, which decides whether Ctrl-C cancels or leaves. static ANSWERING: AtomicBool = AtomicBool::new(false); -/// Route Ctrl-C to [`CANCEL`] instead of killing the process, so a long loop stops at its -/// next checkpoint and what it already did stands. -/// -/// `exit_when_idle` is for a REPL, where Ctrl-C means two things and a handler that only -/// ever meant one would trap the user: mid-answer it cancels the stream, but at an idle -/// prompt it must still be the way out, since swallowing it would leave `/exit` and -/// Ctrl-D as the only exits. `ctrlc` runs the handler on its own thread. -/// -/// Best-effort: if the handler can't be installed, Ctrl-C keeps its default (terminate), -/// which is still safe — a reindex writes edges and FTS before any vectors, and a chat -/// stores nothing. +/// Route Ctrl-C to [`CANCEL`], so a long loop stops at its next checkpoint. With +/// `exit_when_idle` (a REPL), Ctrl-C at an idle prompt still exits. Best-effort: the +/// default terminate is still safe. pub fn install_cancel_on_sigint(exit_when_idle: bool) { let _ = ctrlc::set_handler(move || { if exit_when_idle && !ANSWERING.load(Ordering::SeqCst) { @@ -41,9 +25,8 @@ pub fn install_cancel_on_sigint(exit_when_idle: bool) { }); } -/// Run one REPL answer with Ctrl-C meaning "stop this answer". A fresh cancel budget per -/// turn: the Ctrl-C that stopped the *last* answer must not cancel this one before its -/// first token. +/// Run one REPL answer with Ctrl-C meaning "stop this answer", clearing the last turn's +/// cancel first. pub fn while_answering(answer: impl FnOnce() -> T) -> T { CANCEL.store(false, Ordering::SeqCst); ANSWERING.store(true, Ordering::SeqCst); @@ -52,10 +35,7 @@ pub fn while_answering(answer: impl FnOnce() -> T) -> T { result } -/// Map the Ctrl-C flag onto the cooperative-cancel signal the engine's loops read — -/// [`ControlFlow::Break`] once a cancel has been requested, else -/// [`ControlFlow::Continue`]. Shared by `reindex`'s two progress closures and by the -/// chat surfaces' token callback. +/// The Ctrl-C flag as the [`ControlFlow`] the engine's loops read. pub fn cancel_flow() -> ControlFlow<()> { if CANCEL.load(Ordering::SeqCst) { ControlFlow::Break(()) diff --git a/crates/b2-cli/src/chat.rs b/crates/b2-cli/src/chat.rs index 7d50c13..225b203 100644 --- a/crates/b2-cli/src/chat.rs +++ b/crates/b2-cli/src/chat.rs @@ -11,14 +11,11 @@ use std::io::{IsTerminal, Write}; use std::ops::ControlFlow; pub fn cmd_ask(cli: &Cli, question: &str, llm_args: &LlmArgs) -> Result<(), CliError> { - // The provider first, deliberately: it is the cheap check, and loading the real - // embedder below takes seconds. A stopped model server should cost a round trip, - // not a model load followed by a failure. + // The provider first: a stopped server should cost a round trip, not a model load. let llm = open_llm(llm_args)?; - // Retrieval embeds the question for the vector half → the real model, like `search`. + // Retrieval embeds the question, so the real model. let vault = open_vault(cli.vault_or_cwd(), true)?; - // Ctrl-C cancels the answer at the next token rather than killing the process, so - // what already streamed stays on screen and is reported as partial. + // Ctrl-C cancels at the next token; what streamed stays, reported as partial. install_cancel_on_sigint(false); ask_streamed(&vault, llm.as_ref(), question, &[], cli.json)?; if !cli.json { @@ -35,8 +32,7 @@ pub fn cmd_why( llm_args: &LlmArgs, ) -> Result<(), CliError> { let llm = open_llm(llm_args)?; - // A pure read over stored vectors, like `similar`: nothing embeds a query, so the - // real model is never loaded. + // Stored vectors only, like `similar`: no model load. let vault = open_vault(cli.vault_or_cwd(), false)?; install_cancel_on_sigint(false); let json = cli.json; @@ -53,12 +49,9 @@ pub fn cmd_why( pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { let llm = open_llm(llm_args)?; let vault = open_vault(cli.vault_or_cwd(), true)?; - // Mid-answer, Ctrl-C cancels the stream (the partial text stands); at an idle prompt - // it leaves, as it would anywhere else in a terminal. + // Mid-answer Ctrl-C cancels the stream; at an idle prompt it leaves. install_cancel_on_sigint(true); - // Prompts and the banner are chrome for a human at a terminal: on stderr so - // stdout stays answers, and only when there's a terminal there to read them - // (the `reindex` progress-line rule). + // Chrome goes to stderr, and only on a terminal. let interactive = !cli.json && std::io::stderr().is_terminal(); if interactive { eprintln!( @@ -74,8 +67,7 @@ pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { if !cli.json { note_fake_llm(); } - // Session-only history (S4): the turns live in this Vec and die with the process — - // a persisted transcript would be B2-derived state outside the Markdown. + // Session-only history (S4): a persisted transcript would be state outside the Markdown. let mut history: Vec = Vec::new(); let stdin = std::io::stdin(); loop { @@ -85,7 +77,6 @@ pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { } let mut line = String::new(); if stdin.read_line(&mut line)? == 0 { - // Ctrl-D / end of a piped script. break; } let question = line.trim(); @@ -99,28 +90,19 @@ pub fn cmd_chat(cli: &Cli, llm_args: &LlmArgs) -> Result<(), CliError> { while_answering(|| ask_streamed(&vault, llm.as_ref(), question, &history, cli.json)); match turn { Ok(answer) => { - // A cancelled answer goes into the history too: the human saw that - // text, so a follow-up referring to it ("go on") must be read - // against what was actually said, not against a turn we pretend - // never happened. + // A cancelled answer is kept too: a follow-up refers to what was shown. history.push(ChatTurn::user(question)); history.push(ChatTurn::assistant(&answer.answer)); } - // A failed turn ends the turn, not the session: a model server that - // hiccuped is worth retyping a question at, not worth losing the - // conversation over. The message is the same one the process would - // have exited with. + // A failed turn ends the turn, not the session. Err(e) => eprintln!("{}", user_message(&e)), } } Ok(()) } -/// One grounded ask, rendered as it arrives — the shared body of `ask` and each `chat` -/// turn (flow ④'s streaming contract: every surface streams). Under `--json` this is a -/// **JSON Lines event stream** — one `token` event per token, then one `answer` event -/// carrying the [`AnswerView`] — so an agent sees the answer forming and still gets the -/// resolved citations as data. +/// One grounded ask, streamed: the body of `ask` and each `chat` turn. Under `--json`, a +/// JSON Lines stream of `token` events, then one `answer` event carrying the [`AnswerView`]. fn ask_streamed( vault: &Vault, llm: &dyn LlmProvider, @@ -135,8 +117,7 @@ fn ask_streamed( Ok(answer) } -/// Render one streamed token — the framing `ask`, `chat` and `why` share — and report -/// whether Ctrl-C has asked the stream to stop. +/// Render one streamed token and report whether Ctrl-C asked the stream to stop. fn stream_token(token: &str, json: bool) -> ControlFlow<()> { if json { print_event(&AskEvent::Token { text: token }); @@ -157,10 +138,8 @@ fn finish_answer(answer: &AnswerView, json: bool) { } } -/// One line of the `--json` ask stream. The framing is the CLI's; the payload of -/// the final event is `b2-core`'s [`AnswerView`], which is the standing -/// convention (the view types are the adapters' shared contract — the desktop -/// carries the same two facts as Tauri events plus a command return). +/// One line of the `--json` ask stream. The final payload is `b2-core`'s [`AnswerView`], +/// the adapters' shared contract. #[derive(serde::Serialize)] #[serde(tag = "event", rename_all = "snake_case")] enum AskEvent<'a> { @@ -170,16 +149,14 @@ enum AskEvent<'a> { Answer(&'a AnswerView), } -/// Print one event as a single line of JSON — **not** `print_json`, which is -/// pretty-printed: this is a stream, so one object per line is the contract. +/// One event per line: not `print_json`, which pretty-prints. fn print_event(event: &AskEvent) { if let Ok(line) = serde_json::to_string(event) { println!("{line}"); } } -/// The human-readable tail of an answer: end the streamed line, then the sources -/// the model cited, then — honestly — whether the answer is the whole of one. +/// The human-readable tail of an answer: sources, tools, and whether it was cut short. fn print_answer_tail(answer: &AnswerView) { println!(); if !answer.citations.is_empty() { @@ -194,8 +171,7 @@ fn print_answer_tail(answer: &AnswerView) { if !answer.tools.is_empty() { println!("\nB2 tools used:"); for t in &answer.tools { - // A lookup B2 made itself is marked, so the list never overstates what the - // model chose to do. + // Marked, so the list never overstates what the model chose to do. let by = if t.seeded { " (made by B2)" } else { "" }; println!(" {} {}{by}", t.name, t.arguments); } @@ -205,10 +181,8 @@ fn print_answer_tail(answer: &AnswerView) { } } -/// Never overstate what answered (the `search` fake-embedder caveat, applied to -/// the chat seam): under `B2_LLM=fake` the "answer" is deterministic scaffolding, -/// not a model. On stderr, so stdout stays the answer; the sentence is b2-llm's, so the -/// desktop's setup card says the same thing. +/// Never overstate what answered: under `B2_LLM=fake` the answer is scaffolding. On +/// stderr, so stdout stays the answer. fn note_fake_llm() { if b2_llm::fake_requested() { eprintln!("{}", b2_llm::FAKE_NOTICE); diff --git a/crates/b2-cli/src/error.rs b/crates/b2-cli/src/error.rs index 06ceddf..7994672 100644 --- a/crates/b2-cli/src/error.rs +++ b/crates/b2-cli/src/error.rs @@ -3,19 +3,16 @@ use b2_embed::{EmbedConfig, EmbedError}; use b2_llm::{model_missing_message, refusal_message, unreachable_message, LlmError}; -/// The CLI's error, composing the two crates it drives. Kept internal; `user_message` -/// turns it into a generic, actionable, no-internals-leaked line (logging policy). -/// `#[from]` supplies the `?` conversions; `transparent` defers `Display` to the -/// inner error (only ever surfaced under `B2_DEBUG`). +/// The CLI's error, composing the crates it drives. Kept internal: `user_message` turns it +/// into a generic, actionable line, and `Display` surfaces only under `B2_DEBUG`. #[derive(Debug, thiserror::Error)] pub enum CliError { #[error(transparent)] Core(#[from] b2_core::Error), #[error(transparent)] Embed(#[from] EmbedError), - /// A setup-time chat failure the adapter can act on — an unreachable model - /// server, an un-pulled model. Call-time failures arrive as - /// [`b2_core::Error::Llm`] instead (the seam collapses them to a message). + /// A setup-time chat failure the adapter can act on. Call-time failures arrive as + /// [`b2_core::Error::Llm`]. #[error(transparent)] Llm(#[from] LlmError), #[error(transparent)] @@ -23,35 +20,28 @@ pub enum CliError { /// A filesystem error creating `.b2/` or opening the reindex lock file. #[error(transparent)] Io(#[from] std::io::Error), - /// A command that writes (`reindex`, `add`, `write`, `mv`, `rm`, `link`) was run with - /// no vault at all (no positional, no `-C`, no `$B2_VAULT_PATH`) — refuse rather than - /// silently write into the current directory. + /// A writing command got no vault; refuse rather than write into the cwd. #[error("no vault specified")] VaultRequired, /// Another `reindex` already holds the single-in-flight lock on this vault. #[error("a reindex is already running")] ReindexRunning, - /// `reindex --cancel` found no run in flight on this vault — nothing to signal. + /// `reindex --cancel` found no run in flight. #[error("no reindex is running")] NoReindexRunning, - /// `reindex --cancel` found a run in flight whose lock names no readable pid, so - /// there is no address to signal (a holder that hasn't stamped its pid yet). + /// `reindex --cancel` found a run whose lock holds no readable pid yet. #[error("the running reindex recorded no pid")] ReindexPidUnknown, - /// `rm` was pointed at a folder without `--recursive` — refuse rather than - /// silently remove a whole subtree (the CLI's stand-in for the desktop's - /// confirm dialog; there is no interactive prompt to give an agent). + /// `rm` on a folder without `--recursive`: the CLI's stand-in for the desktop's + /// confirm dialog. #[error("folder delete requires --recursive: {0}")] RecursiveRequired(String), - /// `write` was invoked with stdin attached to a terminal (nothing piped) — refuse - /// rather than hang waiting for hand-typed input; the new body must be piped in. + /// `write` with stdin on a terminal: refuse rather than hang waiting for input. #[error("no body piped on stdin")] StdinRequired, } -/// The head of a model server's own model list, for "…or pick one it already -/// serves". Bounded: a local runtime holds a handful, but a cloud endpoint lists -/// hundreds, and a hundred-model line is not a hint. +/// The head of a server's model list, bounded because a cloud endpoint lists hundreds. fn model_hint(available: &[String]) -> Option { const SHOWN: usize = 6; if available.is_empty() { @@ -65,8 +55,7 @@ fn model_hint(available: &[String]) -> Option { }) } -/// The core relation verbs, as a message lists them — read from `b2_core::relation::CORE` -/// so a verb added there is offered here without an edit. +/// The core relation verbs, read from `b2_core::relation::CORE`. fn core_verbs() -> String { b2_core::relation::CORE .iter() @@ -81,8 +70,7 @@ fn config_file() -> String { .map_or_else(|| "config.toml".to_string(), |p| p.display().to_string()) } -/// Translate an internal error into a generic, actionable, user-facing message — -/// never leaking sqlite/io/serde internals. Set `B2_DEBUG` to also print the detail. +/// A generic, actionable message that never leaks internals; `B2_DEBUG` adds the detail. pub fn user_message(err: &CliError) -> String { let msg = match err { CliError::Core(b2_core::Error::NoteNotFound(r)) => format!( @@ -103,7 +91,6 @@ pub fn user_message(err: &CliError) -> String { CliError::Embed(EmbedError::Load(_)) => { "The embedding model's files failed to load. Run `b2 init` to fetch them again.".to_string() } - // `config.toml` didn't parse, or its `source` names a folder missing a model file. // Either way the fix is in that one file, so name it. CliError::Embed(EmbedError::Config(_)) => format!( "B2 couldn't use its embedder settings. Check the [embedder] table in {}, then try again.", @@ -112,8 +99,7 @@ pub fn user_message(err: &CliError) -> String { CliError::Embed(EmbedError::Io(_)) => { "B2 couldn't read or write the embedding model's files. Check that the model cache is readable and writable, then run `b2 init` again.".to_string() } - // Only a settings picker names a model to switch to; the CLI never does, so this - // arm exists for exhaustiveness and says what the fix would be. + // Only a settings picker names a model to switch to; here for exhaustiveness. CliError::Embed(EmbedError::UnknownModel(m)) => format!( "'{m}' isn't an embedding model B2 offers. Set `model` in {} to one of: {}.", config_file(), @@ -176,23 +162,17 @@ pub fn user_message(err: &CliError) -> String { CliError::StdinRequired => { "No body piped on stdin. Pipe the new note body in, e.g. `cat new-body.md | b2 write notes/foo`.".to_string() } - // The E4 case chat is most likely to hit: nothing is serving the endpoint. The - // sentence is b2-llm's, advised by the endpoint the user actually configured - // (`ollama serve` only when it is Ollama's); the CLI adds only its own way of - // pointing somewhere else. + // E4. The sentence is b2-llm's; the CLI adds only its own flag. CliError::Llm(LlmError::Unreachable { endpoint, .. }) => format!( "{} Or point --llm-url (or B2_LLM_URL) at a different one.", unreachable_message(endpoint) ), - // Something answered the probe and refused it — a *different* mistake from - // nothing listening, and one this used to report as success. The sentence is - // b2-llm's (`refusal_message`), so the CLI and the app say the same thing; the - // Ollama-root hint is the app's alone, since only its card has asked the daemon. + // Something answered and refused, a different mistake from nothing listening. The + // Ollama-root hint is the app's alone: only its card has asked the daemon. CliError::Llm(LlmError::Refused { endpoint, status, message }) => { refusal_message(endpoint, *status, message, None) } - // b2-llm's sentence again, plus the CLI's flag — and the server's own list, since - // "pick one it already serves" is only advice when it names them. + // Plus the server's own list: "pick one it serves" is only advice if it names them. CliError::Llm(LlmError::ModelMissing { model, endpoint, available }) => { let msg = model_missing_message(model, endpoint); match model_hint(available) { @@ -200,28 +180,22 @@ pub fn user_message(err: &CliError) -> String { None => msg, } } - // Not the generic chat failure below: the server is up and the model is there, - // so that advice would mislead. The cap is the one fix on this side of the wire. + // The server is up and the model is there, so the generic advice would mislead. CliError::Core(b2_core::Error::ToolCallLimit { limit }) => format!( "The chat model asked for more than {limit} tool calls in one reply, so b2 stopped it. Try again or use another model (--llm-model). If this model really needs more, raise {}.", b2_llm::ENV_MAX_TOOL_CALLS ), - // Every remaining chat failure — an HTTP refusal at probe time, a malformed - // stream — is one sentence with one fix, and the detail is a `B2_DEBUG` away. CliError::Llm(_) | CliError::Core(b2_core::Error::Llm(_)) => { "The model server couldn't answer. Check that it's running and that the model is installed (`ollama list`), then try again.".to_string() } - // Everything else in the composed crates is an internal (sqlite/io/serde/…) this - // message must never show. Spelled out rather than `_`, so a new `CliError` - // variant fails to compile here instead of silently landing in the catch-all. + // Internals this message must never show. Spelled out rather than `_`, so a new + // `CliError` variant fails to compile instead of landing in the catch-all. CliError::Core(_) | CliError::Serde(_) | CliError::Io(_) => { "Something went wrong. Please check the vault path and try again.".to_string() } }; if std::env::var_os("B2_DEBUG").is_some() { - // Every wrapper variant is `#[error(transparent)]` and every local variant - // carries its own `#[error("…")]` line, so the enum's own `Display` *is* the - // per-variant detail — no match needed. + // The enum's own `Display` is the per-variant detail. let detail = err.to_string(); format!("{msg}\n(debug: {detail})") } else { @@ -255,8 +229,7 @@ mod tests { } } - /// `b2 init` meets these two, and they used to fall through to "check the vault path" - /// — advice about a vault the command never opened. + /// `b2 init` never opens a vault, so "check the vault path" would mislead. #[test] fn embedder_setup_failures_say_what_to_fix_not_to_check_the_vault() { for err in [ diff --git a/crates/b2-cli/src/logging.rs b/crates/b2-cli/src/logging.rs index a4af68d..4137c10 100644 --- a/crates/b2-cli/src/logging.rs +++ b/crates/b2-cli/src/logging.rs @@ -1,20 +1,11 @@ //! Opt-in structured debug logging for the CLI process. -/// Opt-in structured debug logging: the kernel's `tracing` events — per-statement SQLite -/// timings, façade-op spans, flow milestones — rendered as **JSON Lines**, one flat object -/// per line, so a run's log pipes straight into jq/DuckDB while `--json` stdout stays pure -/// data. +/// The kernel's `tracing` events as JSON Lines, one flat object per line, on stderr or +/// appended to `B2_LOG_FILE` (the pure capture: stderr can interleave notices). /// -/// The sink is stderr by default; `B2_LOG_FILE=` writes there instead, in **append** -/// mode so successive runs accumulate into one dataset. A file is also the -/// guaranteed-pure capture: stderr can interleave human notices with the JSONL. -/// -/// `B2_LOG` holds a tracing filter directive; `B2_DEBUG` or `B2_LOG_FILE` without it -/// implies **`b2=debug`** — the kernel's own targets only. That scoping is what keeps the -/// dataset reportable now that a chat command links an HTTP client: `ureq` logs through -/// the `log` bridge in a foreign shape, and a bare `debug` would fold it into the same -/// file. Opt into the firehose with an explicit `B2_LOG=debug`. With none of the three -/// set, no subscriber is installed and the instrumentation stays inert. +/// `B2_LOG` is a filter directive; `B2_DEBUG` or `B2_LOG_FILE` alone implies `b2=debug`, +/// kernel targets only, so `ureq`'s foreign-shaped `log` output stays out. With none set, +/// no subscriber is installed. pub fn init_logging() { let log_file = std::env::var_os("B2_LOG_FILE"); let directive = match std::env::var("B2_LOG") { @@ -31,18 +22,15 @@ pub fn init_logging() { }; let builder = tracing_subscriber::fmt() .json() - // Event fields at the top level of each object (not nested under "fields") - // — what makes `jq '.duration_us'`-style reporting one-liners work. + // Top-level fields, so `jq '.duration_us'` works. .flatten_event(true) - // Close events give each façade-op span its measured duration; the clock - // lives here in the adapter, keeping b2-core itself wall-clock-free. + // Close events time each façade-op span; the clock lives in the adapter, not b2-core. .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_current_span(true) .with_span_list(false) .with_ansi(false) .with_env_filter(filter); - // A CLI run is short-lived and single-threaded at the log site, so a plain - // `Mutex` writer suffices — no async appender needed. + // A short-lived run: a plain `Mutex` writer suffices. match log_file { Some(p) => match std::fs::OpenOptions::new() .create(true) diff --git a/crates/b2-cli/src/main.rs b/crates/b2-cli/src/main.rs index debfe3a..180eb9d 100644 --- a/crates/b2-cli/src/main.rs +++ b/crates/b2-cli/src/main.rs @@ -1,14 +1,9 @@ -//! `b2` — one of the two dumb adapters over the `b2-core` typed API (ADR-0012), -//! headless-first: "the CLI is the UI before the UI". It holds **no engine logic** — it -//! parses args, injects the embedder and chat provider, calls the -//! [`Vault`](b2_core::vault::Vault) façade, and prints (human-readable, or `--json` for -//! agents). `dispatch` below routes each subcommand to its domain module. +//! `b2`, a dumb adapter over the `b2-core` [`Vault`](b2_core::vault::Vault) façade +//! (ADR-0012): parse args, inject the embedder and chat provider, call the façade, print +//! (human or `--json`). No engine logic lives here. //! -//! The embedder is the real candle-backed [`b2_embed::LocalEmbedder`] by default. It is **not -//! bundled**: `b2 init` downloads it into a shared XDG cache, and `reindex`/`search` fail -//! fast with "run `b2 init`" if it is absent, never a surprise mid-command download -//! (ADR-0020). `B2_EMBEDDER=fake` forces the deterministic fake — an offline/dev mode, -//! and what the CLI suite runs under. +//! The real embedder is not bundled: `b2 init` downloads it, and `reindex`/`search` fail +//! fast without it (ADR-0020). `B2_EMBEDDER=fake` forces the deterministic fake. mod args; mod cancel; @@ -41,8 +36,7 @@ fn main() -> ExitCode { } } -/// The thin router: each subcommand's whole behavior lives in its `cmd_*` fn, in the -/// module for its domain; this match only destructures the parsed args and forwards them. +/// Route each subcommand to its `cmd_*` fn. fn dispatch(cli: &Cli) -> Result<(), CliError> { match &cli.command { Command::Init => cmd_init(cli.json), @@ -95,15 +89,13 @@ fn dispatch(cli: &Cli) -> Result<(), CliError> { } } -/// Print `value` as pretty JSON on stdout — the one `--json` output path, shared by -/// every subcommand. +/// Print `value` as pretty JSON on stdout: the one `--json` output path. pub fn print_json(value: &T) -> Result<(), CliError> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) } -/// A command's one result, rendered for whoever asked: the value itself as JSON under -/// `--json`, else `human`'s text. Every single-result command ends here, so the two +/// A command's one result as JSON under `--json`, else `human`'s text, so the two /// renderings can't come from different values. pub fn emit( json: bool, diff --git a/crates/b2-cli/src/read.rs b/crates/b2-cli/src/read.rs index 98b5f1d..0f455c7 100644 --- a/crates/b2-cli/src/read.rs +++ b/crates/b2-cli/src/read.rs @@ -11,14 +11,11 @@ use b2_core::vault::{ }; pub fn cmd_neighbors(cli: &Cli, note: &str) -> Result<(), CliError> { - // Neighbors is a pure graph query — it never embeds, so don't require - // the model (no needless `b2 init` just to explore the graph). + // A pure graph query: no model needed. let vault = open_vault(cli.vault_or_cwd(), false)?; let neighbors = vault.neighbors(note)?; - // Dangling outbound links (a `[[folder]]` or a typo) that resolve to no - // note or resource — surfaced, not dropped (GH #12). `--json` keeps its - // resolved-neighbors array contract; the full structured picture, - // including these, is `b2 explain --json`. + // Dangling links are surfaced, not dropped (GH #12). `--json` stays an array of + // resolved neighbors; `b2 explain --json` carries these. let unresolved = vault.unresolved_links(note)?; emit(cli.json, &neighbors, |neighbors| { if neighbors.is_empty() && unresolved.is_empty() { @@ -45,11 +42,9 @@ pub fn cmd_neighbors(cli: &Cli, note: &str) -> Result<(), CliError> { } pub fn cmd_explain(cli: &Cli, note: &str) -> Result<(), CliError> { - // Explain is a pure graph read (edges + their explanations), no embed — - // like `neighbors`, it opens with the fake and needs no `b2 init`. + // A pure graph read: no model needed. let vault = open_vault(cli.vault_or_cwd(), false)?; - // Kind dispatch by the argument's own shape (core's one rule, §9b #8): - // a resource arg gets the fallback card's view — metadata + backlinks. + // Dispatch by the argument's shape (§9b #8). if doc_kind(note) == DocKind::Resource { return emit( cli.json, @@ -82,8 +77,6 @@ fn print_explanation(view: &ExplainView) { let name = display_name(view.title.as_deref(), &view.path); println!("{name} ({})", view.path); if view.connections.is_empty() && view.resources.is_empty() && view.unresolved.is_empty() { - // Zero connections at all — nothing links to it and it links to - // nothing (an orphan; the kernel only surfaces, never archives). println!("No connections yet."); } else if !view.connections.is_empty() { println!("Connections:"); @@ -98,14 +91,12 @@ fn print_explanation(view: &ExplainView) { println!(" why: {why}"); } } - // If nothing points *at* the note, it's an orphan — surfaced, not - // acted on (invariants.md; files are only touched when asked). + // An orphan is surfaced, never acted on. if !view.connections.iter().any(|c| c.direction == "inbound") { println!("No inbound links — this note is an orphan."); } } - // Outbound links at resources (images, PDFs, …) — the third target - // kind an edge can have, shown from the note's side (GH #22). + // Outbound links at resources (GH #22). if !view.resources.is_empty() { println!("Resource links:"); for r in &view.resources { @@ -120,9 +111,7 @@ fn print_explanation(view: &ExplainView) { } } } - // Dangling outbound links (a `[[folder]]` or a typo): a note is one - // `.md` file, so these resolve to nothing — shown as broken rather - // than silently dropped (GH #12). + // Shown as broken rather than dropped (GH #12). if !view.unresolved.is_empty() { println!("Unresolved links:"); for u in &view.unresolved { @@ -143,26 +132,16 @@ pub fn cmd_search( limit: usize, exclude: &[String], ) -> Result<(), CliError> { - // Search embeds the query for the vector half → it needs the real model. + // Embeds the query, so it needs the real model. let vault = open_vault(cli.vault_or_cwd(), true)?; - // The evidence read, not the bare list (invariants.md D2, GH #201/#202): the - // rows are identical and in the same order, and the verdict beside them is - // what lets this command say **"no matches"** honestly instead of serving - // `limit` confident-looking results for a query the vault holds nothing for. - // `--exclude` paths are the caller's subtraction, never the verdict's. + // The evidence read (D2, GH #201/#202): the same rows, plus a verdict that lets this + // command say "no matches" honestly. let view = vault.search_evidence_excluding(query, limit, exclude)?; - // `--json` is the whole view, verdict included. This is an OBJECT where `--json` used - // to be an array — a deliberate break (GH #202), because a query-level verdict has - // nowhere to live in a list of rows; the rows themselves stay additive. - // - // The JSON serves the rows even at `vouched: false`, where the human surface shows - // none: an agent handed the rows *plus* an explicit verdict can be honest about them, - // where a human handed rows alone cannot. + // `--json` is the whole view (an object since GH #202). It serves rows even at + // `vouched: false`: an agent gets the explicit verdict beside them; a human would not. emit(cli.json, &view, |view| { println!("{}", search_report(view, query)); - // Honesty (never overstate): with the fake embedder the vector half - // isn't semantic. Under the real model it is, so no caveat. Kept on - // stderr so stdout stays pure results. + // The fake embedder's vector half isn't semantic. On stderr, so stdout stays pure. if b2_embed::fake_requested() { eprintln!( "note: keyword (BM25) ranking is live; semantic ranking is off (fake embedder)." @@ -171,27 +150,15 @@ pub fn cmd_search( }) } -/// The human-mode rendering of a search — ADR-0015's three verdict states as one pure -/// function (GH #202). -/// -/// Pure, and separate from [`cmd_search`], because the state that matters most is the one -/// the integration suite structurally cannot reach: `Some(false)` needs a *calibrated* -/// bar, and the fake embedder the suite runs under has none by design. A branch reachable -/// only under the real model is still the fast suite's to own once the rendering is -/// separated from the retrieval. +/// A search rendered for a human: ADR-0015's three verdict states (GH #202). Pure, so the +/// unit tests can reach `Some(false)`, which the fake-embedder integration suite cannot. fn search_report(view: &SearchEvidenceView, query: &str) -> String { match view.vouched { - // D2's "no matches", strict: the vault holds neither a lexical anchor - // nor semantic proximity clearing the model's bar, so the - // nearest-by-meaning rows are not shown at all — no reveal, no `--all`. - // Offering them behind a flag would still be this command putting them - // forward as candidates (GH #202, decision 1). + // D2's "no matches", strict: no rows and no reveal flag, which would still put + // them forward as candidates (GH #202, decision 1). Some(false) => format!("No matches. Nothing in the vault matches “{query}”."), - // `Some(true)` — the vault vouches for these — and `None`, which is *no - // verdict at all*: the active embedder has no calibrated bar (the fake - // one, or any model until the harness measures it — M2), and reading - // that as "no matches" would blank a dev vault. Three states, three - // behaviors; `None` is never folded into `false`. + // `None` is no verdict (no calibrated bar, M2), never folded into `false`, which + // would blank a dev vault. _ if view.results.is_empty() => "No results.".to_string(), _ => view .results @@ -211,25 +178,16 @@ fn search_report(view: &SearchEvidenceView, query: &str) -> String { } pub fn cmd_similar(cli: &Cli, note: &str, limit: usize) -> Result<(), CliError> { - // Candidate generation reads the *stored* vectors (no query embedding), so - // like `neighbors` it needs no live model — a prior `reindex` supplies them. - // Open with the fake; it's a pure, instant local read. (The z grading keys - // on the vault's RECORDED model id, so opening with the fake for reading - // never turns it off.) + // Reads stored vectors only, so the fake suffices. z grading keys on the vault's + // recorded model id, so the fake doesn't turn it off. let vault = open_vault(cli.vault_or_cwd(), false)?; let results = vault.similar(note, limit)?; if cli.json { print_json(&results)?; } else if limit == 0 { - // An ask for nothing yields nothing, silently: the empty-state copy - // below reads the candidate set, and a zero limit proves nothing about - // it — printing "nothing to compare" here would be the one way this - // command could still make a claim it can't check (GH #197). + // Silent: the empty-state copy makes a claim a zero ask can't check (GH #197). } else if results.is_empty() { - // Two honest empty states, and neither claims "nothing relates" - // (GH #197): at a nonzero ask, an empty list means the candidate set is - // genuinely empty — nothing unlinked with stored vectors to compare — - // or the vault's similarity isn't semantic yet. + // Neither empty state claims "nothing relates" (GH #197). let status = vault.embed_status()?; if status.embedded > 0 { println!("Nothing unlinked has stored vectors to compare."); @@ -246,7 +204,7 @@ pub fn cmd_similar(cli: &Cli, note: &str, limit: usize) -> Result<(), CliError> println!(" {}", r.evidence); } } - // Nudge toward the commit step, on stderr so stdout stays pure results. + // On stderr, so stdout stays pure results. eprintln!("Commit one with: b2 link {note} --type "); } Ok(()) @@ -262,7 +220,7 @@ pub fn cmd_explain_similar( other: &str, limit: usize, ) -> Result<(), CliError> { - // A pure read over stored vectors, like `similar`: the fake is enough to open with. + // Stored vectors only, like `similar`. let vault = open_vault(cli.vault_or_cwd(), false)?; let ex = vault.explain_similar(note, other, limit)?; emit(cli.json, &ex, |ex| print_similar_explanation(ex, limit)) @@ -319,8 +277,7 @@ fn print_similar_explanation(ex: &SimilarExplainView, limit: usize) { } if !ex.pairs.is_empty() { println!("\nPassage pairs, nearest first:"); - // Pairs carry a grade only when the list does; say why they don't, once, rather - // than print bare pairs that read as unmeasured by accident. + // Say once why pairs are ungraded, so they don't read as unmeasured by accident. if ex.pairs.iter().all(|p| p.z.is_none()) { println!( " Ungraded: too few notes to compare against, or a vault indexed without the real model." @@ -355,13 +312,12 @@ fn excerpt(text: &str, max: usize) -> String { } } -/// The presentation rule for naming a note: its title when it has one, else its -/// vault-relative path. +/// A note's title, else its path. fn display_name<'a>(title: Option<&'a str>, path: &'a str) -> &'a str { title.unwrap_or(path) } -/// The direction glyph: `→` for an outbound edge (this note → other), `←` inbound. +/// `→` for an outbound edge, `←` inbound. fn arrow(direction: &str) -> &'static str { if direction == "outbound" { "→" @@ -381,13 +337,8 @@ fn decorate(line: &mut String, embed: bool, caption: Option<&str>) { } } -/// ADR-0015's three verdict states as rendered by [`search_report`] (GH #202). -/// -/// The integration suite spawns the binary under `B2_EMBEDDER=fake`, whose embedder has no -/// calibrated bar — so `vouched` there is always `None` and the two states that matter are -/// unreachable from it. That is a fact about the model seam, not a hole to leave: the -/// rendering is pure, so it is tested here directly rather than hidden behind an -/// `#[ignore]` the suite would keep reporting as present. +/// ADR-0015's three verdict states as rendered by [`search_report`] (GH #202), tested here +/// because the fake-embedder integration suite only ever sees `None`. #[cfg(test)] mod tests { use super::*; @@ -417,9 +368,7 @@ mod tests { #[test] fn an_unvouched_query_shows_the_empty_state_and_none_of_its_rows() { - // Strict (GH #202, decision 1): the engine served rows, and the surface - // shows none of them — no reveal, no count of what is being withheld, - // since either would put them forward as candidates after all. + // Strict (GH #202, decision 1): no rows, and no count of what is withheld. let out = search_report(&view(Some(false), 10), "Fasdfadsf"); assert!(out.starts_with("No matches."), "{out}"); assert!(!out.contains("notes/n0.md"), "no row leaks: {out}"); @@ -438,9 +387,7 @@ mod tests { #[test] fn no_calibrated_bar_serves_its_rows_exactly_as_before() { - // `None` is *no verdict*, never "no matches" (M2): the fake embedder and - // every uncalibrated model land here, and folding it into `false` would - // blank the app on a dev vault. + // `None` is no verdict, never "no matches" (M2). let out = search_report(&view(None, 2), "memory"); assert!(out.contains("notes/n0.md"), "{out}"); assert!(!out.contains("No matches"), "{out}"); @@ -448,8 +395,7 @@ mod tests { #[test] fn an_empty_list_reads_as_no_results_whatever_the_verdict() { - // The genuinely-empty list keeps its own older copy, which claims - // nothing about evidence — an unbuilt index is not a judgment. + // This copy claims nothing about evidence: an unbuilt index is not a judgment. for vouched in [Some(true), None] { let out = search_report(&view(vouched, 0), "memory"); assert_eq!(out, "No results.", "{vouched:?}"); diff --git a/crates/b2-cli/src/reindex.rs b/crates/b2-cli/src/reindex.rs index a57c900..bf27845 100644 --- a/crates/b2-cli/src/reindex.rs +++ b/crates/b2-cli/src/reindex.rs @@ -13,7 +13,7 @@ use std::io::{IsTerminal, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; pub fn cmd_init(json: bool) -> Result<(), CliError> { - // Global, per-machine setup — no vault involved. + // Per-machine setup: no vault involved. let config = EmbedConfig::load()?; let report = provision(&config, |line| eprintln!("{line}"))?; emit(json, &report, |report| { @@ -35,58 +35,43 @@ pub fn cmd_reindex( dry_run: bool, cancel: bool, ) -> Result<(), CliError> { - // Reindex writes an index → require an explicit vault (positional wins), - // never a silent cwd fallback. See `Cli::require_vault`. + // Writes, so an explicit vault is required. let root = cli.require_vault(vault)?; if cancel { - // Signals another process and returns; it never opens the vault (no - // model load, no index read) — the run being cancelled owns all of that. + // Signals another process; never opens the vault. return cancel_reindex(root, cli.json); } if dry_run { - // A dry-run neither embeds nor stamps → no model needed (open with - // the fake, like `neighbors`); it's a pure read, so there's no slow - // embed phase to show progress for. + // A pure read: no model and no progress line. let vault = open_vault(root, false)?; return emit(cli.json, &vault.plan_reindex(force)?, print_reindex_plan); } - // Single-in-flight: take an advisory lock *before* the slow model load, so a second - // `b2 reindex` refuses cleanly instead of two processes writing the same index. - // Advisory, not a PID file: the OS frees it the instant the holder exits (crash, kill - // or Ctrl-C included), so nothing stale is left behind. Held until this fn ends. + // Single-in-flight, taken before the slow model load. An advisory lock, not a PID + // file: the OS frees it when the holder exits, however it exits. let lock = open_reindex_lock(root)?; match lock.try_lock() { Ok(()) => {} Err(std::fs::TryLockError::WouldBlock) => return Err(CliError::ReindexRunning), Err(std::fs::TryLockError::Error(e)) => return Err(CliError::Io(e)), } - // Now that the lock is ours, stamp who holds it: the address `b2 reindex - // --cancel` signals, and what `b2 status` prints so a manual `kill` stays - // available (GH #55). Best-effort — a failed write costs the cancel - // affordance, not the reindex. + // Stamp our pid for `--cancel` and `b2 status` (GH #55). Best-effort: a failed write + // costs the cancel affordance, not the reindex. let _ = record_reindex_pid(&lock); - // Reindex embeds every changed chunk → it needs the real model. let vault = open_vault(root, true)?; - // Wire Ctrl-C to the cooperative-cancel flag now that the model is loaded and - // real embedding is next. (During the model load the default SIGINT still - // applies — nothing is written yet, so a hard stop there is safe.) + // Only after the model load: until then nothing is written, so the default SIGINT + // is safe. install_cancel_on_sigint(false); - // Embedding a large vault on CPU is slow; show a live progress line so it - // never looks frozen. Only on an interactive stderr (never in --json, and - // never when piped/captured) so machine output and tests stay clean. + // A live progress line, only on an interactive stderr and never in --json. let report = if cli.json || !std::io::stderr().is_terminal() { vault.reindex_with_progress(force, &mut |_| cancel_flow())? } else { - // Name the vault being indexed up front, then a live line that counts - // the notes actually (re)embedded — not every note, most of which an - // incremental run reuses untouched — with the current file + its chunks. + // Counts the notes actually (re)embedded, not every note. let shown = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); eprintln!("Indexing {}", shown.display()); let mut progressed = false; let mut on_progress = |p: b2_core::ingest::ReindexProgress| { progressed = true; - // \x1b[K clears any tail of a previous, longer line (paths vary in - // length); safe here because this branch only runs on a real terminal. + // \x1b[K clears the tail of a longer previous line; this is a real terminal. eprint!( "\r embedding {}/{} · {} ({} chunk{})\x1b[K", p.notes_embedded, @@ -96,22 +81,19 @@ pub fn cmd_reindex( if p.note_chunks == 1 { "" } else { "s" }, ); let _ = std::io::stderr().flush(); - // Stop after this batch if Ctrl-C was pressed, - // else carry on. The batch is already written above, so a cancel here - // never tears a write. + // The batch is already written, so a cancel here never tears a write. cancel_flow() }; let report = vault.reindex_with_progress(force, &mut on_progress)?; if progressed { - eprintln!(); // close the progress line + eprintln!(); } report }; emit(cli.json, &report, print_reindex_report) } -/// The human-readable `reindex --dry-run` preview (the `--json` sibling prints the -/// plan itself). +/// The human-readable `reindex --dry-run` preview. fn print_reindex_plan(plan: &b2_core::vault::ReindexPlan) { println!( "Dry run: would index {} note(s) — {} to embed. Nothing in your vault is written either way.", @@ -119,8 +101,7 @@ fn print_reindex_plan(plan: &b2_core::vault::ReindexPlan) { ); } -/// The human-readable reindex summary: the one stdout line, then the stderr -/// notices — skipped files and the cancelled line. +/// The human-readable reindex summary: one stdout line, then stderr notices. fn print_reindex_report(report: &b2_core::vault::ReindexReport) { println!( "Indexed {} notes ({} embedded{}) and {} resources{}", @@ -138,17 +119,12 @@ fn print_reindex_report(report: &b2_core::vault::ReindexReport) { String::new() } ); - // One unreadable file no longer aborts the reindex — it is skipped and - // named here (to stderr, so it never pollutes the machine-readable stdout - // line above) with a short, file-level reason. if !report.skipped.is_empty() { eprintln!("Skipped {} unreadable file(s):", report.skipped.len()); for s in &report.skipped { eprintln!(" - {} ({})", s.path, s.reason); } } - // The counts above already report the partial work truthfully; add the - // one line that tells the user it was interrupted and is safe to resume. if report.cancelled { eprintln!( "Cancelled — the index is consistent but only partly embedded. Re-run `b2 reindex` to finish the rest." @@ -157,10 +133,7 @@ fn print_reindex_report(report: &b2_core::vault::ReindexReport) { } pub fn cmd_status(cli: &Cli) -> Result<(), CliError> { - // Read-only coverage report: how much of the vault is embedded (semantic - // ranking live vs. keyword-only) and whether a background reindex is in - // flight — the companion to backgrounding a slow reindex with `b2 reindex &`. - // A pure model-free DB read (#26): open with the fake. + // A model-free read (GH #26): embedding coverage, and whether a reindex is running. let root = cli.vault_or_cwd(); let vault = open_vault(root, false)?; let status = vault.embed_status()?; @@ -192,8 +165,7 @@ pub fn cmd_status(cli: &Cli) -> Result<(), CliError> { status.embedded, status.total ); } - // Name the process, not just the fact: `--cancel` is the supported stop, - // and the pid keeps a plain `kill -INT` as the documented fallback. + // The pid keeps `kill -INT` as the documented fallback to `--cancel`. match (status.reindex_running, status.reindex_pid) { (true, Some(pid)) => println!( "A reindex is currently running (pid {pid}). Stop it with `b2 reindex --cancel` (or `kill -INT {pid}`)." @@ -213,53 +185,42 @@ struct StatusView { embedded: usize, total: usize, reindex_running: bool, - /// The running process's id — `null` when nothing is running (and on the sliver - /// of a moment before a fresh holder stamps it). + /// `null` when nothing is running, or before a fresh holder stamps it. reindex_pid: Option, } -/// `b2 reindex --cancel`'s result. `signalled`, not `cancelled`: the request landed; -/// the run stops at its next batch boundary and reports the partial work itself (honest -/// tense, like the dry-run's `would_*` keys). +/// `b2 reindex --cancel`'s result. `signalled`, not `cancelled`: the run stops at its next +/// batch boundary and reports the partial work itself. #[derive(Debug, Serialize)] struct CancelView { signalled: bool, pid: u32, } -/// Path to the single-in-flight advisory lock for `reindex`, under the disposable -/// index dir (`/.b2/reindex.lock`, gitignored along with the rest of `.b2/`). +/// `/.b2/reindex.lock`, the single-in-flight advisory lock. fn reindex_lock_path(root: &Path) -> PathBuf { root.join(".b2").join("reindex.lock") } -/// Open (creating if absent) the reindex lock file, ensuring `.b2/` exists first so -/// the lock can be taken *before* the index does on a first-ever reindex. The caller -/// takes the lock with [`File::try_lock`] and holds the returned handle for the run. +/// Open or create the reindex lock file, creating `.b2/` so the lock precedes the index. fn open_reindex_lock(root: &Path) -> Result { std::fs::create_dir_all(root.join(".b2"))?; Ok(OpenOptions::new() .create(true) - // A lock file carries no contents we care about — never truncate it (that's a - // needless write, and could race a holder). Presence + the advisory lock is all. + // Truncating here could race a holder. .truncate(false) .read(true) .write(true) .open(reindex_lock_path(root))?) } -/// Stamp this process's id into the reindex lock — **only ever called with the lock -/// held**, which is what makes truncate-then-write safe (`open_reindex_lock` deliberately -/// does not truncate, since that would race a holder). -/// -/// The pid outlives the run, harmlessly: [`reindex_holder`] reads it *only* when the lock -/// is contended, so the **lock**, never the file's contents, decides whether a run is in -/// flight. +/// Stamp this process's id into the reindex lock. Only called with the lock held, which +/// makes truncate-then-write safe. The stale pid left after the run is harmless: the lock, +/// not the contents, decides whether a run is in flight. fn record_reindex_pid(lock: &File) -> std::io::Result<()> { let mut handle = lock; handle.set_len(0)?; handle.seek(SeekFrom::Start(0))?; - // A trailing newline so the file reads sanely under `cat`; parsing trims it. writeln!(handle, "{}", std::process::id())?; handle.flush() } @@ -267,25 +228,19 @@ fn record_reindex_pid(lock: &File) -> std::io::Result<()> { /// A reindex holding the lock, seen from another process. #[derive(Debug)] struct ReindexHolder { - /// The pid it stamped into the lock. `None` when the lock is held but no readable - /// pid is there — a holder that took the lock microseconds ago and hasn't written - /// yet, an older `b2` that stamped none, or an unreadable file. "Running" is the - /// lock's answer; the pid is the extra affordance that may be missing. + /// `None` when the lock is held but no readable pid is there yet (or ever). pid: Option, } -/// Peek at the reindex lock — the best-effort read behind `b2 status` and `b2 reindex -/// --cancel`: open the *existing* lock file and try to take it; a contended lock means -/// a run is in flight, and its pid is read from the file we could not lock. Never -/// creates the file (both callers are read-only on the vault), so a missing lock file -/// simply means no reindex has run; any I/O hiccup degrades to "not running". +/// Peek at the reindex lock for `b2 status` and `--cancel`: a contended lock means a run +/// is in flight. Never creates the file; any I/O hiccup degrades to "not running". fn reindex_holder(root: &Path) -> Option { let mut file = OpenOptions::new() .read(true) .write(true) .open(reindex_lock_path(root)) .ok()?; - // If we *can* take it, nobody holds it; `file` drops here and releases at once. + // If we can take it, nobody holds it; dropping `file` releases it. if !matches!(file.try_lock(), Err(std::fs::TryLockError::WouldBlock)) { return None; } @@ -297,15 +252,9 @@ fn reindex_holder(root: &Path) -> Option { Some(ReindexHolder { pid }) } -/// `b2 reindex --cancel` (GH #55): stop a reindex on this vault by signalling the pid its -/// lock names. It adds **no cancellation machinery** — SIGINT is exactly what Ctrl-C -/// delivers, so the holder takes the shipped path to a consistent, re-runnable partial -/// index. That is the point: a run backgrounded with `b2 reindex &` has no controlling -/// terminal for Ctrl-C to reach. -/// -/// One window is inherent and accepted: a holder still loading the model hasn't installed -/// the handler yet, so SIGINT terminates it outright — the same as Ctrl-C there today, and -/// safe for the same reason (nothing is written until embedding starts). +/// `b2 reindex --cancel` (GH #55): send SIGINT to the pid the lock names, so a backgrounded +/// run takes the Ctrl-C path. A holder still loading the model dies outright, which is safe: +/// nothing is written until embedding starts. fn cancel_reindex(root: &Path, json: bool) -> Result<(), CliError> { let Some(holder) = reindex_holder(root) else { return Err(CliError::NoReindexRunning); @@ -326,18 +275,15 @@ fn cancel_reindex(root: &Path, json: bool) -> Result<(), CliError> { }) } -/// Send the cancel signal to a reindex holder. SIGINT, deliberately: the identical -/// signal a foreground Ctrl-C raises, so there is one cancel path, not two. +/// SIGINT, the signal Ctrl-C raises, so there is one cancel path. fn signal_reindex(pid: u32) -> Result<(), CliError> { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; - // A pid that doesn't fit `pid_t` was never one we wrote — treat a garbled lock as - // nothing to cancel rather than signalling whatever the truncation would name. + // A pid that doesn't fit `pid_t` was never ours: don't signal a truncation. let raw = i32::try_from(pid).map_err(|_| CliError::NoReindexRunning)?; match kill(Pid::from_raw(raw), Signal::SIGINT) { Ok(()) => Ok(()), - // The holder exited between the lock peek and the signal: there is nothing left - // to cancel — the outcome the user wanted, reported as such rather than as I/O. + // The holder exited between the peek and the signal. Err(nix::errno::Errno::ESRCH) => Err(CliError::NoReindexRunning), Err(e) => Err(CliError::Io(std::io::Error::from_raw_os_error(e as i32))), } diff --git a/crates/b2-cli/src/wiring.rs b/crates/b2-cli/src/wiring.rs index a667c9d..5696b4d 100644 --- a/crates/b2-cli/src/wiring.rs +++ b/crates/b2-cli/src/wiring.rs @@ -9,22 +9,16 @@ use b2_core::vault::Vault; use b2_llm::LlmConfig; use std::path::Path; -/// Pick + wire the chat provider — [`open_vault`]'s sibling for the second seam. -/// -/// `B2_LLM=fake` forces the deterministic fake, the `B2_EMBEDDER=fake` sibling. Otherwise -/// the real client is built from the environment with this command's flags laid over it, -/// and **probed before anything else happens** — the `b2 init` posture applied to chat: a -/// stopped daemon is a sentence before the question, never a surprise after it. +/// The chat provider: the fake under `B2_LLM=fake`, else the real client from the +/// environment plus flags, probed first so a stopped daemon fails before the question. pub fn open_llm(args: &LlmArgs) -> Result, CliError> { let config = LlmConfig::from_env().with_overrides(args.llm_url.as_deref(), args.llm_model.as_deref()); Ok(b2_llm::probed_provider(config)?) } -/// Open a vault with the appropriate embedder. `needs_semantic` commands (`reindex`, -/// `search`) load the real model and **fail fast** with "run `b2 init`" if it is absent; -/// pure-graph commands pass `false` and use the fake, so no model is required just to -/// explore the graph. `B2_EMBEDDER=fake` forces the fake everywhere. +/// Open a vault with the right embedder: `needs_semantic` loads the real model (failing +/// fast without it); `false` uses the fake. `B2_EMBEDDER=fake` forces the fake everywhere. pub fn open_vault(root: &Path, needs_semantic: bool) -> Result { Ok(match b2_embed::embedder_for(needs_semantic)? { Some(embedder) => Vault::open_with_embedder(root, Box::new(embedder))?, diff --git a/crates/b2-cli/src/write.rs b/crates/b2-cli/src/write.rs index 06d3171..f92a3c8 100644 --- a/crates/b2-cli/src/write.rs +++ b/crates/b2-cli/src/write.rs @@ -15,8 +15,7 @@ pub fn cmd_add( title: Option<&str>, content: Option<&str>, ) -> Result<(), CliError> { - // Add writes a new note (and embeds its body) → require an explicit vault - // (no silent cwd), and it needs the real model like `reindex`/`mv`/`link`. + // Embeds the new body, so the real model. let vault = open_vault(cli.require_vault(None)?, true)?; let report = vault.add_note(path, title, content)?; emit(cli.json, &report, |report| { @@ -25,10 +24,7 @@ pub fn cmd_add( } pub fn cmd_write(cli: &Cli, note: &str) -> Result<(), CliError> { - // A body splice + re-projection: **model-free** (like the desktop's save and - // `rm`), still an explicit vault like every write. Refuse an interactive - // terminal up front so the command never silently hangs waiting for - // hand-typed input — the new body is always *piped* (an agent, `cat file |`, …). + // Model-free. Refuse a terminal stdin up front rather than hang: the body is piped. let stdin = std::io::stdin(); if stdin.is_terminal() { return Err(CliError::StdinRequired); @@ -36,11 +32,8 @@ pub fn cmd_write(cli: &Cli, note: &str) -> Result<(), CliError> { let vault = open_vault(cli.require_vault(None)?, false)?; let mut body = String::new(); stdin.lock().read_to_string(&mut body)?; - // Stateless one-shot: read the current on-disk revision and chain the write on - // it. A CLI holds no long-lived buffer, so there's no external-edit window to - // guard here — the content-hash guard exists for the desktop's in-memory - // buffer; for a one-shot, the contract is simply "the body becomes exactly - // this" (`Vault::write` still keeps the frontmatter bytes untouched). + // A one-shot holds no buffer, so there's no external-edit window to guard: chain on + // the current revision. let current = vault.read(note)?; let report = vault.write(note, &body, ¤t.revision)?; emit(cli.json, &report, |report| { @@ -49,14 +42,10 @@ pub fn cmd_write(cli: &Cli, note: &str) -> Result<(), CliError> { } pub fn cmd_mv(cli: &Cli, from: &str, to: &str) -> Result<(), CliError> { - // A move rewrites files (and re-embeds them on re-projection) → require an - // explicit vault (no silent cwd), and it needs the real model the index was - // built with, like `reindex`/`add`/`link`. + // Rewritten files re-embed, so the real model. let root = cli.require_vault(None)?; let vault = open_vault(root, true)?; - // Kind dispatch (§9b #8): an existing directory moves as a folder (every file - // under it, one rename); otherwise the extension picks the file arm. The human - // "Moved" line differs per arm; the rewrite tally is shared. + // Kind dispatch (§9b #8): an existing directory is a folder, else the extension decides. if is_dir_arg(root, from) { emit(cli.json, &vault.move_dir(from, to)?, |report| { println!( @@ -79,14 +68,10 @@ pub fn cmd_mv(cli: &Cli, from: &str, to: &str) -> Result<(), CliError> { } pub fn cmd_rm(cli: &Cli, target: &str, recursive: bool) -> Result<(), CliError> { - // A delete removes files and index rows but never rewrites a body - // (inbound links dangle, they aren't repaired) → **model-free**, like - // the desktop's delete; still an explicit vault, like every write. + // Never rewrites a body, so model-free. let root = cli.require_vault(None)?; let vault = open_vault(root, false)?; - // Kind dispatch (§9b #8), mirroring `mv`: an existing directory deletes - // as a folder — gated on --recursive, the CLI's stand-in for the - // desktop's confirm dialog — else the extension picks the file arm. + // Kind dispatch as in `mv`; a folder needs --recursive, the CLI's confirm dialog. if is_dir_arg(root, target) { if !recursive { return Err(CliError::RecursiveRequired(target.to_string())); @@ -118,9 +103,7 @@ pub fn cmd_link( edge_type: &str, explanation: Option<&str>, ) -> Result<(), CliError> { - // Link writes the source note's frontmatter and re-projects it → require an - // explicit vault (no silent cwd), opening with the same real model the index - // was built with (like `add`/`mv`); a frontmatter-only edit won't re-embed. + // Opens with the real model, like `add`/`mv`; a frontmatter-only edit won't re-embed. let vault = open_vault(cli.require_vault(None)?, true)?; let report = vault.link(src, dst, edge_type, explanation)?; emit(cli.json, &report, |report| { @@ -138,14 +121,12 @@ pub fn cmd_link( }) } -/// Whether a `mv`/`rm` argument names an existing directory under `root` — the -/// kind-dispatch test that routes to the folder arm (a trailing `/` is tolerated). +/// Whether a `mv`/`rm` argument names an existing directory (a trailing `/` is fine). fn is_dir_arg(root: &Path, arg: &str) -> bool { root.join(arg.trim_end_matches('/')).is_dir() } -/// The `mv` tally, shared by its three arms: how many inbound link targets were -/// rewritten across how many files — or that nothing linked to the moved item. +/// The `mv` tally, shared by its three arms. fn print_rewrite_tally(links_rewritten: usize, rewrote_files: usize) { if links_rewritten > 0 { println!("Rewrote {links_rewritten} inbound link(s) across {rewrote_files} file(s)."); @@ -154,8 +135,7 @@ fn print_rewrite_tally(links_rewritten: usize, rewrote_files: usize) { } } -/// The `rm` tally, shared by its three arms: which surviving files' links now -/// dangle — or that none were affected. +/// The `rm` tally, shared by its three arms. fn print_dangled(dangled: &[String]) { if dangled.is_empty() { println!("No inbound links affected."); diff --git a/crates/b2-cli/tests/cli.rs b/crates/b2-cli/tests/cli.rs index 08fab2d..672977b 100644 --- a/crates/b2-cli/tests/cli.rs +++ b/crates/b2-cli/tests/cli.rs @@ -1,21 +1,14 @@ -//! CLI-level tests: run the built `b2` binary against a temp copy of the -//! golden-vault fixture and assert its output — the "run a command against a -//! fixture, assert the output" surface invariants.md names. The binary path is -//! `CARGO_BIN_EXE_b2`, which cargo provides to integration tests (so no extra test -//! harness dependency is needed). The CLI is a dumb adapter over `b2_core::Vault`; -//! these prove the wiring + output shape, not engine behavior (that's the façade -//! and engine tests). +//! CLI tests: run the built `b2` binary against a temp copy of the golden vault and +//! assert its output. They prove the wiring and output shape, not engine behavior. use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; -/// The golden note the graph assertions hang off, by the thing that identifies it: -/// its vault-relative path (L1). +/// The golden note the graph assertions hang off (L1: a note is its path). const MEMORY_PATH: &str = "concepts/memory.md"; -/// A temp copy of the golden vault, so no test can mutate the repo fixtures. The -/// `TempDir` guard is returned so it outlives the test. +/// A temp copy of the golden vault; keep the returned guard alive for the test. fn golden_vault() -> (tempfile::TempDir, PathBuf) { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); @@ -38,9 +31,7 @@ fn copy_dir(src: &Path, dst: &Path) { } } -/// Run `b2 ` and capture the result. The suite runs under the fake -/// embedder (`B2_EMBEDDER=fake`) so CI never downloads or runs the real model — it -/// proves the wiring + output shape, not model quality (CLAUDE.md). +/// Run `b2 ` under the fake embedder, so CI never runs the real model. fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_b2")) .env("B2_EMBEDDER", "fake") @@ -49,14 +40,14 @@ fn run(args: &[&str]) -> Output { .expect("b2 binary runs") } -/// Run `b2 -C ` (the common case: point at a vault). +/// Run `b2 -C `. fn run_in(vault: &Path, args: &[&str]) -> Output { let mut full = vec!["-C", vault.to_str().unwrap()]; full.extend_from_slice(args); run(&full) } -/// Run `b2 ` with `B2_VAULT_PATH` set (and no `-C`) — the env-var path. +/// Run `b2 ` with `B2_VAULT_PATH` set and no `-C`. fn run_with_vault_env(vault: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_b2")) .env("B2_EMBEDDER", "fake") @@ -66,8 +57,7 @@ fn run_with_vault_env(vault: &Path, args: &[&str]) -> Output { .expect("b2 binary runs") } -/// Run `b2 -C ` with `input` piped to the child's stdin — the path -/// `b2 write` reads its new body from (an agent, `cat file |`, …). +/// Run `b2 -C ` with `input` on stdin (how `b2 write` takes a body). fn run_in_stdin(vault: &Path, args: &[&str], input: &str) -> Output { use std::io::Write as _; use std::process::Stdio; @@ -97,17 +87,15 @@ fn stderr(o: &Output) -> String { String::from_utf8(o.stderr.clone()).unwrap() } -/// The rows of a `search --json` payload. Since GH #202 that payload is an -/// **object** — the served rows plus D2's query-level verdict, which has nowhere -/// to live in a bare array — so every caller reaches for `results` rather than -/// treating the whole document as the list. +/// The rows of a `search --json` payload, an object since GH #202 (it also carries D2's +/// query-level verdict). fn results_of(v: &Value) -> &[Value] { v["results"] .as_array() .unwrap_or_else(|| panic!("search --json is an object with a `results` array: {v}")) } -/// Reindex a fresh golden vault; returns the guard + root ready for querying. +/// A reindexed golden vault, ready for querying. fn reindexed() -> (tempfile::TempDir, PathBuf) { let (tmp, root) = golden_vault(); let out = run_in(&root, &["reindex"]); @@ -136,7 +124,6 @@ fn reindex_reports_counts_human_and_json() { "a reindex writes nothing to the vault, so it reports no stamps (GH #170)" ); - // the index + log folder is created inside the vault (one portable folder). assert!(root.join(".b2/b2.sqlite").is_file()); } @@ -144,22 +131,18 @@ fn reindex_reports_counts_human_and_json() { fn reindex_is_incremental_and_force_reembeds() { let (_g, root) = golden_vault(); - // First reindex embeds both notes. let first = run_in(&root, &["--json", "reindex"]); assert!(first.status.success(), "{}", stderr(&first)); let v: Value = serde_json::from_slice(&first.stdout).unwrap(); assert_eq!(v["indexed"], 2); assert_eq!(v["embedded"], 2); - // Nothing changed on disk → the second reindex re-embeds nothing. let again = run_in(&root, &["--json", "reindex"]); let v: Value = serde_json::from_slice(&again.stdout).unwrap(); assert_eq!(v["embedded"], 0, "unchanged notes are not re-embedded"); - // --force re-chunks every note; whether it re-*embeds* is content's to decide. - // Unchanged text hashes to vectors already stored (M4), so a forced pass over an - // untouched vault correctly reports no embedding work rather than recomputing - // bytes it already has. + // --force re-chunks every note, but unchanged text hashes to vectors already + // stored (M4), so nothing is re-embedded. let forced = run_in(&root, &["--json", "reindex", "--force"]); let v: Value = serde_json::from_slice(&forced.stdout).unwrap(); assert_eq!(v["indexed"], 2, "--force re-projects everything"); @@ -179,7 +162,6 @@ fn reindex_dry_run_previews_and_writes_nothing() { .unwrap(); let before = std::fs::read_to_string(root.join("fresh.md")).unwrap(); - // JSON: honest `would_*` keys, never the past-tense reindex shape. let json = run_in(&root, &["--json", "reindex", "--dry-run"]); assert!(json.status.success(), "{}", stderr(&json)); let v: Value = serde_json::from_slice(&json.stdout).unwrap(); @@ -187,15 +169,11 @@ fn reindex_dry_run_previews_and_writes_nothing() { assert_eq!(v["would_embed"], 3); assert!(v.get("indexed").is_none(), "not the real-reindex shape"); - // Human: says it's a preview. let human = run_in(&root, &["reindex", "--dry-run"]); assert!(human.status.success(), "{}", stderr(&human)); assert!(stdout(&human).contains("Dry run"), "{:?}", stdout(&human)); - // Nothing was indexed, and the work is still pending — a real reindex now - // embeds all 3. (That a *real* reindex leaves the vault byte-identical too is - // W1's business, asserted in the engine suite; here the point is the dry run - // did no index work.) + // The work is still pending: a real reindex now embeds all 3. assert_eq!( std::fs::read_to_string(root.join("fresh.md")).unwrap(), before @@ -206,10 +184,8 @@ fn reindex_dry_run_previews_and_writes_nothing() { assert_eq!(v["embedded"], 3, "the dry-run did no embedding work"); } -/// Hold a vault's reindex lock exactly the way a running `b2 reindex` does — take the -/// advisory lock, then stamp a pid into it — so the cross-process readers (`b2 status`, -/// `b2 reindex --cancel`) see what a live run presents. The returned `File` must outlive -/// the assertions: dropping it releases the lock, i.e. ends the "run". +/// Hold a vault's reindex lock the way a running `b2 reindex` does (lock, then stamp a +/// pid). Dropping the returned `File` releases the lock, ending the "run". fn hold_reindex_lock(vault: &Path, pid: u32) -> std::fs::File { use std::io::Write as _; let dir = vault.join(".b2"); @@ -231,8 +207,7 @@ fn hold_reindex_lock(vault: &Path, pid: u32) -> std::fs::File { #[test] fn reindex_records_its_pid_in_the_lock() { let (_g, root) = golden_vault(); - // Spawned rather than `run_in`, so the test knows which pid to expect: the address - // `--cancel` (and a manual `kill`) signals is the running process's own (GH #55). + // Spawned rather than `run_in`, so the test knows which pid to expect (GH #55). let child = Command::new(env!("CARGO_BIN_EXE_b2")) .env("B2_EMBEDDER", "fake") .args(["-C", root.to_str().unwrap(), "reindex"]) @@ -256,15 +231,15 @@ fn reindex_records_its_pid_in_the_lock() { fn status_reports_the_running_reindex_and_its_pid() { let (_g, root) = reindexed(); - // Nothing in flight — even though the finished run above left its pid in the lock. - // The *lock*, not the file's contents, answers "is a reindex running". + // The finished run left its pid in the file, but the lock, not the contents, says + // whether a reindex is running. let idle = run_in(&root, &["--json", "status"]); assert!(idle.status.success(), "{}", stderr(&idle)); let v: Value = serde_json::from_slice(&idle.stdout).unwrap(); assert_eq!(v["reindex_running"], false); assert!(v["reindex_pid"].is_null(), "no run, no pid: {v}"); - // A run in flight: reported, and named. (A synthetic pid — `status` only prints it.) + // A synthetic pid: `status` only prints it. let lock = hold_reindex_lock(&root, 4242); let busy = run_in(&root, &["--json", "status"]); assert!(busy.status.success(), "{}", stderr(&busy)); @@ -284,13 +259,12 @@ fn status_reports_the_running_reindex_and_its_pid() { #[test] fn cancel_signals_the_process_the_lock_names() { use std::os::unix::process::ExitStatusExt as _; - /// SIGINT — what Ctrl-C raises, and so what `--cancel` must raise: one cancel path. + /// What Ctrl-C raises, so `--cancel` shares its path. const SIGINT: i32 = 2; let (_g, root) = reindexed(); - // A stand-in for a run backgrounded with `b2 reindex &`: a child that just sits - // there until signalled. What `--cancel` owns is *delivering SIGINT to the pid the - // lock names*; what the real reindex then does with it is the shipped Ctrl-C path. + // A stand-in for a backgrounded run: `--cancel` owns delivering SIGINT to the pid + // the lock names, not what the reindex does with it. let mut victim = Command::new("sleep") .arg("30") .spawn() @@ -326,8 +300,7 @@ fn cancel_reports_json_for_agents() { let out = run_in(&root, &["--json", "reindex", "--cancel"]); assert!(out.status.success(), "{}", stderr(&out)); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); - // Honest tense, like the dry-run's `would_*` keys: the request landed; the run - // itself stops at its next batch boundary and reports the partial work. + // Honest tense: the request landed; the run stops at its next batch boundary. assert_eq!(v["signalled"], true); assert_eq!(v["pid"], victim.id()); assert!(v.get("cancelled").is_none(), "not the reindex-report shape"); @@ -338,9 +311,7 @@ fn cancel_reports_json_for_agents() { #[test] fn cancel_with_no_run_in_flight_is_an_error() { - // The reindex left its pid behind in the lock (nothing clears it), but the lock is - // free — so there is nothing to cancel, and nothing to signal. A stale pid must - // never be mistaken for a live run. + // The lock file still holds the finished run's pid; a stale pid is not a live run. let (_g, root) = reindexed(); let out = run_in(&root, &["reindex", "--cancel"]); assert!( @@ -353,7 +324,7 @@ fn cancel_with_no_run_in_flight_is_an_error() { stderr(&out) ); - // Same on a vault that has never been indexed at all (no lock file to read). + // Never indexed: no lock file at all. let (_g2, fresh) = golden_vault(); let out = run_in(&fresh, &["reindex", "--cancel"]); assert!( @@ -369,8 +340,7 @@ fn cancel_with_no_run_in_flight_is_an_error() { #[test] fn cancel_refuses_without_an_explicit_vault() { - // `--cancel` is a `reindex` invocation and keeps its guard: with no vault it must - // refuse rather than fall back to the cwd and peek at some other vault's lock. + // `--cancel` keeps reindex's explicit-vault guard rather than falling back to cwd. let out = Command::new(env!("CARGO_BIN_EXE_b2")) .env("B2_EMBEDDER", "fake") .env_remove("B2_VAULT_PATH") @@ -399,17 +369,14 @@ fn cancel_conflicts_with_the_flags_that_would_run_a_reindex() { args.join(" ") ); } - // And nothing ran: `--cancel` signals, it never indexes. + // `--cancel` signals; it never indexes. assert!(!root.join(".b2/b2.sqlite").exists()); } #[test] fn write_commands_refuse_without_an_explicit_vault() { - // Every command that writes to the vault (builds the index, or creates/moves/edits - // notes) must fail loudly when no vault is given — never silently touch the current - // directory (the stale-binary / typo'd-env footgun that left a stray `.b2/`). Reads - // (search/neighbors/explain/similar) keep the cwd default and are intentionally out. - // env_remove guards against a B2_VAULT_PATH leaking in from the shell. + // Writing commands never fall back to the cwd; reads keep that default and are out + // of scope. env_remove guards against a B2_VAULT_PATH leaking in from the shell. let write_cmds: &[&[&str]] = &[ &["reindex"], &["add", "notes/new"], @@ -442,7 +409,6 @@ fn write_commands_refuse_without_an_explicit_vault() { #[test] fn reindex_accepts_a_positional_vault() { let (_g, root) = golden_vault(); - // the spec's `b2 reindex [vault]` form, no -C. let out = run(&["reindex", root.to_str().unwrap()]); assert!(out.status.success(), "{}", stderr(&out)); assert!(stdout(&out).contains("Indexed 2")); @@ -451,7 +417,6 @@ fn reindex_accepts_a_positional_vault() { #[test] fn b2_vault_path_env_var_points_at_the_vault() { let (_g, root) = golden_vault(); - // No -C and not run from inside the vault: the vault comes from $B2_VAULT_PATH. let out = run_with_vault_env(&root, &["reindex"]); assert!(out.status.success(), "{}", stderr(&out)); assert!(stdout(&out).contains("Indexed 2"), "{:?}", stdout(&out)); @@ -460,7 +425,6 @@ fn b2_vault_path_env_var_points_at_the_vault() { #[test] fn explicit_flag_overrides_b2_vault_path_env_var() { - // Two distinct vaults: one named by $B2_VAULT_PATH, one by -C. The flag must win. let (_g_env, env_root) = golden_vault(); let (_g_flag, flag_root) = golden_vault(); @@ -472,7 +436,6 @@ fn explicit_flag_overrides_b2_vault_path_env_var() { .expect("b2 binary runs"); assert!(out.status.success(), "{}", stderr(&out)); - // Only the -C vault got an index; the env-named vault was untouched. assert!( flag_root.join(".b2/b2.sqlite").is_file(), "the -C vault should be indexed" @@ -489,7 +452,6 @@ fn explicit_flag_overrides_b2_vault_path_env_var() { fn add_creates_a_note_human_and_json() { let (_g, root) = golden_vault(); - // Human: reports the created path. let human = run_in( &root, &[ @@ -509,21 +471,19 @@ fn add_creates_a_note_human_and_json() { stdout(&human) ); - // The file exists with the titled frontmatter and the body — and nothing else: - // projecting it added no key of B2's (W1). + // Titled frontmatter and body, and no key of B2's (W1). let text = std::fs::read_to_string(root.join("notes/gadgets.md")).unwrap(); assert!(!text.contains("b2id"), "nothing is stamped: {text}"); assert!(text.contains(r#"title: "All about gadgets""#), "{text}"); assert!(text.contains("Gadgets are handy little devices."), "{text}"); - // Immediately searchable (keyword half is real even under the fake embedder). + // Immediately searchable (the keyword half is real under the fake embedder). let search = run_in(&root, &["--json", "search", "gadgets"]); let v: Value = serde_json::from_slice(&search.stdout).unwrap(); assert!(results_of(&v) .iter() .any(|h| h["path"] == "notes/gadgets.md")); - // JSON: a new note at a different path returns the report shape. let json = run_in(&root, &["--json", "add", "notes/another"]); assert!(json.status.success(), "{}", stderr(&json)); let v: Value = serde_json::from_slice(&json.stdout).unwrap(); @@ -557,8 +517,7 @@ fn add_invalid_path_fails_cleanly() { fn write_replaces_body_from_stdin_and_reprojects() { let (_g, root) = reindexed(); - // Overwrite memory's body with piped Markdown, addressed by path (L1) — in the - // extensionless wikilink form, which the resolver's `.md` ladder accepts. + // Addressed in the extensionless wikilink form, which the resolver accepts. let out = run_in_stdin( &root, &["write", "concepts/memory"], @@ -571,8 +530,6 @@ fn write_replaces_body_from_stdin_and_reprojects() { stdout(&out) ); - // Markdown-first + byte-honest: the frontmatter is untouched, the body is the - // piped text verbatim, and the old body is gone. let text = std::fs::read_to_string(root.join("concepts/memory.md")).unwrap(); assert!( text.starts_with("---\ntype: concept\ntitle: \"Human memory\"\n"), @@ -587,7 +544,7 @@ fn write_replaces_body_from_stdin_and_reprojects() { "old body replaced: {text}" ); - // Re-projected without a reindex: the new content is searchable, the old is not. + // Re-projected without a reindex. let hit = run_in(&root, &["--json", "search", "marmots"]); let v: Value = serde_json::from_slice(&hit.stdout).unwrap(); assert!( @@ -606,8 +563,7 @@ fn write_json_returns_path_and_new_revision() { assert!(out.status.success(), "{}", stderr(&out)); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!(v["path"], "concepts/memory.md"); - // The new revision is the blake3 of the final bytes — nonempty, and a *later* - // save would chain on it (the desktop's guard token, surfaced for agents too). + // The revision a later save chains on (the desktop's guard token). assert!(v["revision"].as_str().is_some_and(|s| !s.is_empty())); } @@ -639,12 +595,12 @@ fn explain_shows_connections_human_and_json() { assert!(json.status.success(), "{}", stderr(&json)); let v: Value = serde_json::from_slice(&json.stdout).unwrap(); assert_eq!(v["path"], "notes/spaced-repetition.md"); - // Title is the filename (data-model.md §1), not the frontmatter `title:`. + // Title is the filename (data-model.md §1), not frontmatter `title:`. assert_eq!(v["title"], "spaced-repetition"); let conns = v["connections"].as_array().expect("connections array"); assert_eq!(conns.len(), 2); assert!(conns.iter().all(|c| c["direction"] == "outbound")); - // The two homes: the bare body link vs the typed `b2_relations:` entry. + // The bare body link and the typed `b2_relations:` entry. assert!(conns .iter() .any(|c| c["label"] == "references" && c["origin"] == "inline")); @@ -656,7 +612,6 @@ fn explain_shows_connections_human_and_json() { #[test] fn explain_reports_an_orphan() { let (_g, root) = reindexed(); - // A note nothing links to and that links to nothing. let out = run_in(&root, &["add", "islands/lonely", "--content", "By itself."]); assert!(out.status.success(), "{}", stderr(&out)); @@ -686,17 +641,14 @@ fn neighbors_resolve_in_both_authored_path_forms() { let by_path = run_in(&root, &["neighbors", "notes/spaced-repetition"]); assert!(by_path.status.success(), "{}", stderr(&by_path)); let out = stdout(&by_path); - // outbound edges: the verbs themselves, resolved to the target's title (its - // filename, data-model.md §1). assert!(out.contains("supports"), "{out}"); assert!(out.contains("references"), "{out}"); assert!(out.contains("memory"), "{out}"); - // The other end, addressed with the `.md` a Markdown link would write. let by_full_path = run_in(&root, &["neighbors", MEMORY_PATH]); assert!(by_full_path.status.success(), "{}", stderr(&by_full_path)); let out = stdout(&by_full_path); - // memory sees the inbound inverse labels, from the SRS note. + // Inbound inverse labels, from the SRS note. assert!(out.contains("supported-by"), "{out}"); assert!(out.contains("referenced-by"), "{out}"); assert!(out.contains("spaced-repetition"), "{out}"); @@ -722,15 +674,12 @@ fn neighbors_unknown_note_fails_cleanly() { let (_g, root) = reindexed(); let out = run_in(&root, &["neighbors", "does/not/exist"]); assert!(!out.status.success(), "unknown note must be a nonzero exit"); - // actionable, on stderr, no panic / no stack trace. let err = stderr(&out); assert!(err.to_lowercase().contains("not found"), "stderr: {err}"); assert!(!err.contains("panicked"), "stderr: {err}"); } -/// A note whose only body link is a `[[folder]]` (GH #12): a note is one `.md` file, -/// so the folder resolves to nothing. `neighbors` and `explain` must surface it as -/// unresolved rather than silently drop it. +/// A note whose only body link is a `[[folder]]`, which resolves to nothing (GH #12). fn guide_with_dangling_link(root: &Path) { std::fs::write( root.join("guide.md"), @@ -747,15 +696,13 @@ fn neighbors_surfaces_unresolved_links_in_human_output() { let (_g, root) = reindexed(); guide_with_dangling_link(&root); - // Human output flags the broken link (it would otherwise vanish entirely). let human = run_in(&root, &["neighbors", "guide"]); assert!(human.status.success(), "{}", stderr(&human)); let text = stdout(&human).to_lowercase(); assert!(text.contains("unresolved"), "flags the broken link: {text}"); assert!(text.contains("hermes"), "names the target: {text}"); - // `--json` keeps its resolved-neighbors array contract — the note has no resolved - // neighbors, so it's empty (the structured unresolved data lives in `explain`). + // `--json` stays an array of resolved neighbors; unresolved data lives in `explain`. let json = run_in(&root, &["--json", "neighbors", "guide"]); assert!(json.status.success(), "{}", stderr(&json)); let v: Value = serde_json::from_slice(&json.stdout).unwrap(); @@ -799,7 +746,6 @@ fn search_finds_note_human_and_json() { "{:?}", stdout(&human) ); - // honesty caveat lives on stderr (human mode only). assert!( stderr(&human).to_lowercase().contains("semantic"), "expected a semantic-ranking caveat on stderr: {:?}", @@ -809,24 +755,18 @@ fn search_finds_note_human_and_json() { let json = run_in(&root, &["--json", "search", "forgetting"]); assert!(json.status.success(), "{}", stderr(&json)); let v: Value = serde_json::from_slice(&json.stdout).unwrap(); - // The contract is an OBJECT since GH #202: the rows, plus the query-level - // verdict that has nowhere to live in a bare array (invariants.md D2). let arr = results_of(&v); assert!(!arr.is_empty()); assert!(arr .iter() .any(|h| h["path"] == "notes/spaced-repetition.md")); - // The verdict is present and, under the fake embedder, is `null` — no - // calibrated bar for this model, so no verdict is offered rather than one - // guessed (M2). Never `false`: that would be "no matches" on a dev vault. + // No calibrated bar for the fake embedder, so `null`, never a guessed `false` (M2). assert!(v.get("vouched").is_some(), "the verdict travels: {v}"); assert!(v["vouched"].is_null(), "fake embedder has no bar: {v}"); - // Per-hit provenance rides along on each row, additively. assert!( arr[0].get("bm25_rank").is_some() && arr[0].get("cos").is_some(), "provenance flattened onto the row: {v}" ); - // --json stdout is pure data: no caveat leaks into it. assert!(!stdout(&json).to_lowercase().contains("semantic")); } @@ -839,13 +779,11 @@ fn search_respects_limit() { assert!(results_of(&v).len() <= 1); } -/// `--exclude` is the follow-up-search flag for agent loops: a re-query minus the -/// notes already inspected serves the next-ranked ones instead of the same head. +/// `--exclude` lets an agent re-query minus the notes it already inspected. #[test] fn search_exclude_drops_a_served_note_and_serves_the_rest() { let (_g, root) = reindexed(); - // Both golden notes serve for "memory"; excluding one leaves the other. let full = run_in(&root, &["--json", "search", "memory"]); let v: Value = serde_json::from_slice(&full.stdout).unwrap(); let served: Vec = results_of(&v) @@ -890,7 +828,6 @@ fn search_exclude_drops_a_served_note_and_serves_the_rest() { #[test] fn search_before_reindex_is_empty_but_succeeds() { let (_g, root) = golden_vault(); - // never reindexed → no hits, but a clean exit (not an error). let out = run_in(&root, &["--json", "search", "forgetting"]); assert!(out.status.success(), "{}", stderr(&out)); let v: Value = serde_json::from_slice(&out.stdout).unwrap(); @@ -899,10 +836,8 @@ fn search_before_reindex_is_empty_but_succeeds() { // --- connection discovery (③): similar + link ------------------------------ -/// A reindexed vault of mutually **unconnected** notes, so `b2 similar` has -/// candidates to surface (the golden vault's two notes are directly linked → none). -/// Under the fake embedder the KNN pool is the whole vault, so every other note is a -/// candidate — a reliably non-empty list. +/// A reindexed vault of unconnected notes, so `b2 similar` has candidates (the golden +/// vault's two notes are linked). Under the fake embedder every other note is one. fn discovery_vault() -> (tempfile::TempDir, PathBuf) { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); @@ -919,7 +854,7 @@ fn discovery_vault() -> (tempfile::TempDir, PathBuf) { (tmp, root) } -/// `b2 similar` as JSON (a pure read over stored vectors; no model call). +/// `b2 similar` as JSON. fn similar(root: &Path, note: &str) -> Vec { let out = run_in(root, &["--json", "similar", note]); assert!(out.status.success(), "similar: {}", stderr(&out)); @@ -934,7 +869,6 @@ fn similar(root: &Path, note: &str) -> Vec { fn similar_lists_candidates_json_and_human() { let (_g, root) = discovery_vault(); - // JSON: a non-empty, fully-resolved list, never including the anchor itself. let s = similar(&root, "alpha.md"); assert!(!s.is_empty(), "unconnected notes must surface candidates"); for c in &s { @@ -946,7 +880,6 @@ fn similar_lists_candidates_json_and_human() { ); } - // Human: prints score/path lines, not the empty-state message. let human = run_in(&root, &["similar", "alpha.md"]); assert!(human.status.success(), "{}", stderr(&human)); assert!( @@ -959,12 +892,10 @@ fn similar_lists_candidates_json_and_human() { #[test] fn similar_excludes_already_linked() { let (_g, root) = discovery_vault(); - // beta is a candidate of alpha before any link. assert!(similar(&root, "alpha.md") .iter() .any(|c| c["path"] == "beta.md")); - // link alpha → beta; beta is now a 1-hop neighbor and drops out of the list. let out = run_in( &root, &["link", "alpha.md", "beta.md", "--type", "supports"], @@ -988,11 +919,8 @@ fn similar_unknown_note_fails_cleanly() { assert!(!err.contains("panicked"), "no stack trace: {err}"); } -/// `--limit 0` asks for nothing and prints nothing. The empty-state copy reads -/// the *candidate set* ("nothing unlinked has stored vectors to compare"), and -/// a zero ask proves nothing about it — this vault has four candidates — so -/// printing that copy here would be the one way the command could still make -/// a claim it can't check (GH #197's empty-state honesty, at the degenerate ask). +/// `--limit 0` prints nothing: the empty-state copy makes a claim about the candidate +/// set that a zero ask can't check (GH #197). #[test] fn similar_limit_zero_prints_nothing_rather_than_a_claim() { let (_g, root) = discovery_vault(); @@ -1005,10 +933,8 @@ fn similar_limit_zero_prints_nothing_rather_than_a_claim() { ); } -/// `b2 similar NOTE --explain OTHER` (GH #236): the model-free explanation of one -/// card, read from the same computation as the list. JSON names both notes, the -/// standing (tagged by `kind`), and the passage pairs; the card it explains stands at -/// the rank the list showed it. +/// `b2 similar NOTE --explain OTHER` (GH #236) explains a card from the same computation +/// as the list, so it stands at the rank the list showed. #[test] fn similar_explain_describes_the_served_row() { let (_g, root) = discovery_vault(); @@ -1037,14 +963,12 @@ fn similar_explain_describes_the_served_row() { (pairs[0]["score"].as_f64().unwrap() - list[1]["score"].as_f64().unwrap()).abs() < 1e-9 ); - // Human: the standing in words, then the pairs. let human = run_in(&root, &["similar", "alpha.md", "--explain", &second]); assert!(human.status.success(), "{}", stderr(&human)); let text = stdout(&human); assert!(text.contains("#2"), "says where it stands: {text}"); assert!(text.contains("alpha"), "shows this note's passage: {text}"); - // Five notes under the fake embedder: nothing to grade against, and the output says - // so rather than printing pairs with no grade and no reason. + // Five notes under the fake embedder: nothing to grade against. assert!( text.contains("Ungraded"), "an ungraded explanation says so: {text}" @@ -1101,8 +1025,6 @@ fn link_writes_frontmatter_and_shows_in_both_directions() { assert!(out.status.success(), "{}", stderr(&out)); assert!(stdout(&out).to_lowercase().contains("linked")); - // Markdown-first: the typed relation lands in the source note's frontmatter, - // under the namespaced `b2_relations:` key… let body = std::fs::read_to_string(&src).unwrap(); assert!( body.contains("b2_relations:"), @@ -1110,7 +1032,6 @@ fn link_writes_frontmatter_and_shows_in_both_directions() { ); assert!(body.contains("supports"), "the verb is written: {body}"); - // …and the graph shows it outbound from alpha and inbound (backlink) at beta. let a = run_in(&root, &["--json", "neighbors", "alpha.md"]); let av: Value = serde_json::from_slice(&a.stdout).unwrap(); assert!( @@ -1150,7 +1071,6 @@ fn link_is_idempotent() { &root, &["link", "alpha.md", "beta.md", "--type", "supports"], ); - // a second identical link writes nothing. let out = run_in( &root, &[ @@ -1188,7 +1108,7 @@ fn link_invalid_type_fails_cleanly() { // structured debug logging (B2_LOG) // --------------------------------------------------------------------------- -/// Run `b2 -C ` with `B2_LOG` set — the structured-logging path. +/// Run `b2 -C ` with `B2_LOG` set. fn run_with_log(vault: &Path, log: &str, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_b2")) .env("B2_EMBEDDER", "fake") @@ -1207,12 +1127,11 @@ fn b2_log_emits_jsonl_on_stderr_and_stdout_stays_pure() { let out = run_with_log(&root, "debug", &["--json", "reindex"]); assert!(out.status.success(), "{}", stderr(&out)); - // stdout is still pure machine-readable data — no log line leaks into it. + // stdout stays pure data. let report: Value = serde_json::from_slice(&out.stdout).unwrap(); assert_eq!(report["indexed"], 2); - // stderr is JSON Lines: every line one flat JSON object (the reporting - // contract — pipeable into jq/DuckDB/pandas as-is). + // stderr is JSON Lines: one flat object per line. let err = stderr(&out); assert!(!err.is_empty(), "B2_LOG=debug produced no log output"); let mut sqlite_events = 0usize; @@ -1229,7 +1148,7 @@ fn b2_log_emits_jsonl_on_stderr_and_stdout_stays_pure() { "expected per-query timing events, got {sqlite_events}" ); - // Without B2_LOG/B2_DEBUG nothing is logged — stderr is silent on success. + // Without B2_LOG/B2_DEBUG, stderr is silent on success. let quiet = run_in(&root, &["--json", "reindex"]); assert!(quiet.status.success()); assert_eq!(stderr(&quiet), "", "logging must stay opt-in"); @@ -1240,7 +1159,7 @@ fn b2_log_file_captures_pure_jsonl_and_implies_debug() { let (_g, root) = golden_vault(); let log_path = root.join("run-log.jsonl"); - // B2_LOG_FILE alone (no B2_LOG) implies `debug` and routes the log to the file. + // B2_LOG_FILE alone implies `debug`. let run = |args: &[&str]| { let mut full = vec!["-C", root.to_str().unwrap()]; full.extend_from_slice(args); @@ -1254,7 +1173,7 @@ fn b2_log_file_captures_pure_jsonl_and_implies_debug() { let first = run(&["reindex"]); assert!(first.status.success(), "{}", stderr(&first)); - // Human mode + file sink: stderr carries no JSONL (the file is the pure capture). + // With a file sink, stderr carries no JSONL. assert!( !stderr(&first).contains("\"target\""), "log lines leaked to stderr: {}", @@ -1272,7 +1191,6 @@ fn b2_log_file_captures_pure_jsonl_and_implies_debug() { .count(); assert!(sqlite_events > 10, "got {sqlite_events} sqlite events"); - // Append mode: a second run accumulates rather than truncates. let second = run(&["search", "memory"]); assert!(second.status.success(), "{}", stderr(&second)); let grown = std::fs::read_to_string(&log_path).unwrap(); @@ -1286,8 +1204,7 @@ fn b2_log_file_captures_pure_jsonl_and_implies_debug() { // Resources slice 1 — explain/mv dispatch by argument shape (spec §5) // --------------------------------------------------------------------------- -/// `b2 explain ` renders the fallback card: metadata + backlinks, -/// and `--json` emits the ResourceExplainView verbatim. +/// `b2 explain ` renders the fallback card: metadata plus backlinks. #[test] fn explain_dispatches_to_the_resource_card() { let (_tmp, vault) = golden_vault(); @@ -1319,7 +1236,6 @@ fn explain_dispatches_to_the_resource_card() { assert_eq!(v["backlinks"][0]["caption"], "a tiny diagram"); assert_eq!(v["backlinks"][0]["embed"], true); - // Unknown resource path → generic, actionable message; nonzero exit. let missing = run_in(&vault, &["explain", "resources/nope.pdf"]); assert!(!missing.status.success()); assert!( @@ -1329,8 +1245,7 @@ fn explain_dispatches_to_the_resource_card() { ); } -/// `b2 mv ` moves the file and rewrites inbound links; the -/// human line matches the note-move shape. +/// `b2 mv ` moves the file and rewrites inbound links. #[test] fn mv_dispatches_to_the_resource_move() { let (_tmp, vault) = golden_vault(); @@ -1358,7 +1273,6 @@ fn mv_dispatches_to_the_resource_move() { assert!(body.contains("![d](../img/diagram.png)"), "{body}"); } -/// `b2 similar ` says "not yet" — honest, actionable, nonzero exit. #[test] fn similar_on_a_resource_is_honest() { let (_tmp, vault) = golden_vault(); @@ -1374,8 +1288,7 @@ fn similar_on_a_resource_is_honest() { ); } -/// `b2 rm ` deletes the file from the vault and the disk, reporting the -/// notes whose links now dangle — human and JSON shapes. +/// `b2 rm ` reports the notes whose links now dangle. #[test] fn rm_deletes_a_note_and_reports_dangled() { let (_g, root) = reindexed(); @@ -1387,14 +1300,13 @@ fn rm_deletes_a_note_and_reports_dangled() { assert!(out.contains("Links in 1 file(s) now unresolved"), "{out}"); assert!(!root.join("concepts/memory.md").exists()); - // The linker's body was never rewritten — its links now surface as unresolved. + // The linker's body is not rewritten, so its links surface as unresolved. let explain = run_in(&root, &["--json", "explain", "notes/spaced-repetition"]); let v: Value = serde_json::from_slice(&explain.stdout).unwrap(); assert_eq!(v["connections"].as_array().unwrap().len(), 0); assert_eq!(v["unresolved"].as_array().unwrap().len(), 2); } -/// `b2 rm --json` returns the delete report (agents read `dangled` directly). #[test] fn rm_json_shape() { let (_g, root) = reindexed(); @@ -1410,8 +1322,7 @@ fn rm_json_shape() { ); } -/// A folder delete refuses without -r/--recursive, and removes the whole -/// subtree with it — the CLI's stand-in for the desktop's confirm dialog. +/// `-r` is the CLI's stand-in for the desktop's confirm dialog. #[test] fn rm_folder_requires_recursive() { let (_g, root) = reindexed(); @@ -1438,7 +1349,7 @@ fn rm_folder_requires_recursive() { assert!(!root.join("resources").exists()); } -/// `b2 rm ` dispatches to the resource delete (extension-only rule). +/// Dispatch is by extension only. #[test] fn rm_dispatches_to_the_resource_delete() { let (_g, root) = reindexed(); @@ -1454,7 +1365,6 @@ fn rm_dispatches_to_the_resource_delete() { assert!(!root.join("resources/data.txt").exists()); } -/// Unknown targets fail cleanly with the generic, actionable message. #[test] fn rm_unknown_target_fails_cleanly() { let (_g, root) = reindexed(); @@ -1480,15 +1390,11 @@ fn rm_unknown_target_fails_cleanly() { // flow ④: grounded chat — `ask` and `chat` (GH #154) // --------------------------------------------------------------------------- // -// Every case here runs under the **fake chat provider** (`B2_LLM=fake`), the -// `B2_EMBEDDER=fake` sibling: no model server, no network, deterministic answers. -// So these prove the adapter — streaming shape, the JSONL contract, session -// history, the error phrasing — never answer quality, which is a real-model eval -// concern (crates/b2-llm/evals/). - -/// Run `b2 -C ` with both seams faked, optionally piping `input` -/// to stdin (what `chat` reads its turns from). `B2_LLM` is set rather than -/// inherited, so the suite's behavior can't depend on the developer's shell. +// These run under `B2_LLM=fake`, so they prove the adapter (streaming shape, JSONL, +// session history, error phrasing), never answer quality (crates/b2-llm/evals/). + +/// Run `b2 -C ` with both seams faked, optionally piping `input` to +/// stdin. `B2_LLM` is set, not inherited, so the developer's shell can't leak in. fn run_chat(vault: &Path, args: &[&str], input: Option<&str>) -> Output { use std::io::Write as _; use std::process::Stdio; @@ -1515,7 +1421,7 @@ fn run_chat(vault: &Path, args: &[&str], input: Option<&str>) -> Output { child.wait_with_output().expect("b2 binary completes") } -/// Parse an `--json` chat stream: one JSON object per line, in arrival order. +/// Parse an `--json` chat stream, one object per line. fn events(out: &Output) -> Vec { stdout(out) .lines() @@ -1525,11 +1431,8 @@ fn events(out: &Output) -> Vec { .collect() } -/// `ask --json` is a JSON Lines **event stream**: the tokens as they arrive, then -/// one final answer event carrying the resolved `AnswerView` (the adapters' shared -/// view type). The tokens must reassemble into exactly that answer — an agent -/// rendering the stream live and an agent reading only the last line must end up -/// with the same text. +/// `ask --json` streams token events, then one answer event. The tokens must reassemble +/// into exactly that answer, so live and last-line readers agree. #[test] fn ask_json_streams_tokens_then_the_resolved_answer() { let (_g, root) = reindexed(); @@ -1557,7 +1460,7 @@ fn ask_json_streams_tokens_then_the_resolved_answer() { ); assert_eq!(last["cancelled"], false); - // Citations resolve to real vault paths — the answer's evidence, as data. + // Citations resolve to real vault paths. let citations = last["citations"].as_array().expect("citations array"); assert!(!citations.is_empty(), "the fake cites every passage it got"); for c in citations { @@ -1570,8 +1473,7 @@ fn ask_json_streams_tokens_then_the_resolved_answer() { } } -/// `why` is `ask`'s sibling over the other streaming façade op: the same JSONL event -/// stream, with citations confined to the two notes the explanation is about. +/// `why` streams like `ask`, with citations confined to the two notes. #[test] fn why_json_streams_an_explanation_cited_to_the_two_notes() { let (_g, root) = reindexed(); @@ -1596,8 +1498,7 @@ fn why_json_streams_an_explanation_cited_to_the_two_notes() { assert!(path == anchor || path == candidate, "{path}"); } - // The turn is tool-using, and the answer says which B2 tools ran — the pair lookup - // among them, whoever made it. + // The answer says which B2 tools ran, the pair lookup among them. let tools = last["tools"].as_array().expect("tools array"); assert!( tools.iter().any(|t| t["name"] == "b2_passage_pairs"), @@ -1605,19 +1506,16 @@ fn why_json_streams_an_explanation_cited_to_the_two_notes() { ); assert!(tools.iter().all(|t| t["seeded"].is_boolean())); - // The human surface lists them under the sources. let out = run_chat(&root, &["why", anchor, candidate], None); assert!(stdout(&out).contains("B2 tools used:"), "{}", stdout(&out)); - // An unknown note is the same generic not-found every other command gives. let out = run_chat(&root, &["why", anchor, "nope.md"], None); assert!(!out.status.success()); assert!(stderr(&out).contains("nope.md"), "{}", stderr(&out)); } -/// The human surface: the answer on stdout as it streams, then its sources. The -/// fake-provider caveat is a stderr notice — stdout stays the answer, so -/// `b2 ask … > answer.txt` captures an answer and nothing else. +/// The fake-provider caveat goes to stderr, so `b2 ask … > answer.txt` captures only +/// the answer and its sources. #[test] fn ask_prints_the_answer_then_its_sources_and_keeps_stdout_clean() { let (_g, root) = reindexed(); @@ -1642,9 +1540,8 @@ fn ask_prints_the_answer_then_its_sources_and_keeps_stdout_clean() { ); } -/// `chat` answers each piped turn and leaves on `/exit`. The turns after the first -/// are **follow-ups**: the façade sees a non-empty history and condenses, which is -/// the one externally visible difference between a chat turn and a bare `ask`. +/// `chat` answers each piped turn and leaves on `/exit`. Later turns carry history, +/// which is what distinguishes a chat turn from a bare `ask`. #[test] fn chat_answers_every_turn_and_carries_the_conversation_forward() { let (_g, root) = reindexed(); @@ -1652,7 +1549,7 @@ fn chat_answers_every_turn_and_carries_the_conversation_forward() { let out = Command::new(env!("CARGO_BIN_EXE_b2")) .env("B2_EMBEDDER", "fake") .env("B2_LLM", "fake") - // The façade's own span reports whether a turn had history behind it. + // The façade's span reports whether a turn had history. .env("B2_LOG", "b2::vault=debug") .args(["-C", root.to_str().unwrap(), "--json", "chat"]) .stdin(std::process::Stdio::piped()) @@ -1678,9 +1575,7 @@ fn chat_answers_every_turn_and_carries_the_conversation_forward() { .collect(); assert_eq!(answers.len(), 2, "one answer per piped turn"); - // The first turn is standalone; the second carries the first behind it. The - // façade's `ask` span records which, so the assertion reads the same fact the - // orchestration branched on rather than a proxy for it. + // Read from the `ask` span: the same fact the orchestration branched on. let multi_turn: Vec = stderr(&out) .lines() .filter_map(|l| serde_json::from_str::(l).ok()) @@ -1694,8 +1589,7 @@ fn chat_answers_every_turn_and_carries_the_conversation_forward() { ); } -/// `/exit` is not the only way out: end-of-input (Ctrl-D at a terminal, or a -/// finished script) ends the session just as cleanly. +/// End-of-input (Ctrl-D, or a finished script) ends the session like `/exit`. #[test] fn chat_ends_on_end_of_input() { let (_g, root) = reindexed(); @@ -1728,14 +1622,13 @@ fn ask_dead_endpoint(root: &Path, url: &str) -> Output { .expect("b2 binary runs") } -/// Chat with no model server is one actionable sentence naming the endpoint that -/// was actually tried (E4) — not a stack of transport internals, and not advice -/// about a program the user isn't running. The detail stays behind `B2_DEBUG`. +/// No model server is one actionable sentence naming the endpoint tried (E4); detail +/// stays behind `B2_DEBUG`. #[test] fn ask_without_a_model_server_says_so_and_names_the_endpoint() { let (_g, root) = reindexed(); - // Port 9 (discard) is reserved and never served: a refused connection, at once. + // Port 9 (discard) is never served: an immediate refusal. let out = ask_dead_endpoint(&root, "http://127.0.0.1:9/v1"); assert!(!out.status.success(), "a stopped server is an error"); let err = stderr(&out); @@ -1747,9 +1640,7 @@ fn ask_without_a_model_server_says_so_and_names_the_endpoint() { err.contains("--llm-url"), "the fix names the knob that sets the endpoint: {err}" ); - // Nothing about this endpoint is Ollama, and `--llm-url` also points at LM - // Studio, llama.cpp, vLLM and cloud providers — so `ollama serve` here would - // be instructions for a program that isn't in the picture. + // A non-Ollama endpoint gets no `ollama serve` advice. assert!( !err.to_lowercase().contains("ollama"), "advice about the wrong program: {err}" @@ -1761,15 +1652,12 @@ fn ask_without_a_model_server_says_so_and_names_the_endpoint() { assert!(stdout(&out).is_empty(), "nothing was answered"); } -/// …and when the endpoint *is* Ollama's, the message says so — there the daemon -/// really is the thing to start (the E4 sentence of GH #154). +/// On Ollama's port, the fix is to start the daemon (E4, GH #154). #[test] fn an_unreachable_ollama_endpoint_names_ollamas_own_fix() { let (_g, root) = reindexed(); - // A name that cannot resolve (RFC 2606 reserves `.invalid`) on Ollama's port: - // the failure is immediate, and no Ollama a developer happens to be running - // can answer it instead. + // `.invalid` never resolves (RFC 2606), so a local Ollama can't answer instead. let out = ask_dead_endpoint(&root, "http://nothing.invalid:11434/v1"); assert!(!out.status.success()); let err = stderr(&out); @@ -1780,9 +1668,8 @@ fn an_unreachable_ollama_endpoint_names_ollamas_own_fix() { ); } -/// The `B2_VAULT_PATH` precedence rule, applied to the chat config: an explicit -/// flag beats the environment. Observable because the failure names the endpoint -/// that was tried. +/// An explicit flag beats the environment, as with `B2_VAULT_PATH`. Observable because +/// the failure names the endpoint tried. #[test] fn an_explicit_llm_url_beats_the_environment() { let (_g, root) = reindexed(); @@ -1813,8 +1700,6 @@ fn an_explicit_llm_url_beats_the_environment() { ); } -/// With no flag, the environment supplies the endpoint (and the default supplies -/// it when neither does) — the other half of the precedence rule. #[test] fn the_llm_url_environment_variable_is_used_when_no_flag_is_given() { let (_g, root) = reindexed(); diff --git a/crates/b2-core/Cargo.toml b/crates/b2-core/Cargo.toml index f29dded..422aca6 100644 --- a/crates/b2-core/Cargo.toml +++ b/crates/b2-core/Cargo.toml @@ -7,14 +7,12 @@ description = "B2 index engine — the SQLite-backed disposable projection of (M [dependencies] blake3 = "1.8.5" -# "trace" exposes SQLite's own sqlite3_trace_v2 profiler — per-statement SQL + -# execution time, the source of the `b2::sqlite` query-timing events (db.rs). +# "trace": per-statement timing for the `b2::sqlite` events (db.rs). rusqlite = { version = "0.40.1", features = ["bundled", "trace"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" -# Structured debug logging (facade only — emitting events costs nothing until an -# adapter installs a subscriber; the CLI's is opt-in via B2_LOG/B2_DEBUG). +# Facade only; adapters install the subscriber. tracing = "0.1.41" yaml-rust2 = "0.11.0" @@ -22,6 +20,5 @@ yaml-rust2 = "0.11.0" proptest = "1.11.0" rusqlite = "0.40.1" tempfile = "3.27.0" -# The logging integration test installs a real JSON subscriber to assert the -# emitted events are parseable, plottable JSONL (tests/logging.rs). +# For tests/logging.rs. tracing-subscriber = { version = "0.3.20", features = ["json"] } diff --git a/crates/b2-core/src/add.rs b/crates/b2-core/src/add.rs index 3637679..9cd873f 100644 --- a/crates/b2-core/src/add.rs +++ b/crates/b2-core/src/add.rs @@ -1,13 +1,7 @@ -//! Create a new note (the `b2 add` kernel op — CRUD's *create*). -//! -//! B2 authors a *new* file here, which is not the same as authoring a human's body: the -//! whole document is B2-minted on request, and its frontmatter is B2's managed zone. What -//! B2 still never does is inject structure into an *existing* note (ADR-0004). -//! -//! **Markdown-first**: write the `.md`, then project it from that source of truth. The -//! note is fully reconstructible from Markdown — a file at a path, and that path is its -//! identity (ADR-0003) — so `add` records nothing durable of its own. The `created` date -//! is passed in, keeping `b2-core` wall-clock-free. +//! Create a new note (`b2 add`). B2 authors a new file on request; it never injects +//! structure into an existing note (ADR-0004). Markdown first, then project; the path is +//! the identity (ADR-0003), so nothing else is recorded. `created` is passed in, keeping +//! the core clock-free. use crate::error::{Error, Result}; use crate::ingest::{self, EmbedCtx, ProjectionCtx}; @@ -16,20 +10,14 @@ use serde::Serialize; use std::io::Write; use std::path::Path; -/// What [`add_note`] did: the created note's vault-relative path -/// (`.md`-normalized), which is its identity (L1). +/// The created note's `.md`-normalized path, its identity (L1). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AddReport { pub path: String, } -/// Create a new note at `path_input` (a `.md` suffix is optional and added if missing) -/// with a minimal, valid frontmatter and `content` as its body, then project it. -/// -/// Refuses to clobber: [`Error::AddTargetExists`] if anything already sits there, -/// [`Error::AddDestination`] for an empty/absolute/vault-escaping path. Missing parent -/// directories are created, mirroring `mv`. Projection **embeds** the new note's chunks, -/// so the caller must open with the embedder the index was built with. +/// Create a note at `path_input` (`.md` optional) with minimal frontmatter and `content`, +/// then project and embed it. Refuses to clobber; creates missing parent folders. pub fn add_note( ctx: EmbedCtx, path_input: &str, @@ -39,17 +27,12 @@ pub fn add_note( ) -> Result { let rel = write_new_note(ctx.proj.root, path_input, title, content, created)?; - // 2. Project from that Markdown: chunk + embed the body, and derive any edges - // its content authors. ingest::ingest_file(ctx, &rel)?; Ok(AddReport { path: rel }) } -/// The **model-free** sibling of [`add_note`] — the desktop's New-note action: same file -/// write, but projected through [`ingest::project_file`] with no embedder, the same pass -/// `Vault::write` runs after a save. The new chunks join the pending set for any later -/// embed, and a body-less note has nothing to embed anyway. Same refusals as [`add_note`]. -/// Taking a [`ProjectionCtx`] is what makes that posture the type system's to keep. +/// The model-free [`add_note`] (the desktop's New note): projected without embedding, +/// as `Vault::write` does. Same refusals. pub fn create_note( ctx: ProjectionCtx, path_input: &str, @@ -62,11 +45,7 @@ pub fn create_note( Ok(AddReport { path: rel }) } -/// The shared create step: validate `path_input`, refuse to clobber, render the -/// minimal frontmatter + body, and write the new file (creating missing parent -/// dirs). Markdown first (step 1 of both entry points). Returns the vault-relative -/// `.md` path — which is also the note's identity, so there is nothing further to -/// mint or record. +/// Validate, render and write the new file, refusing to clobber. Returns its path. fn write_new_note( vault_root: &Path, path_input: &str, @@ -76,8 +55,7 @@ fn write_new_note( ) -> Result { let rel = crate::pathspec::normalize_rel_md(path_input).map_err(Error::AddDestination)?; let doc = render_note(title, content, created); - // The refusal is the create itself, never a check before it: a file appearing at - // `rel` in between is refused rather than clobbered. + // The create itself is the refusal (see `place_new`). crate::import::place_new( &vault_root.join(&rel), || Error::AddTargetExists(rel.clone()), @@ -86,14 +64,9 @@ fn write_new_note( Ok(rel) } -/// Render a new note's text: a minimal valid frontmatter block followed by the body. -/// `title` is YAML-quoted and omitted entirely when `None`; `content` is trimmed of -/// trailing newlines and placed after one blank line. -/// -/// The template seeds only what can't be reconstructed later: `created` (lost forever if -/// not stamped now) and an optional `title`. `type:` is deliberately not seeded — ingest -/// defaults it to `"note"`, so stamping it would be redundancy (GH #80). No key is -/// `b2`-namespaced: these are seeded courtesies the human owns the moment they exist. +/// Render a new note: minimal frontmatter, then the body after a blank line. Seeds only +/// what can't be reconstructed later (`created`, optional `title`); not `type:` (GH #80). +/// The human owns these keys once written. fn render_note(title: Option<&str>, content: Option<&str>, created: &str) -> String { let mut s = String::from("---\n"); if let Some(t) = title { @@ -128,15 +101,13 @@ mod tests { fn omits_title_when_absent_and_body_when_empty() { let out = render_note(None, None, "2026-07-03"); assert_eq!(out, "---\ncreated: 2026-07-03\n---\n"); - // An explicitly-empty content string is treated like no body. let blank = render_note(None, Some("\n\n"), "2026-07-03"); assert_eq!(blank, "---\ncreated: 2026-07-03\n---\n"); } #[test] fn does_not_seed_type_ingest_defaults_it() { - // `type:` is not seeded (GH #80) — the template stamps only what can't be - // reconstructed later; ingest defaults an absent type to "note". + // GH #80. let out = render_note(None, None, "2026-07-03"); assert!(!out.contains("type:"), "{out}"); } @@ -149,15 +120,13 @@ mod tests { #[test] fn the_rendered_note_round_trips_and_parses_its_fields() { - // A note `add` writes must parse back with exactly the fields it set. let out = render_note(Some("Spaced repetition"), Some("Body."), "2026-07-03"); let parsed = crate::note::parse(&out); assert_eq!(parsed.as_str(), out, "renders round-trip losslessly"); let f = parsed.fields(); assert!(f.r#type.is_none(), "type is not seeded (GH #80)"); assert_eq!(f.created.as_deref(), Some("2026-07-03")); - // `title:` is inert (the display title is the filename), so it is checked as - // the frontmatter bytes it was written as. + // `title:` is inert, so check the bytes. assert_eq!( parsed.frontmatter(), Some("title: \"Spaced repetition\"\ncreated: 2026-07-03\n") diff --git a/crates/b2-core/src/chat.rs b/crates/b2-core/src/chat.rs index 13c5dcb..215538e 100644 --- a/crates/b2-core/src/chat.rs +++ b/crates/b2-core/src/chat.rs @@ -1,12 +1,9 @@ -//! Flow ④ — grounded chat: the core logic of `Vault::ask`, condense -> retrieve -> -//! assemble -> stream -> cite. This module owns what must be deterministic and testable -//! against [`FakeLlm`] — prompt assembly, the condensation degrade, citation-marker -//! parsing — while the façade owns retrieval and resolving markers into display views, -//! mirroring the `discover.rs`/`vault.rs` split. +//! Flow ④, grounded chat: the deterministic parts testable against [`FakeLlm`] (prompt +//! assembly, the condense degrade, citation parsing). The façade owns retrieval and +//! display views. //! -//! Chat is a **reader** whose output is never stored: no response caching, no `meta` rows, -//! session-only history. Model output is untrusted content (ADR-0016), enforced where it -//! renders. +//! Chat output is never stored, and history is session-only. Model output is untrusted +//! (ADR-0016), enforced where it renders. //! //! [`FakeLlm`]: crate::llm::FakeLlm @@ -16,37 +13,26 @@ use crate::llm::{ use std::collections::BTreeSet; use std::ops::ControlFlow; -/// How many passages retrieval hands the model — the "~8–12" of the spec -/// (GH #153). Retrieval goes through `Vault::search_chunks`, so the passage -/// view's pool discipline applies unchanged: a 10-ask reaches -/// `chunk_candidate_pool(10) = 60` candidates per signal (GH #141/#142) — -/// chat introduces no new candidate width. +/// How many passages retrieval hands the model (GH #153). Uses `search_chunks`'s pool, +/// so chat adds no new candidate width (GH #141/#142). pub const ASK_PASSAGES: usize = 10; -/// The grounded system prompt (flow ④ step 2). Prompt assembly is core logic: -/// authored here, rendered with the numbered passages by -/// [`ChatRequest::system_message`], asserted by the engine suite. The quoted -/// no-evidence sentence is [`crate::llm::NO_EVIDENCE_ANSWER`] verbatim (the -/// suite pins the containment), so the instruction and the fake that obeys it -/// can never drift apart. +/// The grounded system prompt (flow ④ step 2). Its no-evidence sentence must stay +/// [`crate::llm::NO_EVIDENCE_ANSWER`] verbatim; the suite pins it. pub const GROUNDED_SYSTEM_PROMPT: &str = "You are B2, answering questions about the user's own \ notes. Answer ONLY from the numbered passages below. Cite each claim with the supporting \ passage's [n] marker. If the passages do not support an answer, say \"I don't find that in \ your notes.\" — never answer from general knowledge."; -/// The condensation prompt (flow ④ step 0): rewrite a follow-up into a -/// standalone retrieval query ("tell me more about that" → a query that names -/// *that*). The reply is used as search input, never shown to the human. +/// The condensation prompt (flow ④ step 0): a follow-up rewritten as a standalone +/// search query, never shown to the human. pub const CONDENSE_SYSTEM_PROMPT: &str = "Rewrite the user's latest message as one standalone \ search query that names its subject explicitly, using the earlier conversation to fill in \ any pronouns or references. Reply with the query text only — no preamble, no quotes."; -/// Step 0 — condense a follow-up into a standalone retrieval query. Multi-turn -/// only; the caller skips this entirely for a single-turn ask. **This step can -/// never break chat**: on provider failure, a cancelled stream, or a blank -/// reply, it degrades to the raw question text — a worse retrieval query, not -/// an error. Tokens are consumed internally (the condensed query is search -/// input, not answer text), so nothing streams up from this call. +/// Step 0: condense a follow-up into a standalone retrieval query (multi-turn only). +/// Never breaks chat: any failure or blank reply degrades to the raw question. Nothing +/// streams up. pub fn condense_query(llm: &dyn LlmProvider, question: &str, history: &[ChatTurn]) -> String { let req = ChatRequest { kind: RequestKind::Condense, @@ -76,8 +62,7 @@ pub fn condense_query(llm: &dyn LlmProvider, question: &str, history: &[ChatTurn } } -/// Step 2 — assemble the grounded chat request: the system prompt, the -/// conversation ending in the current question, and the numbered passages. +/// Step 2: assemble the grounded chat request. pub fn build_request( question: &str, history: &[ChatTurn], @@ -93,22 +78,16 @@ pub fn build_request( } } -/// How many passage pairs a "why was this suggested?" explanation is grounded in. The -/// first is the pair the card's score came from; the next two show whether the -/// resemblance is one passage or a pattern. Up to `2 × WHY_PAIRS` passages, well under -/// [`ASK_PASSAGES`], so the explanation costs less context than an ordinary ask. +/// Passage pairs a Why? explanation is grounded in: the card's scoring pair, plus two to +/// show whether the resemblance is one passage or a pattern. pub const WHY_PAIRS: usize = 3; /// How many shared neighbours the facts block names before it counts the rest. const WHY_SHARED_NAMED: usize = 5; -/// The system prompt for explaining one *Similar & unlinked* row (`Vault::why_similar`). -/// Grounded chat with a narrower subject, so it keeps grounded chat's two rules — cite by -/// `[n]`, never reach for general knowledge — and the same no-evidence sentence -/// ([`crate::llm::NO_EVIDENCE_ANSWER`], pinned by the suite). The facts block -/// [`build_why_request`] appends is what B2's own discovery reads found for the pair; -/// the model's job is the part no statistic can do, saying what the matched passages -/// have in common in the human's terms. +/// The system prompt for explaining one Similar & unlinked row. Keeps grounded chat's +/// rules and no-evidence sentence ([`crate::llm::NO_EVIDENCE_ANSWER`], pinned by the +/// suite). pub const WHY_SYSTEM_PROMPT: &str = "You are B2, explaining why one of the user's notes was \ suggested as similar to the note they are reading. B2 suggests a note when one of its passages \ sits close to a passage of the open note in embedding space and the two notes are not linked \ @@ -126,42 +105,32 @@ answer from general knowledge."; /// `(anchor marker, candidate marker, score)`. pub type MarkedPair = (usize, usize, f64); -/// What B2's discovery reads found for one (anchor, suggested note) pair — the facts half -/// of a "why was this suggested?" request, gathered by the façade and rendered here so -/// the wording is core logic the suite can assert. Owned: built once per click and handed -/// straight to [`build_why_request`]. +/// What B2's discovery reads found for one (anchor, suggested note) pair: the facts half +/// of a Why? request. #[derive(Debug, Clone, PartialEq)] pub struct WhyFacts { - /// The note being read — the list's anchor. + /// The note being read: the list's anchor. pub anchor_path: String, pub anchor_title: Option, /// The suggested note the human asked about. pub candidate_path: String, pub candidate_title: Option, - /// `(rank, served)`: the 1-based position `similar` served the note at, out of how - /// many rows it served. `None` when the note is not in that list (already linked, or - /// beyond the list's length) — no rank is claimed for a row that would not be shown. + /// `(rank, served)`: 1-based position in `similar`'s list. `None` when not in it. pub rank: Option<(usize, usize)>, /// The row's strength z, when the list was graded (`SimilarView::z`). pub z: Option, /// Whether the two notes are already directly linked. pub linked: bool, - /// Notes directly linked to **both** — the triadic-closure signal discovery keeps in - /// the pool but does not rank on. + /// Notes directly linked to both. pub shared_neighbors: Vec, - /// The matched pairs, nearest first, as `(anchor marker, candidate marker, score)` - /// over the request's 1-based passage numbering. + /// The matched pairs, nearest first. pub pairs: Vec, /// Whether both notes had stored vectors to compare at all. pub embedded: bool, } -/// Assemble the "why was this suggested?" request: [`WHY_SYSTEM_PROMPT`], the rendered -/// facts, one user turn naming both notes, and the matched passages. A -/// [`RequestKind::Chat`] request — it is answered from numbered passages exactly as a -/// grounded ask is, so every provider (and the fake) serves it unchanged. It carries no -/// history: the evidence for one row is self-contained, and a follow-up question goes -/// through the ordinary `ask` with the explanation as its context. +/// Assemble the plain Why? request: prompt, facts, one user turn and the matched +/// passages. A [`RequestKind::Chat`] request, so every provider serves it unchanged. pub fn build_why_request(facts: &WhyFacts, passages: Vec) -> ChatRequest { ChatRequest { kind: RequestKind::Chat, @@ -173,8 +142,7 @@ pub fn build_why_request(facts: &WhyFacts, passages: Vec) -> Cha } } -/// The one user turn of a why-request, naming both notes by path (the identity the -/// tools take). +/// The one user turn of a why-request, naming both notes by path. fn why_turn(facts: &WhyFacts) -> ChatTurn { ChatTurn::user(&format!( "Why was {} suggested as similar to {}?", @@ -182,16 +150,9 @@ fn why_turn(facts: &WhyFacts) -> ChatTurn { )) } -// --- the tool-using turn --------------------------------------------------------- -// -// `Vault::why_similar` is an *agent* turn: the model is offered B2's read-only tools and -// makes the lookups itself. Three things keep that honest on a small local model. The -// first round is the lookup round: its text is never shown, a model that calls nothing -// there is handed the evidence instead, and one that skips the pair lookup has it made for -// it. The loop is bounded, and its last round offers no tools. And a model with no tool -// support at all degrades to [`build_why_request`], the same evidence in one plain request. +// --- the tool-using turn (see `Vault::why_similar`) ------------------------------- -/// The matched passage pairs between two notes — what `b2 similar` ranked the row on. +/// The matched passage pairs between two notes. pub const TOOL_PASSAGE_PAIRS: &str = "b2_passage_pairs"; /// The ranked *Similar & unlinked* list for a note. pub const TOOL_SIMILAR: &str = "b2_similar"; @@ -200,22 +161,17 @@ pub const TOOL_NEIGHBORS: &str = "b2_neighbors"; /// A note's passages, in order. pub const TOOL_READ: &str = "b2_read"; -/// Model calls per why-turn, the final answer included. The last round offers no tools, -/// so a model that would look things up forever still has to answer. +/// Model calls per why-turn, the final answer included. pub const MAX_TOOL_ROUNDS: usize = 4; -/// Tool calls honoured per round; any beyond this are dropped unanswered-for. A bound on -/// what one malformed or runaway completion can make B2 do. +/// Tool calls honoured per round; the rest are dropped. Bounds a runaway completion. pub const MAX_CALLS_PER_ROUND: usize = 4; -/// Passages one `b2_read` call returns. Enough to read a note's opening sections without -/// one call filling a small model's context. +/// Passages one `b2_read` call returns, small enough for a small model's context. pub const READ_PASSAGES: usize = 4; -/// The system prompt of the tool-using why-turn. [`WHY_SYSTEM_PROMPT`]'s rules — cite by -/// `[n]`, nothing from general knowledge, the same no-evidence sentence — plus what the -/// tools are for. The first lookup's result is already in the conversation when the model -/// first sees it. +/// The system prompt of the tool-using why-turn: [`WHY_SYSTEM_PROMPT`]'s rules plus what +/// the tools are for. pub const WHY_AGENT_SYSTEM_PROMPT: &str = "You are B2, explaining why one of the user's notes \ was suggested as similar to the note they are reading. B2 suggests a note when one of its \ passages sits close to a passage of the open note in embedding space and the two notes are not \ @@ -232,10 +188,8 @@ written exactly as the bracketed number — [1], [2] — never as \"passage 1\". suggestion, not a verdict: if the passages share little, say so plainly. If no tool returned \ any passage, say \"I don't find that in your notes.\" — never answer from general knowledge."; -/// The tools a why-turn offers — each one read-only `Vault` op, described for the model. -/// Every argument is optional and defaults to this turn's pair — `note` to the open note, -/// `candidate` (and `b2_read`'s `note`, since the open note is already on screen) to the -/// suggested one — so `{}` is always a valid call, which is what a small model sends. +/// The tools a why-turn offers, each a read-only `Vault` op. Every argument defaults to +/// this turn's pair, so `{}` (what a small model sends) is always valid. pub fn why_tools() -> Vec { let note = |what: &str| { serde_json::json!({ @@ -303,8 +257,7 @@ pub fn why_tools() -> Vec { } /// Assemble one round of the tool-using why-turn. `passages` is the citation ledger so -/// far; the pair lines are left out of the facts because the seeded `b2_passage_pairs` -/// result carries them, beside the passages they number. +/// far; pair lines come from the `b2_passage_pairs` result, not the facts. pub fn build_why_agent_request( facts: &WhyFacts, tools: Vec, @@ -324,8 +277,7 @@ pub fn build_why_agent_request( } } -/// One matched pair as a line of text — shared by the handoff's facts block and the -/// `b2_passage_pairs` tool result, so the two read the same. +/// One matched pair as a line of text, shared by the facts block and the tool result. pub fn pair_line( ordinal: usize, anchor_marker: usize, @@ -343,15 +295,13 @@ pub fn pair_line( ) } -/// One numbered passage as tool-result text — [`ContextPassage::block`], the layout -/// [`ChatRequest::system_message`] uses too, so a passage looks the same whichever way -/// it reached the model. +/// One numbered passage as tool-result text, in the same layout as +/// [`ChatRequest::system_message`]. pub fn passage_block(marker: usize, p: &ContextPassage) -> String { p.block(marker) } -/// The facts block, one line per finding, each naming the B2 read it came from so the -/// model (and a human reading a debug log) can tell a measured fact from an inference. +/// The facts block, one line per finding, each naming the B2 read it came from. fn why_facts_block(f: &WhyFacts, handoff: bool) -> String { let named = |path: &str, title: &Option| match title { Some(t) => format!("{path} (\"{t}\")"), @@ -376,8 +326,7 @@ fn why_facts_block(f: &WhyFacts, handoff: bool) -> String { "- `b2 similar`: ranked #{rank} of {served} unlinked candidates{strength}." )); } - // The graph facts are the handoff's only: a tool-using turn reads them itself, with - // `b2_neighbors`, and a fact the model looked up is one it is likelier to use. + // Graph facts are the handoff's only; a tool-using turn looks them up itself. if !handoff { return lines.join("\n"); } @@ -393,8 +342,7 @@ fn why_facts_block(f: &WhyFacts, handoff: bool) -> String { lines.push(if f.shared_neighbors.is_empty() { "- `b2 neighbors`: they share no linked neighbours.".to_string() } else { - // Named up to a handful: a hub-heavy vault can share dozens, and a long list is - // what a small model parrots back instead of answering. + // Capped: a small model parrots a long list back instead of answering. let named: Vec<&str> = f .shared_neighbors .iter() @@ -426,27 +374,21 @@ fn why_facts_block(f: &WhyFacts, handoff: bool) -> String { lines.join("\n") } -/// The conversation as the provider sees it: the adapter-held history, oldest -/// first, with the current question appended as the final user turn. +/// The history, oldest first, with the question appended as the final user turn. fn turns_with_question(history: &[ChatTurn], question: &str) -> Vec { let mut turns = history.to_vec(); turns.push(ChatTurn::user(question)); turns } -/// Step 4 (the parse half) — the distinct `[n]` markers in an answer that -/// actually name a passage: 1-based, `n <= passage_count`, ascending. A -/// hallucinated marker (`[0]`, `[99]` over ten passages, digits too long to -/// parse) simply doesn't resolve — it stays in the answer text verbatim -/// (model output is never rewritten) and contributes no citation. The façade -/// maps each surviving marker onto its passage for the display view. +/// Step 4: the distinct `[n]` markers naming a real passage (1-based, `n <= +/// passage_count`), ascending. A hallucinated marker just yields no citation. pub fn cited_markers(text: &str, passage_count: usize) -> Vec { let mut found = BTreeSet::new(); let bytes = text.as_bytes(); let mut i = 0; while i < bytes.len() { - // A byte-wise scan is UTF-8-safe: '[', ']' and ASCII digits never occur - // as continuation bytes, so a multi-byte character can't fake a marker. + // UTF-8-safe: '[', ']' and ASCII digits never occur as continuation bytes. if bytes[i] == b'[' { let mut j = i + 1; while j < bytes.len() && bytes[j].is_ascii_digit() { diff --git a/crates/b2-core/src/chunk.rs b/crates/b2-core/src/chunk.rs index ba9d95a..b6a1f24 100644 --- a/crates/b2-core/src/chunk.rs +++ b/crates/b2-core/src/chunk.rs @@ -1,55 +1,33 @@ -//! Body chunker — the qmd heuristic as adapted (docs/index-engine.md §1, GH #19). +//! Body chunker, the [tobi/qmd](https://github.com/tobi/qmd) heuristic adapted +//! (index-engine.md §1, GH #19): size-targeted, overlapping, Markdown-aware chunks, each +//! with a `heading_path` breadcrumb. At the target size it scans back for the best +//! structural break under a quadratic distance decay, and never cuts inside a code fence +//! or table (#41). //! -//! Splits a note body into **size-targeted, overlapping, Markdown-aware** chunks that -//! each carry a `heading_path` breadcrumb. The shape, borrowed wholesale from -//! [tobi/qmd](https://github.com/tobi/qmd): -//! -//! - accumulate toward `cfg.target_tokens` (default 450 — under bge's 512-token -//! truncation); -//! - at the target, scan **backward** over a `cfg.backscan_tokens` window and cut at the -//! best-scoring structural break (`cfg.weights`), weighted by a **quadratic distance -//! decay** so the boundary is the cleanest *near* the target, not an arbitrary slice; -//! - carry `cfg.overlap_frac` of the tail forward, so the `char_start..char_end` ranges -//! overlap rather than partition the body; -//! - never cut *inside* a fenced code block or a Markdown table — a forced cut with no -//! clean break is pushed past the block's end (#41), trading a slightly oversized chunk -//! for a coherent one (`protected_regions`); -//! - track a running heading stack and stamp each chunk's `heading_path`. -//! -//! The core is **model-free** (ADR-0005): there is no tokenizer here, so chunks are sized -//! by a cheap deterministic proxy (`chars / cfg.chars_per_token`) with the embedder's own -//! 512-token truncation as the hard backstop — a proxy under-estimate merely clips the -//! tail of one unusually dense chunk. `chunk_body` is a **pure function** of -//! `(body, ChunkConfig)`, so swapping chunkers is a pure re-projection. -//! -//! `char_start..char_end` always addresses the body slice that produced `text`, **except** -//! under `cfg.prepend_heading_path` — an eval knob (default off) that prepends the -//! breadcrumb into the embedded text, which the range then does not cover. - -/// The tuning surface for [`chunk_body`] (index-engine.md §1). Every lever that shapes a -/// cut lives here; `Default` reproduces the shipped values, so adapters pass -/// `&ChunkConfig::default()` and the eval harness (`make eval-sweep`) sweeps parameters -/// in one process (a loop over configs) rather than one recompile per cell. Kept a plain -/// params struct — no async/generics/traits — so it stays pure, deterministic, -/// model-free. +//! Model-free (ADR-0005): sized by a `chars / chars_per_token` proxy, with the embedder's +//! 512-token truncation as the backstop. A pure function of `(body, ChunkConfig)`. +//! `char_start..char_end` addresses the source slice, except that +//! `cfg.prepend_heading_path` adds text outside it. + +/// The tuning surface for [`chunk_body`] (index-engine.md §1). `Default` is the shipped +/// config; `make eval-sweep` varies it in one process. #[derive(Debug, Clone, PartialEq)] pub struct ChunkConfig { - /// Target chunk size in *estimated* tokens. Default 450 — headroom under bge's - /// 512-token truncation for the token proxy's error and any prepended breadcrumb. + /// Target chunk size in estimated tokens. 450 leaves headroom under bge's 512 for the + /// proxy's error and a prepended breadcrumb. pub target_tokens: usize, /// Fraction of a chunk re-shared with the next one. Default 0.15. pub overlap_frac: f32, - /// The model-free token proxy: `tokens ≈ chars / chars_per_token`. Default - /// 4.0 (English ≈ 4 chars/token; code and tables run denser — a lever, not a law). + /// The token proxy: `tokens ≈ chars / chars_per_token`. Default 4.0 (English; code + /// and tables run denser). pub chars_per_token: f32, /// How far back (in estimated tokens) the boundary search looks from the target. /// Default 200. pub backscan_tokens: usize, /// The Markdown break-point scorer (qmd's H1=100 … list=5). pub weights: BreakWeights, - /// Prepend `heading_path` into the *embedded* `text` (contextual chunk headers). - /// An eval-gated retrieval knob, measured rank-neutral; **default off**. Storing `heading_path` is - /// unconditional — this only controls whether it also seeds the vector. + /// Prepend `heading_path` into the embedded `text`. An eval knob, measured + /// rank-neutral; default off. `heading_path` is stored either way. pub prepend_heading_path: bool, } @@ -66,9 +44,7 @@ impl Default for ChunkConfig { } } -/// Markdown break-point weights (qmd's boundary scorer, index-engine.md §1). Higher = a cleaner -/// place to end a chunk. `heading[i]` is the weight of an H{i+1}; `word` is the -/// lowest-value fallback that lets a giant single-line paragraph still split. +/// Markdown break-point weights (index-engine.md §1). Higher = a cleaner place to cut. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BreakWeights { /// H1..H6 (index 0 = H1). qmd: H1=100, H2=90, then a gentle gradient. @@ -81,7 +57,7 @@ pub struct BreakWeights { pub list_item: u32, /// A plain paragraph/text line start. pub paragraph: u32, - /// A word boundary within a line — the finest fallback. + /// A word boundary within a line, so a giant one-line paragraph can still split. pub word: u32, } @@ -104,17 +80,14 @@ pub struct Chunk { pub seq: usize, pub char_start: usize, pub char_end: usize, - /// The token *estimate* (`chars / chars_per_token`) that sized this chunk — - /// not an exact token count (it was a whitespace word count under the old splitter). + /// The token estimate (`chars / chars_per_token`), not an exact count. pub token_count: usize, - /// The H1 › H2 › H3 breadcrumb the chunk falls under (`"A > B"`), or `None` - /// when the chunk starts before any heading. + /// The heading breadcrumb (`"A > B"`), or `None` before any heading. pub heading_path: Option, pub text: String, } -/// Chunk `body` into size-targeted, overlapping, Markdown-aware chunks (see the -/// module docs). Returns an empty vec for an empty/all-blank body. +/// Chunk `body` (see the module docs). Empty for an all-blank body. pub fn chunk_body(body: &str, cfg: &ChunkConfig) -> Vec { let body_len = body.len(); if body.trim().is_empty() { @@ -123,26 +96,21 @@ pub fn chunk_body(body: &str, cfg: &ChunkConfig) -> Vec { let (breaks, line_paths) = scan(body, &cfg.weights); - // Token params → char thresholds once (offsets are bytes; the proxy is uniform, - // so a token position is just `offset / chars_per_token`). + // Token params → byte thresholds once. let cpt = (cfg.chars_per_token as f64).max(0.1); let target_chars = ((cfg.target_tokens as f64) * cpt).round().max(1.0) as usize; let backscan_chars = ((cfg.backscan_tokens as f64) * cpt).round() as usize; let overlap_chars = ((cfg.target_tokens as f64) * (cfg.overlap_frac as f64) * cpt).round() as usize; - // #41: byte ranges (balanced code fences, GFM tables) a boundary must not cut - // *inside* — a forced cut with no clean break in the window is exactly where a - // bisected block would otherwise happen (this dense content is what the chars/4 - // token proxy mis-sizes). + // #41: fences and tables a boundary must not cut inside. let regions = protected_regions(body); let mut chunks: Vec = Vec::new(); let mut start = 0usize; loop { - // The remainder fits in one chunk → emit it and stop (never split off a - // final sliver). + // The remainder fits: emit it rather than split off a sliver. if body_len - start <= target_chars { push_chunk(&mut chunks, body, &line_paths, start, body_len, cfg); break; @@ -156,39 +124,32 @@ pub fn chunk_body(body: &str, cfg: &ChunkConfig) -> Vec { cfg.backscan_tokens, cpt, ); - // If that cut lands inside a fence/table, push it past the block's end so the - // whole block stays in one chunk. An oversized chunk (the embedder truncates - // at 512 tokens) is fine; a half-fence / header-less table is not. + // Push a cut inside a fence/table past its end: oversized beats a half-fence. let end = snap_past_region(end, ®ions); push_chunk(&mut chunks, body, &line_paths, start, end, cfg); if end >= body_len { break; } - // The next chunk's overlap tail must not start inside a block either — skip - // such a start to just past the block (no overlap into it, rather than a chunk - // that opens mid-fence). + // Nor may the next chunk start inside a block. let overlap_start = choose_overlap_start(&breaks, start, end, overlap_chars); start = snap_past_region(overlap_start, ®ions); } - // Re-number after the fact: an all-whitespace span is skipped by `push_chunk`, - // so `seq` is assigned here to stay contiguous and gap-free. + // `push_chunk` skips blank spans, so number contiguously here. for (i, c) in chunks.iter_mut().enumerate() { c.seq = i; } chunks } -/// A candidate chunk boundary at a byte `offset`, with the structural `score` of the -/// line/word that starts there (qmd's scorer — higher = a cleaner cut). +/// A candidate chunk boundary at a byte `offset`, with its structural `score`. struct Break { offset: usize, score: u32, } -/// Single pass over the body: the ordered break candidates (line starts scored -/// structurally, plus word starts as the fallback) and, per line start, the heading -/// breadcrumb in effect there. Both vecs come out sorted by offset. +/// One pass over the body: the break candidates, and the breadcrumb at each line start. +/// Both sorted by offset. fn scan(body: &str, w: &BreakWeights) -> (Vec, Vec<(usize, Option)>) { let mut breaks = Vec::new(); let mut line_paths = Vec::new(); @@ -203,8 +164,7 @@ fn scan(body: &str, w: &BreakWeights) -> (Vec, Vec<(usize, Option let trimmed = content.trim_start(); let is_fence = is_fence_line(trimmed); - // Classify the line for its break score, and detect a heading (never inside - // a fence — a `# comment` in code must not corrupt the breadcrumb). + // Never a heading inside a fence: a `# comment` in code isn't one. let (score, heading) = if content.trim().is_empty() { (w.blank_line, None) } else if is_fence { @@ -222,8 +182,7 @@ fn scan(body: &str, w: &BreakWeights) -> (Vec, Vec<(usize, Option (w.paragraph, None) }; - // Update the stack AFTER classifying, so a chunk starting on `## X` records a - // path that includes X (the section it opens). + // After classifying, so a chunk starting on `## X` includes X in its path. if let Some((level, text)) = heading { while stack.last().is_some_and(|(l, _)| *l >= level) { stack.pop(); @@ -245,19 +204,16 @@ fn scan(body: &str, w: &BreakWeights) -> (Vec, Vec<(usize, Option (breaks, line_paths) } -/// One `split_inclusive('\n')` line without its `\n` / `\r\n` ending. fn line_content(line: &str) -> &str { line.trim_end_matches('\n').trim_end_matches('\r') } -/// Whether a left-trimmed line opens or closes a fenced code block (```` ``` ```` or -/// `~~~`) — the one fence rule [`scan`] and [`protected_regions`] both track. +/// Whether a left-trimmed line is a code fence; shared by [`scan`] and [`protected_regions`]. fn is_fence_line(trimmed: &str) -> bool { trimmed.starts_with("```") || trimmed.starts_with("~~~") } -/// The ATX heading level (1..=6) if `trimmed` is a heading, else `None`. The `#` run -/// must be followed by a space or end-of-line, so `#tag` is not a heading. +/// The ATX heading level (1..=6), if any. `#tag` is not a heading. fn heading_level(trimmed: &str) -> Option { let hashes = trimmed.bytes().take_while(|&b| b == b'#').count(); if (1..=6).contains(&hashes) { @@ -269,7 +225,6 @@ fn heading_level(trimmed: &str) -> Option { None } -/// The heading's display text: the `#`s and any ATX closing `#`s stripped, trimmed. fn heading_text(trimmed: &str, level: u8) -> String { trimmed[level as usize..] .trim() @@ -278,7 +233,6 @@ fn heading_text(trimmed: &str, level: u8) -> String { .to_string() } -/// Whether `trimmed` opens a Markdown list item (`- `/`* `/`+ ` or `1. `/`1) `). fn is_list_item(trimmed: &str) -> bool { if let Some(rest) = trimmed.strip_prefix(['-', '*', '+']) { return rest.starts_with(' '); @@ -291,8 +245,7 @@ fn is_list_item(trimmed: &str) -> bool { false } -/// Append a low-weight break at each word start within a line (skipping the line -/// start, already a structural break), so a giant single-line paragraph can split. +/// A low-weight break at each word start after the line start. fn push_word_breaks(breaks: &mut Vec, content: &str, line_start: usize, word: u32) { let mut prev_ws = true; for (i, c) in content.char_indices() { @@ -307,7 +260,6 @@ fn push_word_breaks(breaks: &mut Vec, content: &str, line_start: usize, w } } -/// The heading breadcrumb (`"A > B"`) for a stack, or `None` when empty. fn join_path(stack: &[(u8, String)]) -> Option { if stack.is_empty() { None @@ -322,16 +274,13 @@ fn join_path(stack: &[(u8, String)]) -> Option { } } -/// The offset of the first break at or after `off`, or `None` if none exists. fn first_break_at_or_after(breaks: &[Break], off: usize) -> Option { let idx = breaks.partition_point(|b| b.offset < off); breaks.get(idx).map(|b| b.offset) } -/// Pick the chunk end: the break in `(start, anchor]` within the backscan window -/// maximizing `score · decay²`, where `decay = 1 - dist/backscan` falls off -/// quadratically with token distance from the target. Falls back to `anchor` when -/// the window holds no usable break. +/// The chunk end: the break in the backscan window maximizing `score · decay²`, with +/// `decay = 1 - dist/backscan`. Falls back to `anchor`. fn choose_end( breaks: &[Break], start: usize, @@ -358,8 +307,7 @@ fn choose_end( }; let decay = (1.0 - frac).max(0.0); let score = b.score as f64 * decay * decay; - // `>=` so that on a tie the larger offset (nearer the target, less content - // dropped) wins, since offsets are walked ascending. + // `>=`: on a tie the later offset, nearer the target, wins. if best.map(|(bs, _)| score >= bs).unwrap_or(true) { best = Some((score, b.offset)); } @@ -367,10 +315,8 @@ fn choose_end( best.map(|(_, o)| o).unwrap_or(anchor) } -/// The next chunk's start: the break inside `(start, end)` nearest to -/// `end - overlap`, preferring a cleaner (higher-scoring) boundary on a tie. Always -/// `> start` (so the walk makes progress); falls back to `end` (no overlap) when the -/// chunk has no interior break. +/// The next chunk's start: the interior break nearest `end - overlap`, cleaner on a tie. +/// Always `> start`, so the walk progresses; falls back to `end` (no overlap). fn choose_overlap_start(breaks: &[Break], start: usize, end: usize, overlap_chars: usize) -> usize { if overlap_chars == 0 { return end; @@ -380,7 +326,6 @@ fn choose_overlap_start(breaks: &[Break], start: usize, end: usize, overlap_char let hi = breaks.partition_point(|b| b.offset < end); let mut best: Option<((usize, u32), usize)> = None; for b in &breaks[lo..hi] { - // Order by (distance to desired, then higher score). let key = (b.offset.abs_diff(desired), u32::MAX - b.score); if best.map(|(bk, _)| key < bk).unwrap_or(true) { best = Some((key, b.offset)); @@ -389,11 +334,8 @@ fn choose_overlap_start(breaks: &[Break], start: usize, end: usize, overlap_char best.map(|(_, o)| o).unwrap_or(end) } -/// If `off` falls **strictly inside** one of the protected `regions` (a balanced -/// fenced code block or a GFM table, from [`protected_regions`]), return that region's -/// end so the boundary clears the block; otherwise return `off` unchanged. A cut -/// exactly at a region edge is already clean and left as-is. `regions` is sorted by -/// start and non-overlapping, so the first region reaching past `off` decides it. +/// `off`, or the end of the protected region it falls strictly inside. `regions` is sorted +/// and non-overlapping. fn snap_past_region(off: usize, regions: &[(usize, usize)]) -> usize { for &(rs, re) in regions { if rs >= off { @@ -406,22 +348,16 @@ fn snap_past_region(off: usize, regions: &[(usize, usize)]) -> usize { off } -/// Byte ranges `[start, end)` a chunk boundary must not fall **strictly inside** (#41): a -/// balanced fenced code block and a GFM table. Cutting at either edge is clean; cutting -/// the interior would embed an unbalanced fence, or orphan a table's header from its rows. -/// -/// Fence tracking mirrors [`scan`]'s; an **unterminated** fence is left unprotected rather -/// than swallowing the whole tail into one chunk. A table is a header row directly above a -/// delimiter row plus the contiguous rows beneath. Regions come out sorted and never -/// overlap (table scanning is suppressed inside a fence). +/// Byte ranges `[start, end)` a boundary must not fall strictly inside (#41): balanced code +/// fences and GFM tables. An unterminated fence is left unprotected rather than swallow the +/// tail. Sorted, non-overlapping. fn protected_regions(body: &str) -> Vec<(usize, usize)> { let mut regions: Vec<(usize, usize)> = Vec::new(); let mut in_fence = false; let mut fence_start = 0usize; - // Table accumulation across consecutive rows. - let mut prev_row_start: Option = None; // last row candidate — a header-in-waiting + let mut prev_row_start: Option = None; // a header-in-waiting let mut table_start: Option = None; // set once a delimiter row confirms a table - let mut table_end = 0usize; // end offset (past the newline) of the table's last row + let mut table_end = 0usize; let mut offset = 0usize; for line in body.split_inclusive('\n') { @@ -431,7 +367,7 @@ fn protected_regions(body: &str) -> Vec<(usize, usize)> { let is_fence = is_fence_line(trimmed); if is_fence { - // A fence edge ends any table in progress, then toggles the fence. + // A fence edge ends any table in progress. if let Some(ts) = table_start.take() { regions.push((ts, table_end)); } @@ -450,7 +386,7 @@ fn protected_regions(body: &str) -> Vec<(usize, usize)> { if is_table_row(trimmed) { if table_start.is_some() { - table_end = offset; // extend the active table by this row + table_end = offset; } else if is_table_delim(trimmed) { if let Some(header_start) = prev_row_start { table_start = Some(header_start); @@ -467,7 +403,7 @@ fn protected_regions(body: &str) -> Vec<(usize, usize)> { prev_row_start = None; } } - // A table running to EOF (an unterminated fence is intentionally left unprotected). + // A table running to EOF. if let Some(ts) = table_start.take() { regions.push((ts, table_end)); } @@ -476,24 +412,21 @@ fn protected_regions(body: &str) -> Vec<(usize, usize)> { regions } -/// Whether `trimmed` (already left-trimmed) could be a Markdown table row: non-empty -/// and carrying a `|` column separator. Both header and body rows qualify. +/// Whether `trimmed` could be a table row (header or body). fn is_table_row(trimmed: &str) -> bool { !trimmed.is_empty() && trimmed.contains('|') } -/// Whether `trimmed` is a GFM table **delimiter** row — the `| --- | :-: |` line under -/// the header. Requires a `|` (so a setext `---` underline or a `---` rule is *not* one) -/// and at least one `-`, with every character drawn from `-:| ` and nothing else. +/// Whether `trimmed` is a GFM delimiter row (`| --- | :-: |`). The `|` requirement keeps a +/// `---` rule or setext underline out. fn is_table_delim(trimmed: &str) -> bool { trimmed.contains('|') && trimmed.contains('-') && trimmed.chars().all(|c| matches!(c, '-' | ':' | '|' | ' ')) } -/// Emit `body[start..end]`, trimmed to its non-blank span (so `char_start..char_end` -/// stays exact and the text is clean); skip an all-whitespace span. `seq` is a -/// placeholder — [`chunk_body`] re-numbers at the end. +/// Emit `body[start..end]` trimmed to its non-blank span; skip a blank one. `seq` is set +/// later by [`chunk_body`]. fn push_chunk( chunks: &mut Vec, body: &str, @@ -526,8 +459,7 @@ fn push_chunk( }); } -/// The heading breadcrumb in effect at byte offset `cs` — the path of the last line -/// starting at or before it. +/// The breadcrumb in effect at byte offset `cs`. fn heading_path_at(line_paths: &[(usize, Option)], cs: usize) -> Option { let idx = line_paths.partition_point(|(ls, _)| *ls <= cs); idx.checked_sub(1).and_then(|i| line_paths[i].1.clone()) diff --git a/crates/b2-core/src/db.rs b/crates/b2-core/src/db.rs index 3296ae8..b0919a2 100644 --- a/crates/b2-core/src/db.rs +++ b/crates/b2-core/src/db.rs @@ -1,19 +1,13 @@ -//! Opening the index, the schema migration, and the projection helpers for the -//! Markdown-derived tiers: `notes`, `chunks` (+FTS5), the -//! `embeddings`/`note_centroids` vector tables, and the typed `edges` graph. Every -//! table here is a derived projection of Markdown — nothing is a source of truth -//! (ADR-0002). +//! Opening the index, the schema migration, and the projection helpers for `notes`, +//! `chunks` (+FTS5), the vector tables and the typed `edges` graph. Every table is a +//! derived projection of Markdown (ADR-0002). //! -//! **A note is keyed by its vault-relative path** (ADR-0003): `notes.path` is the -//! primary key and every child references it `ON DELETE CASCADE ON UPDATE CASCADE`. -//! The update half is what makes a B2-performed move a **re-key** rather than a -//! rebuild — one `UPDATE notes SET path` carries every derived row with it. It needs -//! `PRAGMA foreign_keys = ON`, set on every connection alongside `WAL`. +//! A note is keyed by its vault-relative path (ADR-0003); children reference it +//! `ON DELETE CASCADE ON UPDATE CASCADE`, so a B2-performed move is one `UPDATE notes +//! SET path` (needs `PRAGMA foreign_keys = ON`). //! -//! Vectors live in **plain tables**, scored in-process, content-addressed by the -//! blake3 of the chunk text (ADR-0006). The one bookkeeping cost is that a vector no -//! longer dies with its chunk — [`prune_orphan_vectors`] collects what nothing -//! references, on the same derived-data lifecycle as centroids. +//! Vectors live in plain tables, content-addressed by the blake3 of the chunk text +//! (ADR-0006), so they outlive their chunk; [`prune_orphan_vectors`] collects them. use crate::chunk::Chunk; use crate::embed::pack_f32; @@ -27,30 +21,21 @@ use std::path::Path; use std::sync::OnceLock; use std::time::Duration; -/// The B2 index schema version stamped into `meta.schema_version`. Bumping it is the -/// migration gate: on a **stale or unknown** stamp `migrate()` drops the derived tables -/// and lets the next `reindex` rebuild them — there are no migrations, by design -/// (ADR-0002). A stamp *above* this is the one mismatch that is not rebuilt: it was -/// written by a newer `b2`, so the index is refused untouched ([`refuse_if_newer`]). +/// The index schema version stamped into `meta.schema_version`. A stale or unknown +/// stamp drops the derived tables for the next `reindex` to rebuild; there are no +/// migrations (ADR-0002). A stamp above this is refused untouched ([`refuse_if_newer`]). /// -/// **2** dropped the suggestion machinery with the 2026-07-04 relator cut. **3** -/// replaced the `chunks_vec` vec0 virtual table with plain vector tables (ADR-0006); -/// a pre-3 index's orphaned `chunks_vec` entry stays inert in `sqlite_master`, -/// because its module is no longer linked to drop it. **4** added the `resources` -/// inventory and widened `edges` with resource targets. **5** switched `chunks_fts` -/// to `porter unicode61` (the GH #157 A/B's verdict). **6** re-keyed the whole index -/// on the vault-relative path and made `embeddings` content-addressed (GH #170). **7** -/// dropped the columns nothing read (`notes.type`, `description`, `updated`) and the -/// `note_aliases` table. +/// 3: plain vector tables (ADR-0006). 4: `resources`. 5: `porter unicode61` FTS (GH #157). +/// 6: path-keyed, content-addressed `embeddings` (GH #170). 7: unread columns and +/// `note_aliases` dropped. pub const SCHEMA_VERSION: i64 = 7; /// Statements at or over this take the slow-query WARN path (`B2_SLOW_QUERY_MS` /// overrides; see [`slow_query_threshold`]). const SLOW_QUERY_MS_DEFAULT: u64 = 100; -/// The duration at or above which a statement logs as a **slow query** (WARN instead -/// of DEBUG), read once from `B2_SLOW_QUERY_MS`. Observability config only — it never -/// changes what an operation computes. +/// The duration at which a statement logs as a slow query (WARN instead of DEBUG), read +/// once from `B2_SLOW_QUERY_MS`. fn slow_query_threshold() -> Duration { static THRESHOLD: OnceLock = OnceLock::new(); *THRESHOLD.get_or_init(|| { @@ -62,29 +47,18 @@ fn slow_query_threshold() -> Duration { }) } -/// Whether a finished statement is worth the string work in [`on_sqlite_profile`]: -/// true only when something would receive the event. -/// -/// Split out because the `slow && warn` term is load-bearing: drop it (the tempting -/// "just check DEBUG" simplification) and slow-query WARNs silently disappear for a -/// WARN-only subscriber. The unit test below is the truth table. +/// Whether anything would receive a finished statement's event. The `slow && warn` +/// term keeps slow-query WARNs for a WARN-only subscriber; don't reduce it to DEBUG. fn should_emit(slow: bool, warn_enabled: bool, debug_enabled: bool) -> bool { (slow && warn_enabled) || debug_enabled } -/// SQLite's own per-statement profiler (`sqlite3_trace_v2`), surfaced as structured -/// `tracing` events on target `b2::sqlite`: the SQL **template** (`?N` placeholders, -/// never bound values, so no note content lands in the log and events group by -/// statement), `duration_us`, and the `vm_steps`/`fullscan_steps` counters — the "why -/// was it slow" signal, since a high fullscan count means a missing index. At or over -/// [`slow_query_threshold`] it logs at WARN, otherwise DEBUG. -/// -/// **`duration_us` precision is platform-bound** — some platforms (macOS observed) -/// quantize SQLite's profiler clock to ~1ms, so sub-millisecond statements read as -/// `0`. For fine-grained cost use `vm_steps`: VDBE opcodes, deterministic and -/// clock-independent. +/// SQLite's per-statement profiler as `tracing` events on `b2::sqlite`: the SQL template +/// (never bound values, so no note content is logged), `duration_us`, and +/// `vm_steps`/`fullscan_steps` (a high fullscan count means a missing index). /// -/// A plain `fn` because `trace_v2` registers a function pointer. +/// Some platforms (macOS) quantize `duration_us` to ~1ms; `vm_steps` is the +/// deterministic cost measure. fn on_sqlite_profile(event: TraceEvent<'_>) { let TraceEvent::Profile(stmt, elapsed) = event else { return; // only SQLITE_TRACE_PROFILE is masked in, but TraceEvent is non-exhaustive @@ -98,8 +72,7 @@ fn on_sqlite_profile(event: TraceEvent<'_>) { ) { return; } - // Collapse the multi-line SQL literals used in this file to one line, so each - // event stays a single clean record with a stable, groupable `sql` key. + // One line per event, so `sql` is a stable, groupable key. let sql = stmt.sql().split_whitespace().collect::>().join(" "); let duration_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX); let vm_steps = stmt.get_status(StatementStatus::VmStep); @@ -123,20 +96,16 @@ fn on_sqlite_profile(event: TraceEvent<'_>) { /// idempotent migration. Safe to call on a fresh or an already-built index. pub fn open(path: &Path) -> Result { let mut conn = Connection::open(path)?; - // Profile every statement through SQLite's trace_v2 hook (`on_sqlite_profile`). conn.trace_v2( TraceEventCodes::SQLITE_TRACE_PROFILE, Some(on_sqlite_profile), ); // execute_batch tolerates the row PRAGMA mmap_size returns. - // busy_timeout: WAL allows one writer at a time, and two short-statement writers - // can legitimately race (a save during the background embed); a modest wait turns - // that into a few-ms stall instead of SQLITE_BUSY. Set explicitly rather than - // leaned on — rusqlite arms the same 5 s by default, but that is its contract. - // mmap_size + cache_size: whole-space vector scans stream ~100+ MB of blob rows - // per call on a real vault, which under the 2 MB default cache was syscall-bound - // (the bulk of `b2 similar`'s ~4.4 s, #38). mmap_size is a *cap*, not an - // allocation; cache_size is KiB when negative (32 MiB). + // busy_timeout: two writers can race (a save during the background embed); set + // explicitly rather than relying on rusqlite's default. + // mmap_size + cache_size: whole-space vector scans stream 100+ MB per call, which + // the 2 MB default cache made syscall-bound (#38). mmap_size is a cap, not an + // allocation; negative cache_size is KiB (32 MiB). conn.execute_batch( "PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; @@ -149,28 +118,21 @@ pub fn open(path: &Path) -> Result { Ok(conn) } -/// How many times [`enter_wal_mode`] attempts the flip before surfacing the busy error. -/// Large, because these retries *stand in for* `busy_timeout` — the one statement it does -/// not cover (ADR-0021) — so the whole ≈4 s wait budget is here. +/// Attempts at the WAL flip. Large, because these retries stand in for `busy_timeout`, +/// which does not cover it: the whole ≈4 s budget is here (ADR-0021). const WAL_FLIP_ATTEMPTS: u32 = 16; -/// How many times the DDL rebuilds re-attempt their `BEGIN IMMEDIATE`. Small where the -/// flip's is large, and for the opposite reason: these sit *on top of* `busy_timeout`, so -/// each attempt already waits its full 5 s. Past ≈15 s it is a stuck writer (ADR-0021). +/// Attempts at a DDL rebuild's `BEGIN IMMEDIATE`. Small, because each already waits the +/// full 5 s `busy_timeout`; past ≈15 s it is a stuck writer (ADR-0021). const REBUILD_ATTEMPTS: u32 = 3; /// The pause schedule [`retry_while_locked`] uses between attempts. const LOCK_RETRY_BACKOFF_START: Duration = Duration::from_millis(2); const LOCK_RETRY_BACKOFF_MAX: Duration = Duration::from_millis(500); -/// Run `op`, retrying while SQLite reports the lock it wants is held by someone else -/// — the one failure in this module that is a *race* rather than a fault. `what` names -/// the contended step for the log line only. -/// -/// The backoff sleeps but reads no clock and decides nothing from one, so the core's -/// determinism is untouched. Both callers hand it an operation that is idempotent and -/// self-checking: a retry re-reads the state it is about to change, so the attempt -/// after a lost race finds the work already done. +/// Run `op`, retrying while SQLite reports lock contention. `what` names the step for +/// the log. The backoff sleeps but reads no clock. `op` must be idempotent and +/// self-checking, so a retry after a lost race finds the work already done. fn retry_while_locked( what: &str, attempts: u32, @@ -197,21 +159,12 @@ fn retry_while_locked( } } -/// Put the connection in WAL mode, waiting out a concurrent opener (ADR-0021). +/// Put the connection in WAL mode, waiting out a concurrent opener (ADR-0021). The flip +/// takes a write lock that `busy_timeout` does not cover, so the retry is ours. /// -/// `journal_mode = WAL` is the one statement in [`open`] that takes a write lock, and only -/// when it actually *changes* the mode — so once per vault, ever — and it is the one -/// `busy_timeout` cannot cover, so the retry is ours ([`retry_while_locked`]). It converges -/// fast: the next attempt either takes the lock or finds the database already in WAL. -/// -/// **The mode is read back, not assumed.** A filesystem with no shared-memory support -/// declines the flip with `SQLITE_OK` and the *old* mode, which a row-discarding -/// `execute_batch` would report as success. A decline is not an error — B2 is correct in -/// rollback-journal mode — but it is said out loud, and not retried: nothing holds a lock, -/// so waiting changes nothing. +/// The mode is read back: a filesystem without shared memory declines with `SQLITE_OK` +/// and the old mode. That is logged, not retried; B2 works in rollback-journal mode. fn enter_wal_mode(conn: &Connection) -> Result<()> { - // A *declined* flip comes back as `Ok` carrying the old mode, so it leaves the - // retry immediately. let mode = retry_while_locked("journal_mode=WAL", WAL_FLIP_ATTEMPTS, || { Ok(conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get::<_, String>(0))?) })?; @@ -225,9 +178,8 @@ fn enter_wal_mode(conn: &Connection) -> Result<()> { Ok(()) } -/// Whether an error is SQLite lock contention — the retryable kind. `SQLITE_LOCKED` -/// is matched alongside `SQLITE_BUSY` because the desktop host opens the index from -/// more than one thread. +/// Whether an error is lock contention. `SQLITE_LOCKED` too, because the desktop opens +/// the index from more than one thread. fn is_locked(err: &Error) -> bool { matches!(err, Error::Sqlite(e) if matches!( e.sqlite_error_code(), @@ -235,12 +187,9 @@ fn is_locked(err: &Error) -> bool { )) } -/// The tables [`apply_schema`] creates — the structural half of [`schema_is_current`]. -/// -/// Tables only, and that is sufficient rather than lazy: this guards against a -/// concurrent `DROP TABLE` (#114), and dropping a table takes its indexes and triggers -/// with it, so a missing table is the visible edge of every partial rebuild. The unit -/// test at the foot of this file pins the list to what the DDL actually creates. +/// The tables [`apply_schema`] creates, checked by [`schema_is_current`]. Tables suffice: +/// a dropped table takes its indexes and triggers with it (#114). A unit test pins the +/// list to the DDL. const SCHEMA_TABLES: [&str; 6] = [ "meta", "notes", @@ -250,34 +199,20 @@ const SCHEMA_TABLES: [&str; 6] = [ "edges", ]; -/// Bring the index to [`SCHEMA_VERSION`] — **atomically, and serialized against every other -/// opener** on SQLite's own write lock (ADR-0021, which carries the measured races and why -/// an advisory lock file was rejected). -/// -/// Three properties, each load-bearing. **Serialized:** `migrate` reads, decides, then -/// writes, so two openers that both read a stale version both rebuild and their ~30 DDL -/// statements interleave. **Atomic:** the drop-and-rebuild runs in one transaction, so the -/// stamp and the tables it vouches for commit together, which is what lets -/// [`schema_is_current`] trust it. **The fast path takes no write lock at all**, which is -/// what keeps C1's "a reader is never refused" true: an already-current index costs two -/// reads instead of the ~30 `IF NOT EXISTS` statements it used to re-run on every open. +/// Bring the index to [`SCHEMA_VERSION`], atomically and serialized against other openers +/// on SQLite's write lock (ADR-0021). The stamp commits with the tables it vouches for, and +/// a current index takes no write lock at all, so a reader is never refused (C1). fn migrate(conn: &mut Connection) -> Result<()> { if schema_is_current(conn)? { return Ok(()); } - // Direction, before the rebuild branch can act on "not mine". Checked here as well as - // inside the lock so the common case — a newer index, no contention — is refused - // without ever taking a write lock on a database this build has no business writing. + // Also checked here so a newer index is refused without taking a write lock. refuse_if_newer(conn)?; retry_while_locked("schema migration", REBUILD_ATTEMPTS, || { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - // Re-checked *inside* the write lock, which is the whole point of taking it: - // if we lost the race we waited on the winner's transaction, and this is where - // we find its work already committed. + // Re-checked inside the lock: a lost race finds the winner's work committed. if !schema_is_current(&tx)? { - // The race the outer check cannot cover: the opener we waited on was a - // *newer* b2 that rebuilt this index while we queued. Refusing rolls the - // transaction back untouched. + // The opener we waited on may have been a newer b2. refuse_if_newer(&tx)?; apply_schema(&tx)?; } @@ -286,26 +221,13 @@ fn migrate(conn: &mut Connection) -> Result<()> { }) } -/// Refuse an index stamped **above** [`SCHEMA_VERSION`] — written by a newer `b2` than -/// this binary — instead of letting [`apply_schema`] drop it ([`Error::IndexTooNew`]). -/// -/// [`schema_is_current`] asks only whether the stamp *equals* ours, and every way it can -/// say no used to reach the same unconditional rebuild. That is right for an index of no -/// known version and wrong for one of a *later* known version: the index is disposable -/// (ADR-0002), but this build is not the one entitled to dispose of it, and dropping does -/// not make a shape it predates readable. In the field the cost fell on the user — an -/// older `b2` on `PATH` (a stale `cargo install`, an unupgraded shell) wiped a complete -/// index on a **read-only** command, and the only symptom was a whole-vault re-embed. +/// Refuse an index stamped above [`SCHEMA_VERSION`] ([`Error::IndexTooNew`]) rather than +/// let [`apply_schema`] drop it: an older `b2` on `PATH` must not wipe a newer index. /// -/// **The stamp alone decides, deliberately.** A stamp is written only inside -/// [`apply_schema`]'s transaction, alongside the tables it vouches for, so a stamp above -/// ours means some newer `b2` committed a complete index here. Gating on our own -/// [`SCHEMA_TABLES`] as well would be worse than redundant: a future schema may rename or -/// drop any table on that list, so their absence is evidence of the version gap, not of -/// damage — and it is not our call to make about a shape we cannot read. +/// The stamp alone decides. It commits with its tables, and a future schema may rename +/// any of [`SCHEMA_TABLES`], so their absence is not damage. fn refuse_if_newer(conn: &Connection) -> Result<()> { - // `meta` carries the stamp; on an index that has none there is no claim to compare, - // and `meta_value` would fault on the missing table. + // No `meta`, no stamp to compare. if !table_exists(conn, "meta")? { return Ok(()); } @@ -318,8 +240,7 @@ fn refuse_if_newer(conn: &Connection) -> Result<()> { } } -/// Whether `name` is a table in this database — the one-table form of the presence check -/// [`schema_is_current`] makes over the whole list. +/// Whether `name` is a table in this database. fn table_exists(conn: &Connection, name: &str) -> Result { let n: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -329,11 +250,8 @@ fn table_exists(conn: &Connection, name: &str) -> Result { Ok(n > 0) } -/// Whether this connection sees a **complete** schema at the current -/// [`SCHEMA_VERSION`]: every table in [`SCHEMA_TABLES`] present *and* the stamp -/// current. Both halves are load-bearing — a current stamp over an incomplete schema -/// is precisely what #114 could leave behind, so an index damaged by an older `b2` is -/// detected here rather than far from its cause on the next `search`. +/// Whether every table in [`SCHEMA_TABLES`] is present and the stamp is current. Both +/// halves matter: #114 could leave a current stamp over an incomplete schema. fn schema_is_current(conn: &Connection) -> Result { let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")?; let present: HashSet = stmt @@ -345,47 +263,33 @@ fn schema_is_current(conn: &Connection) -> Result { Ok(stamped_version(conn)? == Some(SCHEMA_VERSION)) } -/// The value stored in `meta` under `key`, or `None` when unset. Callers must -/// know `meta` exists — every caller reads it past a check that implies it -/// (a table-presence check, or the embed pass having ensured the space). +/// The value stored in `meta` under `key`, or `None` when unset. Callers must know +/// `meta` exists. fn meta_value(conn: &Connection, key: &str) -> Result> { Ok(conn .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) .optional()?) } -/// The `schema_version` recorded in `meta`, or `None` on an index that has never been -/// stamped — or whose stamp was lost, which [`apply_schema`] treats the same way. +/// The `schema_version` recorded in `meta`, or `None` when unstamped. fn stamped_version(conn: &Connection) -> Result> { Ok(meta_value(conn, "schema_version")?.and_then(|s| s.parse().ok())) } -/// Create the schema and stamp `schema_version`, dropping whatever was there first. -/// The DDL mirrors `index-engine.md`; the vector tables are created at embed time -/// instead (ADR-0006, [`ensure_embedding_space`]). +/// Drop whatever is there, create the schema and stamp `schema_version`. The DDL mirrors +/// `index-engine.md`; vector tables are created at embed time ([`ensure_embedding_space`]). /// -/// **One outcome, whatever it finds:** an empty schema at the current version. -/// Reaching here means [`schema_is_current`] said no, and every way it can say no — -/// wrong shape, structurally incomplete (#114), or present but unstamped — is an index -/// of no known version. So the drop is unconditional: guarding it on "was there a prior -/// stamp?" reads like an optimization, but `DROP TABLE IF EXISTS` over an empty catalog -/// is already a no-op, and all the guard would do is wave through the -/// unstamped-but-populated index. Dropping is safe because the index is disposable -/// (ADR-0002), and rows surviving in tables of unknown shape are worse than none: an -/// incremental reindex skips notes whose `body_hash` still matches, so it would leave -/// the recreated tables empty forever. -/// -/// **Called only from inside [`migrate`]'s transaction**, which is what makes -/// drop-then-create safe; it is not a standalone entry point. +/// The drop is unconditional: anything reaching here is of no known version, and +/// surviving rows would be skipped by an incremental reindex (matching `body_hash`), +/// leaving the tables empty forever. Call only inside [`migrate`]'s transaction. fn apply_schema(conn: &Connection) -> Result<()> { // `meta` must exist before the batch below can clear it. conn.execute_batch( "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);", )?; - // Children first (FKs); dropping `chunks` takes its FTS triggers with it. The - // legacy vec0 `chunks_vec` (schema <= 2) is deliberately absent: its module is no - // longer linked, so SQLite cannot DROP it. `DELETE FROM meta` clears the recorded - // embedder, so the next embed pass recreates the vector tables from nothing. + // Children first (FKs). Legacy vec0 `chunks_vec` is absent: its module is no longer + // linked, so SQLite cannot drop it. Clearing `meta` makes the next embed pass + // recreate the vector tables. conn.execute_batch( "DROP TABLE IF EXISTS edge_provenance; DROP TABLE IF EXISTS edges; @@ -499,12 +403,8 @@ pub struct NoteRow<'a> { pub mtime: Option, } -/// Upsert a note keyed by its vault-relative `path`. -/// `indexed_at` is set by SQLite so the projection needs no wall-clock from Rust. -/// -/// `ON CONFLICT(path)` is the *whole* of path reconciliation, which is the point of -/// keying on the path (ADR-0003): the filesystem already guarantees one file per path, -/// so a note deleted and recreated there is simply that path's note now. +/// Upsert a note keyed by its vault-relative `path` (ADR-0003). SQLite sets `indexed_at`, +/// so no wall clock is needed from Rust. pub fn upsert_note(conn: &Connection, row: &NoteRow) -> Result<()> { conn.execute( "INSERT INTO notes (path, title, created, body_hash, mtime, indexed_at) @@ -520,32 +420,24 @@ pub fn upsert_note(conn: &Connection, row: &NoteRow) -> Result<()> { Ok(()) } -/// Delete every `notes` row whose path is not in `seen` — the paths the whole-vault -/// walk actually met, including ones skipped as unreadable (the walk *saw* that file, -/// so evicting it would lie). Returns how many were pruned: the note half of #31, -/// without which a file deleted outside `b2` leaves a ghost row that listings, search, -/// `similar` and the graph keep serving, so an incremental reindex diverges from a -/// from-scratch rebuild (S3). +/// Delete every `notes` row whose path is not in `seen` (every path the walk met, +/// unreadable ones included) and return how many went, so a file deleted outside `b2` +/// leaves no ghost row (#31, S3). /// -/// Chunks (FTS in lockstep via the `chunks_ad` trigger), centroid and -/// **outgoing** edges cascade with the row. Vectors no longer do — they are -/// content-addressed and may be shared, so [`prune_orphan_vectors`] collects them. -/// **Inbound** edges are the caller's concern: `edges.dst_path` carries no FK (it must -/// be free to be NULL — the dangling case), so this must run *before* edge derivation, -/// which then re-dangles the links that pointed here. +/// Chunks, centroid and outgoing edges cascade; vectors are shared, so +/// [`prune_orphan_vectors`] collects them. `edges.dst_path` has no FK, so run this before +/// edge derivation, which re-dangles inbound links. pub fn prune_notes_except(conn: &Connection, seen: &HashSet<&str>) -> Result { prune_members_except(conn, Members::Notes, |path| seen.contains(path)) } -/// Drop the `notes` row at `path` — the index half of deleting a note — and return how -/// many rows went (0 or 1). Everything keyed to the note cascades with the row, as for +/// Drop the `notes` row at `path` and return how many rows went (0 or 1). Cascades as /// [`prune_notes_except`]; inbound edges are the caller's to re-project. pub fn delete_note_row(conn: &Connection, path: &str) -> Result { delete_member(conn, Members::Notes, path) } -/// The two path-keyed member tables: a vault path names a note row or a resource row -/// (L1, L3), and the row-level housekeeping below is the same for both. +/// The two path-keyed member tables: a vault path names a note or a resource (L1, L3). #[derive(Debug, Clone, Copy)] enum Members { Notes, @@ -584,9 +476,8 @@ fn prune_members_except( Ok(pruned) } -/// Every member path under the folder `dir` (vault-relative, no trailing slash), -/// path-ordered. Prefix-matched with `substr` (not `LIKE`) so a folder name containing -/// `%`/`_` never wildcards. +/// Every member path under the folder `dir` (no trailing slash), path-ordered. Uses +/// `substr`, not `LIKE`, so `%`/`_` in a folder name never wildcard. fn members_under_dir(conn: &Connection, members: Members, dir: &str) -> Result> { let prefix = format!("{dir}/"); let mut stmt = conn.prepare(&format!( @@ -603,8 +494,7 @@ fn members_under_dir(conn: &Connection, members: Members, dir: &str) -> Result { pub path: &'a str, @@ -614,8 +504,7 @@ pub struct ResourceRow<'a> { pub content_hash: &'a str, } -/// Upsert a resource keyed by its vault-relative path. `indexed_at` is set by -/// SQLite, like [`upsert_note`]'s — the projection needs no wall-clock from Rust. +/// Upsert a resource keyed by its vault-relative path; SQLite sets `indexed_at`. pub fn upsert_resource(conn: &Connection, row: &ResourceRow) -> Result<()> { conn.execute( "INSERT INTO resources (path, class, size, mtime, content_hash, indexed_at) @@ -631,9 +520,7 @@ pub fn upsert_resource(conn: &Connection, row: &ResourceRow) -> Result<()> { Ok(()) } -/// The stored `(size, mtime)` for an inventoried resource — the change-detection -/// short-circuit: a matching stat means the bytes are not re-read or re-hashed -/// (hashing is the only byte-read the inventory pass performs). +/// The stored `(size, mtime)` for a resource. A matching stat skips re-hashing. pub fn resource_stat(conn: &Connection, path: &str) -> Result)>> { Ok(conn .query_row( @@ -644,7 +531,7 @@ pub fn resource_stat(conn: &Connection, path: &str) -> Result, } -/// One resource's full inventory row (`resource_detail`) — the fallback card's -/// metadata. +/// One resource's full inventory row: the fallback card's metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResourceDetail { pub class: String, @@ -663,8 +549,7 @@ pub struct ResourceDetail { pub content_hash: String, } -/// Every inventoried resource — [`ResourceListing`] rows, path-ordered — the -/// file tree's resource half (`Vault::list_resources`). +/// Every inventoried resource, path-ordered. pub fn list_resources(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT path, class, size, mtime FROM resources ORDER BY path")?; let rows = stmt.query_map([], |r| { @@ -678,8 +563,7 @@ pub fn list_resources(conn: &Connection) -> Result> { Ok(rows.collect::>>()?) } -/// One resource's full inventory row — [`ResourceDetail`] — the fallback card's -/// metadata. `None` when the path is not inventoried. +/// One resource's [`ResourceDetail`], or `None` when not inventoried. pub fn resource_detail(conn: &Connection, path: &str) -> Result> { Ok(conn .query_row( @@ -697,8 +581,7 @@ pub fn resource_detail(conn: &Connection, path: &str) -> Result Result> { let mut stmt = conn.prepare( "SELECT n.path, n.title, e.type, e.caption, e.embed @@ -730,8 +611,7 @@ pub fn inbound_resource_edges(conn: &Connection, path: &str) -> Result>>()?) } -/// One edge a note points *at a resource*, joined with the inventory's `class` -/// (the display glyph) — a row of `explain`'s file-links panel. +/// One edge from a note to a resource, with the resource's `class` (the display glyph). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResourceEdgeRow { pub path: String, @@ -743,10 +623,8 @@ pub struct ResourceEdgeRow { pub explanation: Option, } -/// Every active edge a note points *at a resource* — the outbound complement of -/// [`inbound_resource_edges`], so `explain` can present all three target kinds a -/// note authors (note / resource / dangling — GH #22) instead of silently hiding -/// its file links. Ordered for deterministic display. +/// Every edge from a note to a resource, so `explain` shows file links alongside note +/// and dangling targets (GH #22). Ordered. pub fn outbound_resource_edges(conn: &Connection, note_path: &str) -> Result> { let mut stmt = conn.prepare( "SELECT e.dst_resource_path, r.class, e.type, e.origin, e.caption, e.embed, e.explanation @@ -768,37 +646,29 @@ pub fn outbound_resource_edges(conn: &Connection, note_path: &str) -> Result>>()?) } -/// The bounded inbound set a **resource move** must rewrite — [`InboundEdge`] -/// rows. The resource sibling of [`inbound_edge_targets`]; ordered for -/// deterministic rewriting. +/// The inbound edges a resource move must rewrite; the resource sibling of +/// [`inbound_edge_targets`]. pub fn inbound_resource_edge_targets(conn: &Connection, path: &str) -> Result> { inbound_edges_on(conn, "dst_resource_path", path) } -/// Delete every `resources` row whose path is not in `seen` (the walk's survivors) -/// and return how many were pruned. Inbound edges **re-dangle** automatically — -/// `edges.dst_resource_path` is `ON DELETE SET NULL`, `dst_path_raw` retained — -/// so a stale inventory row never outlives its file (the resource half of #31). +/// Delete every `resources` row whose path is not in `seen` and return how many went +/// (#31). Inbound edges re-dangle via `ON DELETE SET NULL`. pub fn prune_resources_except(conn: &Connection, seen: &HashSet) -> Result { prune_members_except(conn, Members::Resources, |path| seen.contains(path)) } -/// Drop the inventory row at `path` — the index half of deleting a resource — and -/// return how many rows went (0 or 1). Inbound edges re-dangle as for -/// [`prune_resources_except`]. +/// Drop the inventory row at `path` and return how many rows went (0 or 1). Inbound +/// edges re-dangle. pub fn delete_resource_row(conn: &Connection, path: &str) -> Result { delete_member(conn, Members::Resources, path) } -/// Re-key the inventory row at `old_path` to `new_path` — the resource sibling of -/// [`repoint_note_path`], and the index half of moving one. The bytes are the same, so -/// `size` and `content_hash` carry over; `class` (from the new extension) and `mtime` -/// (the moved file's) are the caller's. Returns whether `old_path` was inventoried — -/// `false` changes nothing. +/// Re-key the inventory row at `old_path` to `new_path`, keeping `size` and +/// `content_hash`. Returns whether `old_path` was inventoried. /// -/// An upsert of the new row then a delete of the old, not an `UPDATE`: -/// `edges.dst_resource_path` has no `ON UPDATE CASCADE`, so the old row's inbound edges -/// re-dangle (`ON DELETE SET NULL`) until the caller re-projects their sources. +/// Upsert then delete, not `UPDATE`: `edges.dst_resource_path` has no `ON UPDATE +/// CASCADE`, so inbound edges re-dangle until the caller re-projects their sources. pub fn repoint_resource( conn: &Connection, old_path: &str, @@ -827,31 +697,19 @@ pub fn repoint_resource( // chunks (FTS kept in lockstep by the triggers in apply_schema()) // --------------------------------------------------------------------------- -/// The content address of one chunk's embed input: blake3 of the chunk text. The text -/// stored on the row *is* what the embedder is handed (`chunk.rs` folds the heading -/// breadcrumb in before it lands here), so this hash keys the vector store exactly -/// (ADR-0006) — two chunks with byte-identical text must have the same vector, which is -/// a correctness statement before it is a saving. -/// -/// The model identity is deliberately *not* mixed in: a model swap drops the whole -/// `embeddings` table (ADR-0007), so the space is per-model by construction and a wider -/// key would only make that drop look optional. +/// The content address of a chunk's embed input: blake3 of the stored text, which is +/// exactly what the embedder sees (ADR-0006). No model id: a model swap drops the whole +/// table (ADR-0007). pub fn text_hash(text: &str) -> String { blake3::hash(text.as_bytes()).to_hex().to_string() } -/// Replace a note's chunks (delete + reinsert) and return the new chunk ids in `seq` -/// order; the FTS triggers emit the `'delete'` sentinel for the removed rows. +/// Replace a note's chunks and return the new chunk ids in `seq` order. /// -/// Stored vectors do **not** cascade — they are content-addressed and may be shared, -/// so they outlive any one chunk row and the whole-vault pass collects what nothing -/// references (ADR-0006, [`prune_orphan_vectors`]). That is exactly what makes a -/// re-chunk (or a move) re-embed nothing when the text is unchanged. The note's -/// centroid summarizes the *old* chunk set, so it is dropped here and the next embed -/// pass recomputes it. The caller embeds the returned ids. +/// Vectors are shared, so they survive (ADR-0006, [`prune_orphan_vectors`]) and a re-chunk +/// of unchanged text re-embeds nothing. The centroid is stale, so it is dropped. pub fn replace_chunks(conn: &Connection, note_path: &str, chunks: &[Chunk]) -> Result> { - // Guarded on existence so the model-free projection pass still never *creates* - // the embedding space (index-engine.md). + // The model-free projection must never create the embedding space (index-engine.md). if embedding_space_exists(conn)? { conn.execute( "DELETE FROM note_centroids WHERE note_path = ?1", @@ -882,10 +740,9 @@ pub fn replace_chunks(conn: &Connection, note_path: &str, chunks: &[Chunk]) -> R Ok(new_ids) } -/// The closed set of tokenizers `chunks_fts` can be rebuilt with — an enum, so the -/// string spliced into [`rebuild_fts`]'s DDL is never caller-supplied text. -/// `PorterUnicode61` is the shipped default (the GH #157 verdict); `Unicode61` is the -/// unstemmed ablation arm the eval harness keeps measurable. +/// The tokenizers `chunks_fts` can be rebuilt with; an enum so [`rebuild_fts`]'s DDL never +/// splices caller text. `PorterUnicode61` ships (GH #157); `Unicode61` is the eval's +/// unstemmed arm. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FtsTokenizer { Unicode61, @@ -893,8 +750,7 @@ pub enum FtsTokenizer { } impl FtsTokenizer { - /// The FTS5 `tokenize =` value this variant names — public so the eval's - /// recorded rows spell the tokenizer exactly as the schema does. + /// The FTS5 `tokenize =` value, spelled as the schema does. pub fn sql(self) -> &'static str { match self { FtsTokenizer::Unicode61 => "unicode61", @@ -903,14 +759,9 @@ impl FtsTokenizer { } } -/// The tokenizer `chunks_fts` was actually created with, read back from the schema -/// rather than assumed — because it *moves*: [`rebuild_fts`] swaps it under the GH #157 -/// ablation, so a caller that must tokenize a query the way the index does (see -/// [`search::lexical_evidence`](crate::search::lexical_evidence)) has to ask. -/// -/// The recorded value is matched against the closed [`FtsTokenizer`] set, never spliced -/// onward as text. An unreadable or unrecognised schema degrades to the shipped -/// default, which is the one `migrate` creates. +/// The tokenizer `chunks_fts` was created with, read back because [`rebuild_fts`] can +/// swap it (see [`search::lexical_evidence`](crate::search::lexical_evidence)). An +/// unrecognised schema degrades to the shipped default. pub fn index_tokenizer(conn: &Connection) -> Result { const DEFAULT: FtsTokenizer = FtsTokenizer::PorterUnicode61; let sql: Option = conn @@ -933,20 +784,12 @@ pub fn index_tokenizer(conn: &Connection) -> Result { .unwrap_or(DEFAULT)) } -/// Drop and recreate `chunks_fts` with `tokenizer`, repopulated from the untouched -/// `chunks` content table (FTS5's external-content `'rebuild'`). Chunk rows, vectors -/// and centroids are untouched — the tokenizer only changes how the lexical half -/// indexes the same text, which is what makes the GH #157 stemmer A/B runnable without -/// re-chunking or re-embedding. -/// -/// A drop-and-rebuild like the migration, so it takes the same write-lock discipline; -/// the op is idempotent, so an attempt after a lost race lands on the same end state. -/// The `chunks_*` triggers live on `chunks` and reference this table by name, so they -/// survive the swap. +/// Drop and recreate `chunks_fts` with `tokenizer`, repopulated from `chunks`, without +/// re-chunking or re-embedding (GH #157). Same write-lock discipline as the migration; +/// the `chunks_*` triggers reference this table by name, so they survive. pub fn rebuild_fts(conn: &Connection, tokenizer: FtsTokenizer) -> Result<()> { retry_while_locked("chunks_fts rebuild", REBUILD_ATTEMPTS, || { - // `new_unchecked` for the same reason as `ensure_embedding_space`: this takes - // `&Connection`, and nothing in this crate can already be inside a transaction. + // `new_unchecked`: see `ensure_embedding_space`. let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?; tx.execute_batch(&format!( "DROP TABLE IF EXISTS chunks_fts; @@ -965,9 +808,8 @@ pub fn rebuild_fts(conn: &Connection, tokenizer: FtsTokenizer) -> Result<()> { } // --------------------------------------------------------------------------- -// embeddings — the vector tables are created at embed time, not in apply_schema(): -// their *existence* is the "this vault has an embedding space" signal the -// projected-but-unembedded fallbacks key on (ADR-0006). +// embeddings: created at embed time, not in apply_schema(); their existence is the +// "this vault has an embedding space" signal (ADR-0006). // --------------------------------------------------------------------------- /// Whether the embedding space (the `embeddings` table) currently exists. @@ -975,24 +817,18 @@ pub fn embedding_space_exists(conn: &Connection) -> Result { table_exists(conn, "embeddings") } -/// Ensure the vector tables exist, recording `(embed_model_id, embed_dim)` in `meta`. If -/// either differs from what is recorded — a model swap — the tables are dropped and -/// recreated empty, so a full re-embed follows: `meta` is the only place a swap is -/// detectable, so vectors never go silently stale (ADR-0007). (`dim` is bookkeeping only now -/// that vectors are plain BLOBs, but it still gates the swap and the read-time fail-fast.) +/// Ensure the vector tables exist for `(model_id, dim)`, recorded in `meta`. A model swap +/// drops and recreates them empty, so vectors never go silently stale (ADR-0007). /// -/// **Serialized and atomic on the same terms as [`migrate`]** (ADR-0021): two embed passes -/// genuinely overlap — the desktop's reindex task and a `b2 reindex`, which the CLI's own -/// advisory lock does not cover — and B's `DROP` after A's `CREATE` leaves A inserting into -/// a table that no longer exists. +/// Serialized and atomic like [`migrate`] (ADR-0021): the desktop's reindex and a +/// `b2 reindex` can overlap, and one's `DROP` after the other's `CREATE` breaks it. pub fn ensure_embedding_space(conn: &Connection, model_id: &str, dim: usize) -> Result<()> { if embedding_space_matches(conn, model_id, dim)? { return Ok(()); } retry_while_locked("embedding-space rebuild", REBUILD_ATTEMPTS, || { - // `new_unchecked` because this takes `&Connection`, not `&mut` — the embed - // pass threads a shared connection through. Sound because `b2-core` opens no - // other transaction on it. + // `new_unchecked` because the embed pass shares a `&Connection`. Sound because + // `b2-core` opens no other transaction on it. let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?; if !embedding_space_matches(&tx, model_id, dim)? { tx.execute_batch( @@ -1016,10 +852,7 @@ pub fn ensure_embedding_space(conn: &Connection, model_id: &str, dim: usize) -> }) } -/// Whether the vector tables exist *and* were built by this exact embedder identity — -/// the "nothing to do" test [`ensure_embedding_space`] runs twice, once cheaply and -/// once under the write lock. Identity first: a differing model settles it without the -/// `sqlite_master` lookup. +/// Whether the vector tables exist and were built by this exact embedder. fn embedding_space_matches(conn: &Connection, model_id: &str, dim: usize) -> Result { let unchanged = matches!( recorded_embedder(conn)?, @@ -1028,9 +861,8 @@ fn embedding_space_matches(conn: &Connection, model_id: &str, dim: usize) -> Res Ok(unchanged && embedding_space_exists(conn)?) } -/// The `(embed_model_id, embed_dim)` a prior ingest recorded, if any; `None` means the -/// vault has never been embedded. The only place a model swap is detectable, so a read -/// compares it to the active embedder and fails fast on a mismatch (ADR-0007). +/// The `(embed_model_id, embed_dim)` a prior ingest recorded; `None` if never embedded. +/// Reads compare it to the active embedder and fail fast on a mismatch (ADR-0007). pub fn recorded_embedder(conn: &Connection) -> Result> { let model = meta_value(conn, "embed_model_id")?; let dim = meta_value(conn, "embed_dim")?; @@ -1049,10 +881,8 @@ fn upsert_meta(conn: &Connection, key: &str, value: &str) -> Result<()> { Ok(()) } -/// Store one embedding under the content address of the text it came from. `OR IGNORE` -/// because the store is shared: two notes with byte-identical chunk text address the -/// same row, and the second writer is agreeing with the first. That also makes a -/// resumed or overlapping embed pass idempotent. +/// Store one embedding under its text's content address. `OR IGNORE`: identical text +/// addresses the same row, which also makes overlapping embed passes idempotent. pub fn set_vector(conn: &Connection, text_hash: &str, embedding: &[f32]) -> Result<()> { conn.execute( "INSERT OR IGNORE INTO embeddings(text_hash, vector) VALUES (?1, ?2)", @@ -1061,15 +891,9 @@ pub fn set_vector(conn: &Connection, text_hash: &str, embedding: &[f32]) -> Resu Ok(()) } -/// Delete every stored vector no chunk references — the collection pass -/// content-addressing costs us, run by the whole-vault projection once this run's chunk -/// set is final. -/// -/// A vector cannot be dropped with its chunk precisely because it may be shared, and -/// that sharing is the point: a moved note's chunks are deleted and re-inserted under a -/// new path, and their vectors have to survive the gap (ADR-0006). So the lifecycle is -/// the centroids' — derived data reconciled by the pass that knows the whole picture, -/// never by a per-row rule that cannot. Requires the embedding space to exist. +/// Delete every stored vector no chunk references, once the whole-vault pass's chunk set +/// is final. Not per-row: a moved note's vectors must survive re-insertion (ADR-0006). +/// Requires the embedding space to exist. pub fn prune_orphan_vectors(conn: &Connection) -> Result { Ok(conn.execute( "DELETE FROM embeddings @@ -1089,9 +913,8 @@ pub fn note_for_chunk(conn: &Connection, chunk_id: i64) -> Result .optional()?) } -/// A ranked chunk resolved for display in one read: its note, that note's title, and the -/// chunk's heading breadcrumb and text. One statement, so the note and the chunk come -/// from the same snapshot: a hit is either whole or `None` (GH #137). +/// A ranked chunk resolved for display. One statement, so the note and chunk come from +/// one snapshot: a hit is whole or `None` (GH #137). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChunkHit { pub note_path: String, @@ -1129,8 +952,7 @@ pub fn chunk_text(conn: &Connection, chunk_id: i64) -> Result> { .optional()?) } -/// A chunk's heading breadcrumb + text in one read (None if the chunk id is -/// unknown) — the chunk-level hit resolution (`Vault::search_chunks`). +/// A chunk's heading breadcrumb and text (None if the chunk id is unknown). pub fn chunk_detail(conn: &Connection, chunk_id: i64) -> Result, String)>> { Ok(conn .query_row( @@ -1141,9 +963,8 @@ pub fn chunk_detail(conn: &Connection, chunk_id: i64) -> Result Result> { Ok(conn .query_row( @@ -1154,14 +975,8 @@ pub fn note_body_hash(conn: &Connection, note_path: &str) -> Result Result { let n_missing: i64 = conn.query_row( "SELECT COUNT(*) @@ -1173,26 +988,14 @@ pub fn note_fully_embedded(conn: &Connection, note_path: &str) -> Result { Ok(n_missing == 0) } -/// The vault's embedding coverage as `(notes_embedded, notes_total)` — the honest -/// "N/M embedded" signal (#26). `notes_embedded` counts notes with **no chunk awaiting a -/// vector**; it is `0` before any embed on a vault of ordinary notes, so this reads -/// cleanly on a projected-but-unembedded one. **Model-free:** a pure count over the -/// projection. +/// Embedding coverage as `(notes_embedded, notes_total)`: notes with no chunk awaiting a +/// vector (#26). Model-free. /// -/// **A note with no chunks counts as embedded** — an empty body: a frontmatter-only stub, -/// a `Untitled.md` the tree just created, a file with nothing in it. It has no chunk, so -/// there is no vector it is waiting for, and the older `>= 1 chunk` requirement left it -/// permanently outside the numerator. That is not a rounding error: this fraction is the -/// vault's "am I done?" and every consumer treats `embedded < total` as outstanding work -/// — the chat pane's grounding note, the search caveat, the graph pane's ghost hint, and -/// the desktop's auto-index-on-open and fs-watch heal, which each scheduled a no-op embed -/// pass forever. A vault holding one empty note could never read as finished, however -/// often it was reindexed. Vacuous truth is the honest answer, not a lenient one. +/// A chunkless note counts as embedded. Consumers treat `embedded < total` as pending +/// work, so otherwise one empty note would schedule no-op embed passes forever. pub fn embed_progress(conn: &Connection) -> Result<(usize, usize)> { let total: i64 = conn.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))?; - // No embeddings table yet: the join below would reference a missing table, so the - // same predicate is asked of the projection alone. Only the chunkless notes can - // satisfy it here — every chunk in the vault is awaiting a vector by definition. + // No embeddings table yet: only chunkless notes are embedded. if !embedding_space_exists(conn)? { let chunkless: i64 = conn.query_row( "SELECT COUNT(*) FROM notes n @@ -1202,8 +1005,7 @@ pub fn embed_progress(conn: &Connection) -> Result<(usize, usize)> { )?; return Ok((chunkless as usize, total as usize)); } - // A note counts as embedded iff no chunk of it lacks a vector — `note_fully_embedded`, - // aggregated. A chunkless note satisfies the NOT EXISTS vacuously, as it should. + // `note_fully_embedded`, aggregated. let embedded: i64 = conn.query_row( "SELECT COUNT(*) FROM notes n WHERE NOT EXISTS ( @@ -1217,14 +1019,12 @@ pub fn embed_progress(conn: &Connection) -> Result<(usize, usize)> { Ok((embedded as usize, total as usize)) } -/// One chunk still lacking a stored vector — a row of the DB-derived pending set -/// ([`chunks_missing_vectors`]) the embed pass fills. +/// One chunk still lacking a stored vector. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingChunk { pub note_path: String, pub text: String, - /// The content address the embed pass stores this vector under — carried on the - /// row so the pass never re-hashes text SQLite already hashed at chunk time. + /// The content address to store the vector under, so the pass never re-hashes. pub text_hash: String, } @@ -1237,17 +1037,11 @@ fn pending_chunk(r: &rusqlite::Row) -> rusqlite::Result { }) } -/// Every chunk still lacking a stored vector, in `(path, seq)` order — the -/// **DB-derived pending set** the embed pass fills. Deriving it here is what decouples -/// projection from embedding: nothing is handed between the passes in memory, so any -/// stop point (a cancelled embed, a crash between passes) heals on the next embed. The -/// ordering reproduces the fused reindex's per-note batching and progress. Requires the -/// embedding space to exist. +/// Every chunk still lacking a stored vector, in `(path, seq)` order: the pending set the +/// embed pass fills. Derived from the DB, so any interrupted pass heals on the next. +/// Requires the embedding space. /// -/// A row is a *chunk*, not a distinct vector: two chunks sharing text appear twice, -/// because the caller needs to know which notes are waiting on work. Deduplicating the -/// embedder calls is [`crate::ingest::embed_vault`]'s job — it is the only layer that -/// knows the order the notes will be worked in. +/// Chunks sharing text appear twice; [`crate::ingest::embed_vault`] dedups embedder calls. pub fn chunks_missing_vectors(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT c.note_path, c.text, c.text_hash @@ -1260,10 +1054,8 @@ pub fn chunks_missing_vectors(conn: &Connection) -> Result> { Ok(rows.collect::>>()?) } -/// [`chunks_missing_vectors`] for **one** note, in `seq` order — what the inline -/// ingest path (`add`/`link`/`mv`) embeds after re-projecting a single file. Its own -/// indexed query rather than a filter over the whole-vault set: a directory move -/// re-projects every note it moved, and filtering there would be O(vault × moved). +/// [`chunks_missing_vectors`] for one note, for the inline ingest path. Its own indexed +/// query, because a directory move would make filtering O(vault × moved). pub fn note_chunks_missing_vectors( conn: &Connection, note_path: &str, @@ -1279,8 +1071,7 @@ pub fn note_chunks_missing_vectors( Ok(rows.collect::>>()?) } -/// A note's chunk ids in reading order (`seq`), embedded or not — the unit a reader -/// walks a note by. Empty for an unknown or empty note. +/// A note's chunk ids in `seq` order, embedded or not. Empty for an unknown or empty note. pub fn note_chunk_ids(conn: &Connection, note_path: &str) -> Result> { let mut stmt = conn.prepare_cached("SELECT id FROM chunks WHERE note_path = ?1 ORDER BY seq")?; @@ -1300,9 +1091,8 @@ pub fn note_title(conn: &Connection, note_path: &str) -> Result> .flatten()) } -/// A note's `(title, created)` in one read (both `None` if the note is absent), resolved -/// from the projection (GH #22): a neighbor is titled and dated for display without an -/// adapter ever re-reading the file. +/// A note's `(title, created)` from the projection (both `None` if absent), so adapters +/// never re-read the file (GH #22). pub fn note_header(conn: &Connection, note_path: &str) -> Result<(Option, Option)> { Ok(conn .query_row( @@ -1314,9 +1104,7 @@ pub fn note_header(conn: &Connection, note_path: &str) -> Result<(Option .unwrap_or_default()) } -/// Every indexed note's `(path, title)`, ordered by `path` — the flat listing -/// the desktop UI's file tree is built from (`Vault::list_notes`). Path order means -/// the adapter can assemble the folder tree in one pass without re-sorting. +/// Every indexed note's `(path, title)`, path-ordered so the file tree builds in one pass. pub fn all_notes(conn: &Connection) -> Result)>> { let mut stmt = conn.prepare("SELECT path, title FROM notes ORDER BY path")?; let rows = stmt.query_map([], |r| { @@ -1325,12 +1113,9 @@ pub fn all_notes(conn: &Connection) -> Result)>> { Ok(rows.collect::, _>>()?) } -/// A note's stored chunk vectors as `(chunk_id, vector)` in `seq` order — one indexed -/// join, not a per-chunk round-trip. Reading a note's own vectors back is what lets -/// discovery search from them without re-embedding (passage-to-passage, no -/// `embed_query`); it is also discovery's rescore unit and the input to a centroid -/// refresh. Call only when the embedding space exists. `prepare_cached` because -/// discovery calls this once per shortlisted note. +/// A note's stored chunk vectors as `(chunk_id, vector)` in `seq` order, so discovery +/// searches from them without re-embedding. Requires the embedding space. Cached: +/// discovery calls it once per shortlisted note. pub fn note_chunk_vectors(conn: &Connection, note_path: &str) -> Result)>> { let mut stmt = conn.prepare_cached( "SELECT c.id, e.vector FROM chunks c @@ -1346,10 +1131,8 @@ pub fn note_chunk_vectors(conn: &Connection, note_path: &str) -> Result>>()?) } -/// Recompute and store `note_path`'s centroid from its currently stored chunk vectors -/// (the row is deleted when it has none). The embed pass calls this after finishing a -/// note, so a centroid row exists exactly for embedded notes and always summarizes -/// their *current* vectors — derived data with no separate invalidation (ADR-0006). +/// Recompute `note_path`'s centroid from its stored chunk vectors, deleting it when there +/// are none (ADR-0006). pub fn refresh_note_centroid(conn: &Connection, note_path: &str) -> Result<()> { let vectors: Vec> = note_chunk_vectors(conn, note_path)? .into_iter() @@ -1373,20 +1156,10 @@ pub fn refresh_note_centroid(conn: &Connection, note_path: &str) -> Result<()> { Ok(()) } -/// Every fully-embedded note with **no** centroid row, path-ordered — the second half -/// of the embed pass's work list (GH #170). -/// -/// It exists because content-addressing broke a coupling the old design leaned on. -/// `replace_chunks` drops a note's centroid and used to drop its vectors too, so a -/// re-chunked note always had pending chunks and refreshing the centroid inside the -/// embed loop sufficed. Vectors now survive a re-chunk — that is the whole point — so a -/// note can reach the embed pass with a complete vector set, no centroid, and nothing -/// pending to bring it back. Silently: discovery's coarse stage scans centroids only, -/// so such a note would stop being discoverable while looking perfectly indexed -/// (S3, which is how the property suite caught it). -/// -/// "Fully embedded" is the gate, not "has any vector": a centroid over a partial set -/// would be wrong rather than merely stale. +/// Every fully-embedded note with no centroid row, path-ordered (GH #170). A re-chunk +/// keeps vectors but drops the centroid, leaving nothing pending; without this the note +/// silently leaves discovery's coarse stage (S3). Fully embedded only: a partial +/// centroid would be wrong. pub fn notes_missing_centroids(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT DISTINCT c.note_path FROM chunks c @@ -1402,17 +1175,14 @@ pub fn notes_missing_centroids(conn: &Connection) -> Result> { Ok(rows.collect::>>()?) } -/// Stream every stored `(note_path, centroid_blob)` through `f`, one row at a time — -/// discovery's first-stage coarse scan. O(notes), the whole point of the two-stage -/// shape (#38): the O(chunks) work happens only for the shortlisted notes. The blob -/// is *borrowed* for the callback (`get_ref`), so scoring adds no per-row allocation. +/// Stream every `(note_path, centroid_blob)` through `f`: discovery's O(notes) coarse +/// scan (#38). The blob is borrowed, so scoring allocates nothing per row. pub fn for_each_note_centroid(conn: &Connection, mut f: impl FnMut(&str, &[u8])) -> Result<()> { let mut stmt = conn.prepare("SELECT note_path, centroid FROM note_centroids")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { - // Match the ValueRefs rather than `.as_str()?` — their `FromSqlError` isn't in - // our error enum, and the column types are fixed by our own DDL, so a - // mismatched row is skipped rather than an error. + // Match ValueRefs: `FromSqlError` isn't in our error enum, and our DDL fixes the + // types, so a mismatched row is skipped. let rusqlite::types::ValueRef::Text(text) = row.get_ref(0)? else { continue; }; @@ -1426,15 +1196,9 @@ pub fn for_each_note_centroid(conn: &Connection, mut f: impl FnMut(&str, &[u8])) Ok(()) } -/// Stream every embedded chunk's `(chunk_id, vector_blob)` through `f`, one row at a -/// time — the scan behind every vector read, which never materializes the whole space -/// at once (ADR-0006). The blob is *borrowed* for the callback, so scoring adds no -/// per-row allocation. -/// -/// Ranking is per **chunk**, so content-addressing has to be undone here: the join -/// hands each vector to every chunk that addresses it, which is why two notes with -/// identical text still get one rank each. A shared vector is read once and scored -/// twice rather than stored twice. +/// Stream every embedded chunk's `(chunk_id, vector_blob)` through `f` without +/// materializing the space (ADR-0006). The join hands a shared vector to each chunk that +/// addresses it, since ranking is per chunk. pub fn for_each_stored_vector(conn: &Connection, mut f: impl FnMut(i64, &[u8])) -> Result<()> { let mut stmt = conn.prepare( "SELECT c.id, e.vector FROM embeddings e JOIN chunks c ON c.text_hash = e.text_hash", @@ -1442,8 +1206,7 @@ pub fn for_each_stored_vector(conn: &Connection, mut f: impl FnMut(i64, &[u8])) let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let chunk_id: i64 = row.get(0)?; - // Match the ValueRef rather than `as_blob()?` — its `FromSqlError` isn't in our - // error enum, and a stored vector is always a Blob. + // Match the ValueRef: `FromSqlError` isn't in our error enum. if let rusqlite::types::ValueRef::Blob(blob) = row.get_ref(1)? { f(chunk_id, blob); } @@ -1451,9 +1214,8 @@ pub fn for_each_stored_vector(conn: &Connection, mut f: impl FnMut(i64, &[u8])) Ok(()) } -/// Every chunk's squared-L2 distance to `query`, sorted nearest first (ties broken by -/// `chunk_id` for determinism) — the scan behind [`vector_search`], computed in-process over the [`for_each_stored_vector`] -/// stream: one sequential statement, one reused decode buffer (ADR-0006). +/// Every chunk's squared-L2 distance to `query`, nearest first, ties by `chunk_id` +/// (ADR-0006). fn scan_vector_distances(conn: &Connection, query: &[f32]) -> Result> { let mut out: Vec<(i64, f32)> = Vec::new(); let mut scratch: Vec = Vec::new(); @@ -1465,11 +1227,8 @@ fn scan_vector_distances(conn: &Connection, query: &[f32]) -> Result Result> { let mut hits = scan_vector_distances(conn, query)?; hits.truncate(k); @@ -1480,34 +1239,28 @@ pub fn vector_search(conn: &Connection, query: &[f32], k: usize) -> Result, - /// The resolved **resource** target (vault-relative path into `resources`), - /// when the link names a non-`.md` file — mutually exclusive with `dst_path` - /// in practice (a target resolves as a note or a resource, never both). + /// The resolved resource target (a non-`.md` file); exclusive with `dst_path`. pub dst_resource_path: Option, pub dst_path_raw: String, pub r#type: String, pub origin: String, pub explanation: Option, - /// An embed form (`![alt](…)` / `![[…]]`) — display nicety, not a verb. + /// An embed form (`![alt](…)` / `![[…]]`): display only, not a verb. pub embed: bool, /// The authored alt/link/alias text. pub caption: Option, pub occurrence_index: i64, } -/// Replace a note's edges. Every edge is authored (body links ∪ frontmatter -/// `b2_relations:`), so this deletes the note's edges and re-inserts them from the -/// current Markdown (Flow ①) — the whole graph is a projection of Markdown (G1). +/// Replace a note's edges from its current Markdown (Flow ①, G1). pub fn replace_authored_edges(conn: &Connection, src_path: &str, edges: &[EdgeRow]) -> Result<()> { conn.execute("DELETE FROM edges WHERE src_path = ?1", [src_path])?; for e in edges { @@ -1538,9 +1291,7 @@ pub fn replace_authored_edges(conn: &Connection, src_path: &str, edges: &[EdgeRo // resolver: the authored `[[path]]` → the note it names // --------------------------------------------------------------------------- -/// Whether `path` names an indexed note — the existence check that replaced the -/// two-way `b2id ⇄ path` resolver (GH #170): with the path *being* the identity, -/// resolution is one membership test rather than a translation. +/// Whether `path` names an indexed note; the path is the identity (GH #170). pub fn note_exists(conn: &Connection, path: &str) -> Result { let found: Option = conn .query_row("SELECT 1 FROM notes WHERE path = ?1", [path], |r| r.get(0)) @@ -1548,36 +1299,29 @@ pub fn note_exists(conn: &Connection, path: &str) -> Result { Ok(found.is_some()) } -/// One inbound edge a move/delete must act on: the source note's vault-relative -/// path and the exact authored link text (`dst_path_raw`) written there. +/// One inbound edge a move or delete must act on: the source note and the authored link +/// text. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InboundEdge { pub src_path: String, pub dst_raw: String, } -/// Every active authored edge pointing *at* the note `dst_path`, as [`InboundEdge`] -/// rows. This is the bounded set a move must rewrite — the -/// materialized graph names the files to touch, so a move never scans the vault -/// (index-engine.md §8). Ordered for deterministic rewriting. +/// Every edge pointing at the note `dst_path`: the set a move rewrites without scanning +/// the vault (index-engine.md §8). Ordered. pub fn inbound_edge_targets(conn: &Connection, dst_path: &str) -> Result> { inbound_edges_on(conn, "dst_path", dst_path) } -/// Which member of a set an [`inbound_edges_of`] edge points at: an index into the -/// note paths or the resource paths the set was read from. +/// Which member an [`inbound_edges_of`] edge points at, as an index into its input. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InboundTarget { Note(usize), Resource(usize), } -/// Every edge pointing at one of a set of members — the notes at `notes`, the -/// resources at `resources` — tagged with the member it targets: the notes' edges -/// first, then the resources', each member's in [`inbound_edge_targets`] order. The one -/// graph read a move (which rewrites these links) and a delete (which dangles them) -/// both start from, bounded by the inbound count rather than a vault scan -/// (index-engine.md §8). +/// Every edge pointing at one of `notes` or `resources`, tagged with its target; notes +/// first. The graph read a move or delete starts from (index-engine.md §8). pub fn inbound_edges_of( conn: &Connection, notes: &[&str], @@ -1595,8 +1339,7 @@ pub fn inbound_edges_of( Ok(out) } -/// The notes linking into a set of members ([`inbound_edges_of`]), sorted and deduped — -/// the files whose edges must re-project once the set moves or goes. +/// The notes linking into a set of members, sorted and deduped. pub fn inbound_sources( conn: &Connection, notes: &[&str], @@ -1630,31 +1373,22 @@ fn inbound_edges_on( Ok(rows.collect::>>()?) } -/// Every indexed note path under the directory `dir` (vault-relative, no trailing -/// slash), path-ordered — the moved (or deleted) set of a **directory** op. +/// Every indexed note path under `dir` (no trailing slash), path-ordered. pub fn notes_under_dir(conn: &Connection, dir: &str) -> Result> { members_under_dir(conn, Members::Notes, dir) } -/// Every inventoried resource path under the directory `dir` — the resource half -/// of [`notes_under_dir`], same prefix semantics, path-ordered. +/// Every inventoried resource path under `dir`, as [`notes_under_dir`]. pub fn resources_under_dir(conn: &Connection, dir: &str) -> Result> { members_under_dir(conn, Members::Resources, dir) } -/// Re-key a note from `old_path` to `new_path` — **the** index-side move (ADR-0003). -/// -/// One statement, and the FK graph does the rest: `chunks`, -/// `note_centroids` and `edges.src_path` all declare `ON UPDATE CASCADE`, so every -/// derived row travels with the note atomically and its chunk *vectors* are never -/// touched at all — they are content-addressed, so they belong to the text, not the -/// path (ADR-0006). What does **not** cascade is `edges.dst_path`: it carries no FK, -/// because it must be free to be NULL for a dangling link, so callers re-project every -/// inbound source afterwards. +/// Re-key a note from `old_path` to `new_path`: the index-side move (ADR-0003). Child rows +/// cascade; vectors belong to the text (ADR-0006). `edges.dst_path` has no FK, so callers +/// re-project inbound sources afterwards. /// -/// A **directory move** runs this for every moved note *before* re-projecting any file, -/// so link resolution stays independent of re-projection order. Requires -/// `PRAGMA foreign_keys = ON`; without it the cascades silently do not fire. +/// A directory move re-keys every note before re-projecting any file, so resolution is +/// order-independent. Requires `PRAGMA foreign_keys = ON`. pub fn repoint_note_path(conn: &Connection, old_path: &str, new_path: &str) -> Result<()> { conn.execute( "UPDATE notes SET path = ?1 WHERE path = ?2", @@ -1674,10 +1408,8 @@ pub fn resolve_link_target(conn: &Connection, link_path: &str) -> Result Result> { Ok(conn .query_row("SELECT path FROM resources WHERE path = ?1", [path], |r| { @@ -1687,12 +1419,11 @@ pub fn resolve_resource_target(conn: &Connection, path: &str) -> Result(0)) .unwrap() .map(|n| n.unwrap()) - // FTS5 keeps its own shadow tables (`chunks_fts_data`, `_idx`, …) alongside - // the virtual table; they are SQLite's bookkeeping, created and dropped with - // it, never ours to list. `sqlite_%` is reserved for the same reason. + // FTS5 shadow tables and `sqlite_%` are SQLite's own. .filter(|n| !n.starts_with("chunks_fts_") && !n.starts_with("sqlite_")) .collect(); @@ -1743,12 +1470,9 @@ mod tests { ); } - /// The full truth table for the profiler's emit guard (all 8 combinations). - /// Both listening paths have to survive independently: a slow statement logs at - /// WARN, so it must emit with DEBUG **off**, and DEBUG on emits regardless of speed. + /// All 8 combinations: slow emits with only WARN on; DEBUG emits regardless. #[test] fn emits_only_when_some_level_would_receive_the_event() { - // Something is listening at the level this event would use. assert!( should_emit(true, true, false), "slow + WARN on: the slow-query log, and the case a naive simplification drops" @@ -1764,7 +1488,6 @@ mod tests { "DEBUG on: every statement logs" ); - // Nothing would receive it — skip the string work. assert!( !should_emit(false, true, false), "fast statement, only WARN on: nothing to say" diff --git a/crates/b2-core/src/dirs.rs b/crates/b2-core/src/dirs.rs index 8268b3c..ea30f73 100644 --- a/crates/b2-core/src/dirs.rs +++ b/crates/b2-core/src/dirs.rs @@ -1,9 +1,5 @@ -//! Folder-structure ops — the structure half of "the vault directory is the source -//! of truth": Markdown files carry content, the directory tree carries **structure** -//! (data-model.md §1), and a folder — empty or not — is user-authored vault material -//! exactly like a note. Folders are never projected into the index (nothing to -//! chunk, embed, or link), so both ops here read/write the filesystem directly: -//! the walk *is* the projection, and a listing can never go stale against disk. +//! Folder ops. A folder, empty or not, is vault material like a note (data-model.md §1), +//! but is never indexed, so these read and write the filesystem directly. use std::fs; use std::path::Path; @@ -18,12 +14,8 @@ pub struct DirCreateReport { pub dir: String, } -/// Every folder under `vault_root` (empty ones included), vault-relative with `/` -/// separators and no trailing slash, sorted. Dot-prefixed directories are skipped — the -/// same hidden rule the ingest walk applies (GH #136), so the tree and the index agree on -/// what a vault member is. That mirroring includes symlink behaviour: `is_dir()` follows -/// directory symlinks exactly as the ingest walk does, so a vault-wide symlink policy, if -/// one ever lands, must change both walks together. +/// Every folder under `vault_root`, sorted, skipping dot-prefixed ones as the ingest walk +/// does (GH #136). `is_dir()` follows symlinks as that walk does; change both together. pub fn list_dirs(vault_root: &Path) -> Result> { let mut out = Vec::new(); collect_dirs(vault_root, vault_root, &mut out)?; @@ -41,8 +33,7 @@ fn collect_dirs(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { if crate::pathspec::is_hidden(&path) { continue; } - // `path` was produced by walking `root`, so `strip_prefix` cannot fail; - // handle it gracefully anyway rather than panic on the invariant. + // Cannot fail under `root`; skip rather than panic. let Ok(rel) = path.strip_prefix(root) else { continue; }; @@ -52,12 +43,8 @@ fn collect_dirs(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { Ok(()) } -/// Create the folder `dir_input` (vault-relative; a trailing `/` is tolerated), -/// missing parents included — matching `create_note`'s parent creation and the -/// UI's nested-name input — but, unlike `mkdir -p`, **refusing an occupied -/// target**: the user asked to *create*, and it's already there. Errors with -/// [`Error::DirDestination`] for an invalid path and [`Error::DirTargetExists`] -/// when anything (file or folder) already sits there. +/// Create the folder `dir_input` with missing parents. Unlike `mkdir -p`, an occupied +/// target is refused. pub fn create_dir(vault_root: &Path, dir_input: &str) -> Result { let dir = crate::pathspec::normalize_rel_dir(dir_input).map_err(Error::DirDestination)?; let abs = vault_root.join(&dir); diff --git a/crates/b2-core/src/discover.rs b/crates/b2-core/src/discover.rs index c0bb293..35ef4a4 100644 --- a/crates/b2-core/src/discover.rs +++ b/crates/b2-core/src/discover.rs @@ -1,32 +1,11 @@ -//! Connection-discovery candidate generation — the engine behind **`b2 similar`**. It -//! surfaces the notes to *consider* linking; the human is the precision gate, and -//! `b2 link` commits one (ADR-0009). It is the only discovery stage, and the only one -//! that reads the graph. +//! Connection discovery, the engine behind `b2 similar`: notes semantically near the anchor +//! but not already linked, for the human to judge (ADR-0009). Recall-oriented: the ranked +//! list is always served and no statistic gates membership (ADR-0014); z only grades. //! -//! A candidate is a note **semantically near the anchor but not already connected** — -//! the *complement* of the graph, not the intersection. Generation is deliberately **recall-oriented**, and the **ranked list is -//! always served**: no statistic gates membership, and `limit` under-fills only for want -//! of scorable notes. ADR-0014 carries the retired existence gate's whole evidence trail -//! — including why an anchor-local statistic cannot tell *nothing is related* from -//! *everything is related*, and the terms any replacement signal must win on. The z -//! survives ungated as the strength band's input. -//! -//! Mechanics are **two-stage**: -//! -//! 1. **Coarse, O(notes):** rank every note by its stored *centroid*'s distance to the -//! anchor's, minus the anchor and its 1-hop neighbours, and keep a shortlist many -//! times larger than `limit`. -//! 2. **Exact, O(shortlist):** for each shortlisted note, load its chunk vectors and -//! score the exact max-sim across the anchor's chunks, keeping the chunk that -//! achieved it as evidence. -//! -//! Stage 2's scoring is the same exact max-sim a whole-space scan computed, so a -//! shortlist that covers the vault reproduces it exactly; what the shape buys is reading -//! N_notes centroid rows instead of N_chunks vector rows (#38). Discovery is vector-only -//! and **re-embeds nothing** — the anchor is represented by the vectors already stored, -//! never by an `embed_query` of its text (bge's asymmetric query prefix is the wrong -//! side). Graph distance beyond the 1-hop exclusion is **not** a ranking signal; -//! weighting it is a deferred, eval-gated experiment. +//! Two stages (#38): a coarse O(notes) centroid scan, minus the anchor and its 1-hop +//! neighbours, keeps a wide shortlist; then exact chunk max-sim scores it, keeping the +//! winning chunk as evidence. Re-embeds nothing: the anchor is its stored vectors, never an +//! `embed_query` (bge's query prefix is the wrong side). use crate::db; use crate::embed::{centroid_of, l2_sq, unpack_f32_into}; @@ -34,57 +13,38 @@ use crate::error::Result; use crate::graph; use rusqlite::Connection; -/// The exclusion radius: a candidate must not already be *directly* linked to the -/// anchor. Fixed at 1 so triadic-closure candidates — two hops away, transitively -/// related but with no direct edge — stay in the pool. +/// The exclusion radius. 1, so two-hop (triadic-closure) candidates stay in the pool. const EXCLUDE_HOPS: usize = 1; -/// Floor on the stage-1 shortlist. Generous relative to any `limit` a human-facing -/// surface asks for: discovery is recall-oriented (the human is the precision gate), -/// so the coarse stage must never be the reason a nearby note goes missing. On any -/// vault at or below this many candidate notes the two-stage result is *exactly* -/// the old whole-space scan's. +/// Floor on the stage-1 shortlist, generous so the coarse stage never loses a nearby note. +/// At or below this many notes the result equals a whole-space scan. const SHORTLIST_MIN: usize = 200; -/// Stage-1 shortlist size per requested result: `limit × this`, floored at -/// [`SHORTLIST_MIN`]. A wide margin over `limit` because a note's centroid can rank -/// a few places below where its single best chunk deserves (the centroid smooths -/// over the note's chunks); the exact stage re-ranks whatever survives. +/// Stage-1 shortlist per requested result, floored at [`SHORTLIST_MIN`]. Wide, because a +/// centroid can rank below its note's best chunk. const SHORTLIST_PER_RESULT: usize = 20; -/// Scored pools smaller than this carry no statistic: a z over a handful of distances -/// is noise, so under it every candidate is served **ungraded** and an adapter says so -/// rather than banding. It moves *banding only*, never membership — serving is -/// continuous in population size (ADR-0014; the retired gate shared this guard, which -/// made it a serve-everything/serve-nothing cliff at n = 12). +/// Below this pool size candidates are served ungraded: a z over a handful is noise. +/// Affects banding only, never membership (ADR-0014). const STATS_MIN_POPULATION: usize = 12; -/// One discovery candidate: a note near the anchor and not already connected, ranked by -/// best-passage `score`. Owned, so the façade can resolve it to a -/// [`SimilarView`](crate::vault::SimilarView) without threading a lifetime through. +/// One discovery candidate, ranked by best-passage `score`. #[derive(Debug, Clone, PartialEq)] pub struct CandidateNote { - /// The candidate note's vault-relative path — its identity (L1). + /// The candidate's identity (L1). pub note_path: String, - /// Best chunk-pair similarity across the anchor's chunks × this note's chunks — - /// higher is nearer (negated L2 distance, matching [`Hit`](crate::search::Hit)). + /// Best chunk-pair similarity, as negated L2 distance (like [`Hit`](crate::search::Hit)). pub score: f64, - /// The candidate's chunk that achieved `score` — the passage that made this note - /// similar, surfaced by `b2 similar` as the evidence for *why* it appeared. + /// The candidate's chunk that achieved `score`, shown as evidence. pub evidence_chunk_id: i64, - /// The candidate's stage-2 best-passage z against the anchor's scored shortlist - /// population. It **gates nothing** (ADR-0014): it is the strength band's input and - /// nothing else. `None` when no statistic was computed (an ungraded space, a pool - /// under [`STATS_MIN_POPULATION`], or zero variance). It is strictly monotonic in - /// `score` within one query, so the band and the row order are one number by - /// construction. + /// Best-passage z against the scored shortlist: the strength band's input, gating + /// nothing (ADR-0014). Monotonic in `score`, so band and row order agree. `None` when + /// ungraded. pub z: Option, } -/// The statistic behind the strength band: the mean and spread of the scored -/// population's best-passage **squared** distances. It grades; it never gates -/// (ADR-0014). Any distance in the same space can be read against it, which is how a -/// passage pair in the explain view is graded on the same yardstick as the cards. +/// Mean and spread of the scored population's best-passage squared distances. Grades, +/// never gates (ADR-0014); explain grades passage pairs on the same yardstick. #[derive(Debug, Clone, Copy, PartialEq)] struct Population { mean: f64, @@ -98,53 +58,44 @@ impl Population { } } -/// One anchor's whole discovery field: the stage-1 order, the stage-2 scores, and the -/// statistic. [`candidates`] serves a prefix of it and [`explain`] reads one note's place -/// in it, so a card and its explanation are two reads of one computation and can never -/// disagree. +/// One anchor's whole discovery field. [`candidates`] and [`explain`] both read it, so a +/// card and its explanation can't disagree. struct Field { - /// Every unlinked note with a centroid, nearest centroid first (ties by path), - /// before the shortlist cut. + /// Every unlinked note with a centroid, nearest first, before the shortlist cut. coarse: Vec, /// How many of `coarse` stage 2 was allowed to score. shortlist: usize, - /// Stage 2: `(squared distance, note, evidence chunk)` for every shortlisted note - /// with stored vectors, nearest first (ties by path). + /// Stage 2: `(squared distance, note, evidence chunk)`, nearest first. scored: Vec<(f32, String, i64)>, - /// `None` when ungraded: not asked for, a pool under [`STATS_MIN_POPULATION`], or - /// zero variance. + /// `None` when ungraded. population: Option, - /// The anchor and its 1-hop neighbours: excluded from discovery. + /// The anchor and its 1-hop neighbours. excluded: std::collections::HashSet, } -/// Compute `anchor`'s field at the shortlist a `limit`-sized list uses. `None` when -/// there is nothing to search from: no embedding space, or an anchor with no stored -/// vectors. +/// Compute `anchor`'s field for a `limit`-sized list. `None` with no embedding space or +/// no anchor vectors. fn field(conn: &Connection, anchor: &str, limit: usize, grade: bool) -> Result> { if !db::embedding_space_exists(conn)? { return Ok(None); } - // The anchor's own stored vectors, loaded once (re-embeds nothing — index-engine.md §3); - // none ⇒ nothing to search from. Its centroid is computed in-process from them - // rather than read back, so an anchor mid-embed still discovers from what it has. + // Stored vectors only (index-engine.md §3). The centroid is computed here, not read + // back, so an anchor mid-embed still discovers from what it has. let (anchor_ids, anchor_vecs): (Vec, Vec>) = db::note_chunk_vectors(conn, anchor)?.into_iter().unzip(); let Some(anchor_centroid) = centroid_of(&anchor_vecs) else { return Ok(None); }; - // The only use of the graph in generation: subtract what's already linked — the - // anchor and everything within 1 hop (self + direct neighbors). + // The graph's only use here: subtract what's already linked. let excluded = graph::reachable_within(conn, anchor, EXCLUDE_HOPS)?; - // Stage 1 — coarse shortlist over note centroids: one O(notes) scan, excluded - // notes skipped up front so they never occupy a shortlist slot. + // Stage 1: coarse scan over centroids, skipping excluded notes so they take no slot. let mut coarse: Vec<(f32, String)> = Vec::new(); let mut scratch: Vec = Vec::new(); db::for_each_note_centroid(conn, |note, blob| { if excluded.contains(note) { - return; // the anchor or a direct neighbor — already connected + return; } unpack_f32_into(blob, &mut scratch); coarse.push((l2_sq(&anchor_centroid, &scratch), note.to_string())); @@ -152,19 +103,13 @@ fn field(conn: &Connection, anchor: &str, limit: usize, grade: bool) -> Result = coarse.into_iter().map(|(_, note)| note).collect(); - // Stage 1 ends here, and nothing judges it: the shortlist is a recall device, never - // a quality gate (GH #192). It used to be both — cutting the coarse list on centroid - // z meant max-sim could never rescue a candidate whose best passage is far nearer - // than its centroid suggests, which is exactly a multi-topic note's shape (ADR-0014). + // The shortlist is a recall device, never a quality gate (GH #192, ADR-0014). let shortlist = limit .saturating_mul(SHORTLIST_PER_RESULT) .max(SHORTLIST_MIN); - // Stage 2 — exact max-sim over the whole shortlist: per note, the best (smallest - // squared-L2) of its [`nearest_pairs`] — the same kernel the explanation reads, so - // the card's evidence is by construction the explanation's first pair. The earliest - // chunk wins a tie. A shortlisted note with no stored chunk vectors (possible - // mid-embed) scores nothing and drops out. + // Stage 2: exact max-sim via [`nearest_pairs`], the kernel explain reads too. The + // earliest chunk wins a tie; a note with no vectors yet drops out. let mut scored: Vec<(f32, String, i64)> = Vec::new(); for note_path in coarse.iter().take(shortlist) { let pairs = nearest_pairs( @@ -179,16 +124,13 @@ fn field(conn: &Connection, anchor: &str, limit: usize, grade: bool) -> Result= STATS_MIN_POPULATION { let n = scored.len() as f64; @@ -213,21 +155,9 @@ fn field(conn: &Connection, anchor: &str, limit: usize, grade: bool) -> Result, } @@ -287,25 +213,19 @@ pub struct GradedPair { #[derive(Debug, Clone, PartialEq)] pub struct Explanation { pub standing: Standing, - /// The candidate's rank by whole-note centroid in stage 1 (1-based), when it entered - /// stage 1 at all. Beside a best-passage rank it shows a buried gem: a note whose - /// one section matches far better than the note as a whole. + /// The candidate's 1-based centroid rank in stage 1. Beside its passage rank it shows + /// a note whose one section matches far better than the whole. pub centroid_rank: Option, /// The candidate's z, when it was scored in a graded field. pub z: Option, - /// Every scored note's z in served order (so descending): the field the band is - /// relative to. Empty when ungraded. + /// Every scored note's z, descending. Empty when ungraded. pub population: Vec, /// One pair per candidate passage, nearest first ([`passage_pairs`]), each graded. pub pairs: Vec, } -/// Explain one note's place in `anchor`'s discovery field, at the shortlist a -/// `limit`-sized list uses. The same computation [`candidates`] serves from, so a -/// served row's rank, z and winning pair are exactly the card's. Answers for any note, -/// not only a served one: a linked, unembedded or unshortlisted note says which, and its -/// passages are still compared when both sides have vectors. A pure read over stored -/// vectors, re-embedding nothing. +/// Explain one note's place in `anchor`'s discovery field, from the same computation +/// [`candidates`] serves. Answers for any note, served or not. Re-embeds nothing. pub fn explain( conn: &Connection, anchor: &str, @@ -357,38 +277,24 @@ pub fn explain( shortlist: field.shortlist, } } else { - // Not scored and not past the shortlist: it has no centroid or no chunk - // vectors yet. + // Not scored and not past the shortlist: no centroid or vectors yet. Standing::Unembedded }; Ok(out) } -/// One matched passage pair between an anchor and a candidate: the candidate's chunk, -/// the anchor's chunk nearest to it, and how near they are. +/// A candidate's chunk, the anchor chunk nearest to it, and how near. #[derive(Debug, Clone, PartialEq)] pub struct PassagePair { - /// The anchor's chunk nearest to `candidate_chunk_id`. pub anchor_chunk_id: i64, - /// The candidate's chunk this pair is about. pub candidate_chunk_id: i64, - /// Negated L2 distance between the two — higher is nearer, the same unit as - /// [`CandidateNote::score`]. + /// Negated L2 distance, as in [`CandidateNote::score`]. pub score: f64, } -/// The evidence behind one discovery row: up to `limit` passage pairs between `anchor` -/// and `candidate`, nearest first — **one pair per candidate chunk**, each matched to the -/// anchor chunk nearest to it. The first pair is exactly the pair [`candidates`] scored -/// the note on (same distances, same strictly-less tie rule, so the same -/// `evidence_chunk_id` at the same `score`): an explanation built from these is about -/// the passage the card showed, never a second ranking that could disagree with it. -/// -/// [`candidates`] keeps only the winning candidate chunk because a list needs no more; -/// this is the read for the moment a human asks *why* one row is there, where the -/// anchor's half of the pair is the other half of the answer. A pure read over stored -/// vectors, re-embedding nothing. Empty when the vault has no embedding space, when -/// either note has no stored vectors, or when `limit` is 0. +/// Up to `limit` passage pairs between `anchor` and `candidate`, nearest first, one per +/// candidate chunk. The first is exactly the pair [`candidates`] scored the note on. +/// Re-embeds nothing. pub fn passage_pairs( conn: &Connection, anchor: &str, @@ -401,8 +307,7 @@ pub fn passage_pairs( .collect()) } -/// [`passage_pairs`] with each pair's squared distance kept beside it, the unit the -/// population statistic reads. +/// [`passage_pairs`] with each squared distance kept, for grading. fn passage_pairs_sq( conn: &Connection, anchor: &str, @@ -419,8 +324,7 @@ fn passage_pairs_sq( &anchor_vecs, db::note_chunk_vectors(conn, candidate)?, ); - // Stable, so equal distances keep the candidate's chunk order — the earliest chunk - // wins a tie, as it does in `candidates`. + // Stable, so the earliest chunk wins a tie, as in `candidates`. pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); Ok(pairs .into_iter() @@ -438,12 +342,9 @@ fn passage_pairs_sq( .collect()) } -/// Discovery's max-sim kernel: for each candidate chunk, in order, the anchor chunk -/// nearest to it, as `(squared distance, anchor chunk, candidate chunk)`. Strictly-less -/// keeps the earliest anchor chunk on a tie. Stage 2 takes the minimum of this and the -/// explanation sorts it, so a card and its explanation read one computation. Squared L2 -/// is the ranking key without the per-comparison `sqrt`; the readers take it once per -/// surfaced pair. Empty when either side has no vectors. +/// Discovery's max-sim kernel: per candidate chunk, the nearest anchor chunk, as +/// `(squared distance, anchor chunk, candidate chunk)`. Strictly-less keeps the earliest +/// anchor chunk on a tie. fn nearest_pairs( anchor_ids: &[i64], anchor_vecs: &[Vec], diff --git a/crates/b2-core/src/embed.rs b/crates/b2-core/src/embed.rs index 6f1cfe1..6a4a485 100644 --- a/crates/b2-core/src/embed.rs +++ b/crates/b2-core/src/embed.rs @@ -1,57 +1,41 @@ -//! The embedder seam (index-engine.md §6; the "build for tomorrow's -//! model" tenet in invariants.md). Producing embeddings inside a single binary -//! is the one genuinely hard part, and it is *orthogonal* to the store — so it -//! sits behind this trait. The engine is built and tested against a deterministic -//! fake; the real local model (`b2-embed`'s candle-backed `LocalEmbedder`, -//! index-engine.md §6) drops in through this same seam with no schema or flow change. +//! The embedder seam (index-engine.md §6). The engine is tested against a deterministic +//! fake; `b2-embed`'s real model drops in here with no schema or flow change. use crate::error::Result; /// Turns note text into a vector. The dimension is fixed per model and recorded as /// `meta.embed_dim` (ADR-0007). /// -/// `embed` is **fallible**: the fake never fails, but a real model runs tensor math that -/// can, and the index path must surface that rather than panic. Retrieval is -/// **asymmetric-ready** — [`embed`](Self::embed) embeds a passage, -/// [`embed_query`](Self::embed_query) a query; models that prefix the two differently -/// override the latter, whose default is symmetric. +/// Fallible because a real model can fail. [`embed`](Self::embed) embeds a passage and +/// [`embed_query`](Self::embed_query) a query, for asymmetric models. pub trait Embedder { - /// Stable identifier recorded in `meta.embed_model_id`. A change to it (or to - /// `dim`) is a model swap → drop the stored vectors + re-embed (index-engine.md §8). + /// Stable identifier recorded in `meta.embed_model_id`. A change is a model swap + /// (index-engine.md §8). fn model_id(&self) -> &str; /// Vector dimension; must equal the recorded `meta.embed_dim`. fn dim(&self) -> usize; /// Embed one document/passage for indexing. fn embed(&self, text: &str) -> Result>; - /// Embed a search query. Default is symmetric (query == document); asymmetric - /// models override this to apply their query-side prompt prefix. + /// Embed a search query. Symmetric by default; asymmetric models add their prefix. fn embed_query(&self, text: &str) -> Result> { self.embed(text) } - /// Embed a batch of documents/passages for indexing, one vector per input in - /// order. The default maps [`embed`](Self::embed) over the slice — always - /// correct; a real model overrides this to run **one batched forward pass**, - /// which is dramatically faster on CPU than N single passes (the reindex hot - /// path). Chunk vectors are independent, so batch boundaries never change a - /// result. + /// Embed a batch of passages, one vector per input in order. A real model overrides + /// this with one batched forward pass. Batch boundaries never change a result. fn embed_batch(&self, texts: &[&str]) -> Result>> { texts.iter().map(|t| self.embed(t)).collect() } } -/// Deterministic, content-addressed embedder for tests/dev: identical text → -/// identical vector, so KNN is reproducible and drop-&-rebuild yields the same -/// vectors. It is **not** semantic — it stands in for `b2-embed`'s real local model -/// behind the same seam, so the engine suite runs offline (`B2_EMBEDDER=fake`, -/// docs/architecture.md). +/// Deterministic embedder for tests and dev (`B2_EMBEDDER=fake`): identical text gives +/// an identical vector. Not semantic. #[derive(Debug, Clone, Copy)] pub struct FakeEmbedder { dim: usize, } impl FakeEmbedder { - /// A fake of dimension `dim`. A vector has at least one component, so a `dim` of 0 - /// is clamped to 1 rather than refused. + /// A fake of dimension `dim`, clamped to at least 1. pub fn new(dim: usize) -> Self { Self { dim: dim.max(1) } } @@ -64,11 +48,8 @@ impl Default for FakeEmbedder { } } -/// The fake embedder's recorded model id — `pub` because the identity is load-bearing -/// beyond tests: a vault whose `meta.embed_model_id` equals this holds hash vectors -/// with no semantic geometry, so `Vault::similar` never grades it — the ranked list -/// is served with no z, since a statistic over hash noise would be a band worth of -/// noise (GH #150/#197). +/// The fake embedder's model id. A vault recorded with it holds hash vectors, so +/// `Vault::similar` never grades it (GH #150/#197). pub const FAKE_MODEL_ID: &str = "fake-deterministic-v1"; impl Embedder for FakeEmbedder { @@ -81,8 +62,7 @@ impl Embedder for FakeEmbedder { } fn embed(&self, text: &str) -> Result> { - // blake3 XOF → dim little-endian u32 words → floats in [0,1). Deterministic - // in text, so the same chunk always embeds to the same vector. + // blake3 XOF → dim little-endian u32 words → floats in [0,1). let mut hasher = blake3::Hasher::new(); hasher.update(text.as_bytes()); let mut reader = hasher.finalize_xof(); @@ -98,10 +78,8 @@ impl Embedder for FakeEmbedder { } } -/// Pack a vector as a compact little-endian float32 BLOB — the stored form of every -/// vector in the index (`embeddings.vector`, `note_centroids.centroid`; -/// index-engine.md §3). The query side packs the same way so an exact match has -/// distance 0. +/// Pack a vector as a little-endian float32 BLOB, the stored form of every vector +/// (index-engine.md §3). pub fn pack_f32(v: &[f32]) -> Vec { let mut out = Vec::with_capacity(v.len() * 4); for x in v { @@ -110,21 +88,15 @@ pub fn pack_f32(v: &[f32]) -> Vec { out } -/// Inverse of [`pack_f32`]: read a stored BLOB (little-endian float32, no header) -/// back into a vector. Used to reuse a note's *stored* chunk vectors as discovery -/// queries without re-embedding (index-engine.md §3). A trailing partial group can't occur -/// for a vector written by [`pack_f32`], so a non-multiple-of-4 length is simply -/// truncated rather than treated as an error. +/// Inverse of [`pack_f32`]. A trailing partial group is truncated, not an error. pub fn unpack_f32(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len() / 4); unpack_f32_into(bytes, &mut out); out } -/// [`unpack_f32`] into a caller-owned scratch buffer, reusing its capacity. The -/// whole-space scans decode one stored vector per visited row; a fresh `Vec` per row -/// was a measurable slice of the `b2 similar` stall (#38), where this costs nothing -/// after the first row. +/// [`unpack_f32`] into a reused scratch buffer: a fresh `Vec` per scanned row was +/// measurable (#38). pub fn unpack_f32_into(bytes: &[u8], out: &mut Vec) { out.clear(); out.extend( @@ -134,15 +106,11 @@ pub fn unpack_f32_into(bytes: &[u8], out: &mut Vec) { ); } -/// Squared Euclidean distance between two equal-length vectors — the index's one ranking -/// key, minus the final `sqrt` (monotonic, so dropping it never changes an ordering; it is -/// applied once per *surfaced* result). A length mismatch scores the shared prefix rather -/// than panicking. +/// Squared Euclidean distance, the index's ranking key. A length mismatch scores the +/// shared prefix rather than panicking. /// -/// Eight independent accumulators, summed at the end: float addition is non-associative, -/// so a single running sum forms one serial dependency chain the compiler must execute as -/// written — splitting it lets LLVM autovectorize. Measured at the #38 scale, the naive -/// iterator shape cost ~530 ms against this shape's ~75 ms. +/// Eight accumulators: float addition is non-associative, so one running sum can't +/// autovectorize (~530 ms vs ~75 ms at the #38 scale). pub fn l2_sq(a: &[f32], b: &[f32]) -> f32 { let n = a.len().min(b.len()); let (a, b) = (&a[..n], &b[..n]); @@ -164,18 +132,13 @@ pub fn l2_sq(a: &[f32], b: &[f32]) -> f32 { sum } -/// The **centroid** of a note's chunk vectors: their arithmetic mean, L2-normalized -/// (the spherical mean — the standard coarse representative when the underlying -/// vectors are cosine-normalized, as b2-embed's are). `None` for an empty set — a -/// note with no stored vectors has no centroid row. Deterministic: summation runs in -/// the given (chunk `seq`) order. Discovery's first stage ranks whole *notes* by -/// centroid distance, so its heavy scan is O(notes), not O(chunks) (#38). +/// A note's centroid: the L2-normalized mean of its chunk vectors, summed in the given +/// order; `None` for an empty set. Discovery's first stage scans these (#38). pub fn centroid_of(vectors: &[Vec]) -> Option> { let first = vectors.first()?; let mut mean = vec![0.0f32; first.len()]; for v in vectors { - // A length mismatch can't occur within one embedding space; fold the shared - // prefix rather than panic, mirroring `l2_sq`. + // Fold the shared prefix on a length mismatch, as `l2_sq` does. for (m, x) in mean.iter_mut().zip(v) { *m += x; } diff --git a/crates/b2-core/src/error.rs b/crates/b2-core/src/error.rs index 9797107..14b0457 100644 --- a/crates/b2-core/src/error.rs +++ b/crates/b2-core/src/error.rs @@ -1,8 +1,7 @@ -//! The crate error type — a `thiserror` enum, because the adapters match on its -//! variants to choose a user-facing message. +//! The crate error type. Adapters match its variants to choose a user-facing message. -/// Errors surfaced by the index engine. Kept internal/structured — user-facing -/// surfaces (the CLI, the desktop app) translate these into generic, actionable messages. +/// Errors surfaced by the engine. Adapters translate them into generic, actionable +/// messages. #[derive(thiserror::Error, Debug)] pub enum Error { #[error("sqlite error: {0}")] @@ -14,143 +13,99 @@ pub enum Error { #[error("frontmatter edit unsupported: {0}")] Frontmatter(String), - /// A note reference (a vault-relative path) did not resolve to any indexed - /// note — the one domain error the façade distinguishes from "found, no - /// results". + /// A note reference did not resolve to an indexed note. #[error("note not found: {0}")] NoteNotFound(String), - /// The embedder failed to produce a vector (real-model tensor/runtime error). - /// Kept as a message so `b2-core` stays free of the embedding runtime's types. + /// The embedder failed. A message, so `b2-core` stays free of the runtime's types. #[error("embedding failed: {0}")] Embed(String), - /// The chat provider failed outright — connection refused, an HTTP error, a - /// malformed stream — on the *answer* call of flow ④ (a condensation - /// failure never surfaces: it degrades to the raw question, GH #153). - /// The [`Error::Embed`] posture applied to the second seam: kept as a - /// message so `b2-core` stays free of the LLM runtime's types. + /// The chat provider failed on flow ④'s answer call (a failed condense degrades + /// instead, GH #153). A message, as for [`Error::Embed`]. #[error("llm call failed: {0}")] Llm(String), - /// One model reply asked for more tool calls than the provider's configured cap. A - /// typed sibling of [`Error::Llm`] rather than a message, because callers treat it - /// differently: a tool-using turn degrades on an ordinary failed lookup round (the - /// model may simply not support tools), but must **not** degrade on this — a reply - /// this far outside the protocol is a broken or hostile server, and answering anyway - /// would hide it. + /// One model reply asked for more tool calls than the configured cap. Separate from + /// [`Error::Llm`] because a tool-using turn must not degrade on it: it signals a + /// broken or hostile server. #[error("the model asked for more than {limit} tool calls in one reply")] ToolCallLimit { limit: usize }, - /// The index's recorded embedding model/dim differs from the active embedder, - /// so its vectors are incomparable with new query vectors. A read (search) - /// fails fast with this rather than returning silently wrong results; the fix - /// is a `reindex` (which re-embeds). See index-engine.md §8 and GitHub Issues. + /// The index's recorded embedder differs from the active one, so a search would be + /// silently wrong (index-engine.md §8). #[error("index built with embedding model {indexed}, but the active model is {active}; run `b2 reindex`")] ModelMismatch { indexed: String, active: String }, - /// `b2 mv` was given a destination that isn't a valid vault-relative Markdown - /// path — empty, absolute, escaping the vault via `..`, dot-prefixed (b2 - /// indexes no hidden member, so it would move the file out of the vault's - /// managed subtree — GH #136), or the source itself. + /// `b2 mv` was given an invalid destination: empty, absolute, escaping via `..`, + /// dot-prefixed (GH #136), or the source itself. #[error("invalid move destination: {0}")] MoveDestination(String), - /// `b2 mv` would overwrite an existing file — refused (the vault never clobbers, - /// data-model.md §1). The path is echoed for the user-facing message. + /// `b2 mv` would overwrite an existing file (data-model.md §1). #[error("move target already exists: {0}")] MoveTargetExists(String), - /// A move failed part-way and B2 could not put back every file it had already - /// changed (GH #230). Rare: an ordinary failure is rolled back and surfaces as - /// its own error. Carries the vault-relative paths still holding the move's - /// rewrite, so the user can check those links by hand, and the failure that - /// started the undo, so the adapters' internal log keeps the root cause. + /// A move failed part-way and could not be fully undone (GH #230). Carries the paths + /// still holding the rewrite, for the user to check, and the root cause. #[error("move failed and could not be fully undone: {} (cause: {source})", paths.join(", "))] MoveIncomplete { paths: Vec, source: Box, }, - /// `b2 mv` was given a source folder that doesn't exist in the vault — the - /// directory sibling of [`Error::NoteNotFound`] (a folder is a path prefix, - /// never an indexed row, so it resolves against the filesystem). + /// A source folder doesn't exist in the vault. #[error("directory not found: {0}")] DirNotFound(String), - /// `b2 add` was given a destination that isn't a valid vault-relative Markdown - /// path — empty, absolute, escaping the vault via `..`, or dot-prefixed (a - /// `.scratch.md` would never be indexed, so b2 refuses to author one — GH - /// #136). The `mv` parallel of [`Error::MoveDestination`], distinct so the CLI - /// can phrase it for note creation rather than a move. + /// `b2 add` was given an invalid path: empty, absolute, escaping via `..`, or + /// dot-prefixed (GH #136). #[error("invalid new-note path: {0}")] AddDestination(String), - /// `b2 add` would overwrite an existing file — refused (the vault never clobbers, - /// data-model.md §1). The path is echoed for the user-facing message. + /// `b2 add` would overwrite an existing file (data-model.md §1). #[error("note already exists: {0}")] AddTargetExists(String), - /// An import ([`crate::import`]) was handed a destination it can't place a file - /// at — a name carrying path separators, or a folder/name pair that normalizes - /// to an empty, absolute, escaping, or dot-prefixed path. The import sibling of - /// [`Error::AddDestination`], distinct so the adapters can phrase it for a file - /// arriving from outside rather than a note being authored. + /// An import was given an invalid destination: a name with separators, or a path + /// that is empty, absolute, escaping or dot-prefixed. #[error("invalid import destination: {0}")] ImportDestination(String), - /// An import would overwrite an existing vault file — refused, exactly as - /// [`Error::AddTargetExists`] is, and separate for the same reason: the thing - /// arriving may be a PDF, so the message must not call it a note. + /// An import would overwrite an existing file. Separate from + /// [`Error::AddTargetExists`] because the file may not be a note. #[error("file already exists: {0}")] ImportTargetExists(String), - /// `create_dir` was given a path that isn't a valid vault-relative folder — - /// empty, absolute, escaping via `..`, or dot-prefixed (b2 never manages - /// hidden paths). The folder sibling of [`Error::AddDestination`]. + /// `create_dir` was given an invalid folder path. #[error("invalid folder path: {0}")] DirDestination(String), - /// `create_dir` would land on an existing file or folder — refused rather than - /// silently merged (the user asked to *create*; it's already there). The folder - /// sibling of [`Error::AddTargetExists`]. + /// `create_dir` would land on an existing file or folder. #[error("folder already exists: {0}")] DirTargetExists(String), - /// `Vault::write` was handed a `base_revision` that no longer matches the file - /// on disk — an external editor changed the note since it was read. Refused - /// rather than clobbered: the caller re-reads (getting - /// the current revision) and either reloads or knowingly re-writes. The path is - /// carried for the debug detail, never for the user-facing message. + /// A save's `base_revision` no longer matches the file: it changed on disk since it + /// was read. The path is for debug detail only. #[error("write conflict: {0} changed on disk since it was read")] WriteConflict(String), - /// `b2 link` was given a `--type` that is not a core relation verb - /// (data-model.md §2). The core is the palette `b2 link` offers; a tail verb can - /// still be hand-authored in the Markdown, but the command validates to the core - /// so a typo (`support` for `supports`) is caught rather than silently stored. + /// `b2 link` was given a `--type` that is not a core verb (data-model.md §2), so a + /// typo is caught rather than stored. #[error("not a core relation verb: {0}")] InvalidRelation(String), - /// The index on disk was written by a **newer** `b2` than this binary — its - /// `meta.schema_version` is above [`crate::SCHEMA_VERSION`]. Refused rather than - /// rebuilt: the migration has no migrations by design (ADR-0002), so its answer to - /// an unreadable index is to drop it, and a *newer* index is unreadable for a - /// reason that dropping does not fix. An older `b2` left on `PATH` would otherwise - /// destroy a complete index on a read-only command. The fix is to run the newer - /// `b2`, not to reindex. + /// The index was written by a newer `b2` (schema above [`crate::SCHEMA_VERSION`]). + /// Refused rather than dropped (ADR-0002); the fix is the newer `b2`. #[error("index schema {found} was written by a newer b2 (this build reads {supported}); run the newer b2")] IndexTooNew { found: i64, supported: i64 }, - /// A resource reference (vault-relative path) did not resolve to any - /// inventoried resource — the resource sibling of [`Error::NoteNotFound`]. + /// A resource path did not resolve to an inventoried resource. #[error("resource not found: {0}")] ResourceNotFound(String), - /// The operation exists for notes but not (yet) for resources — e.g. - /// `b2 similar `, until resources have chunks and centroids of their - /// own. Distinct from [`Error::ResourceNotFound`] so the adapters can - /// say "not yet" rather than "no such file". + /// The operation works on notes but not yet on resources (e.g. `b2 similar`), so + /// adapters say "not yet" rather than "no such file". #[error("not supported for resources yet: {0}")] ResourceUnsupported(String), } diff --git a/crates/b2-core/src/graph.rs b/crates/b2-core/src/graph.rs index 4e99af2..a370482 100644 --- a/crates/b2-core/src/graph.rs +++ b/crates/b2-core/src/graph.rs @@ -1,7 +1,5 @@ -//! Graph queries over the typed `edges` table. Inversion (backlinks) is the -//! reason the graph is materialized rather than parsed at read time -//! (index-engine.md §3): a note's inbound edges live in every *other* -//! note, so `neighbors` is one indexed lookup, not a full-vault scan. +//! Graph queries over the typed `edges` table. Materialized so backlinks are one indexed +//! lookup rather than a full-vault scan (index-engine.md §3). use crate::error::Result; use crate::relation; @@ -15,31 +13,21 @@ pub enum Direction { Inbound, } -/// One neighbor of a note: the note at the other end of an active edge, plus the -/// display label (the verb for outbound, the inverse for inbound). +/// One neighbor of a note: the other end of an edge, plus its display label. #[derive(Debug, Clone)] pub struct Neighbor { - /// The vault-relative path at the other end of the edge — the note's identity - /// (L1). + /// The note at the other end (L1). pub other: String, - /// The stored relation verb. pub edge_type: String, pub direction: Direction, - /// Display label: the verb itself outbound, the inverse label inbound - /// (data-model.md §2). Symmetric verbs read the same both ways. + /// The verb outbound, its inverse label inbound (data-model.md §2). pub label: String, pub explanation: Option, - /// Edge provenance — which of the two homes authored it (data-model.md §0): - /// `inline` (a body link) or `frontmatter` (a `b2_relations:` entry, whether - /// `b2 link` wrote it on the human's command or they wrote it by hand). `b2 explain` - /// surfaces this so a plain body link reads distinctly from a typed relation. + /// `inline` (a body link) or `frontmatter` (a `b2_relations:` entry) (data-model.md §0). pub origin: String, } -/// All neighbors of the note at `note_path` — outbound edges (this note → others) -/// then inbound edges (others → this note), each labeled for display. Every edge is -/// authored and active (there is no suggestion lifecycle), so this is the note's full -/// typed graph. +/// All neighbors of `note_path`: outbound, then inbound. pub fn neighbors(conn: &Connection, note_path: &str) -> Result> { let mut out = collect_neighbors( conn, @@ -60,9 +48,7 @@ pub fn neighbors(conn: &Connection, note_path: &str) -> Result> { Ok(out) } -/// One direction's half of [`neighbors`]: run a query yielding -/// `(other, type, explanation, origin)` rows and label each for `direction` — -/// the verb itself outbound, its inverse inbound (data-model.md §2). +/// One direction's half of [`neighbors`], from `(other, type, explanation, origin)` rows. fn collect_neighbors( conn: &Connection, sql: &str, @@ -88,28 +74,21 @@ fn collect_neighbors( Ok(rows.collect::>>()?) } -/// One outbound link that resolved to **nothing** — neither a note nor a resource -/// exists at its target, so it is a *dangling* edge (`dst_path IS NULL AND -/// dst_resource_path IS NULL`). A note is one `.md` file (data-model.md §1), so a -/// `[[Hermes]]` that names a *folder* — or a plain typo — never resolves. These are -/// surfaced rather than silently dropped so a broken link is visible (GH #12). +/// A dangling outbound link: no note or resource at its target (a typo, or a folder; +/// data-model.md §1). Surfaced, not dropped (GH #12). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Unresolved { - /// The target exactly as authored (`dst_path_raw`) — e.g. `Hermes`. + /// The target as authored (`dst_path_raw`). pub target: String, - /// The relation verb (`references` for a bare link). pub edge_type: String, - /// Edge origin — `inline` (a body link) or `frontmatter` (a `b2_relations:` entry). + /// `inline` or `frontmatter`. pub origin: String, pub explanation: Option, } -/// A note's **dangling** outbound links: the edges it authored whose target resolves -/// to no note and no resource, in a deterministic order. The complement of -/// [`neighbors`]'s outbound half (which keeps only `dst_path IS NOT NULL`) — together -/// they cover every outbound edge, so a link is either a resolved neighbor or a -/// surfaced unresolved link, never silently gone (GH #12). Backed by the -/// `edges_dangling_idx` partial index, whose predicate this query mirrors. +/// A note's dangling outbound links, in a deterministic order: the complement of +/// [`neighbors`]'s outbound half, so a link is never silently gone (GH #12). The query +/// mirrors `edges_dangling_idx`'s predicate. pub fn unresolved_outbound(conn: &Connection, note_path: &str) -> Result> { let mut stmt = conn.prepare( "SELECT dst_path_raw, type, origin, explanation FROM edges @@ -127,10 +106,7 @@ pub fn unresolved_outbound(conn: &Connection, note_path: &str) -> Result>>()?) } -/// The set of notes within `hops` typed hops of `anchor` (inclusive of `anchor`), -/// traversing `active` edges **undirected** — a note related to the anchor either -/// way is reachable. Discovery subtracts the anchor's 1-hop set (what is already linked) -/// from its candidates. `hops = 0` is just the anchor. +/// Notes within `hops` undirected hops of `anchor`, the anchor included. pub fn reachable_within(conn: &Connection, anchor: &str, hops: usize) -> Result> { let mut seen = HashSet::from([anchor.to_string()]); let mut frontier = vec![anchor.to_string()]; diff --git a/crates/b2-core/src/import.rs b/crates/b2-core/src/import.rs index 4e64af9..a5471a3 100644 --- a/crates/b2-core/src/import.rs +++ b/crates/b2-core/src/import.rs @@ -1,19 +1,9 @@ -//! Bring an *outside* file into the vault — CRUD's **import** arm, and the kernel -//! behind the desktop's drag-from-Finder-onto-the-tree gesture. +//! Import an outside file into the vault: a byte-honest copy, authoring nothing, which is +//! what makes it a permitted write (ADR-0004). Unlike [`crate::add`], which authors a note. //! -//! What separates this from [`crate::add`]: `add` **authors** a new document, while an -//! import is a **byte-honest copy** of a file the human already has. B2 writes the bytes -//! it was handed and authors nothing, so a dropped `.md` keeps its own frontmatter -//! verbatim and a dropped PDF keeps its bytes. That is what makes it a permitted write -//! (ADR-0004): placing a file is the same category of act as moving or deleting one. -//! -//! **Vault first**, the order `add`/`mv`/`link` also write in: place the file, then -//! project *from disk*, so the index is derived from what actually landed. **Model-free** -//! (a [`ProjectionCtx`], like `create_note`): an imported note's chunks join the pending -//! set for the next embed pass, so importing works with no model provisioned. -//! -//! Two entry points because the two gestures arrive differently: a file dropped on the -//! webview is **bytes**, a file chosen in an OS picker is a **path**. +//! Vault first: place the file, then project from disk. Model-free: imported chunks wait +//! for the next embed pass. [`import_bytes`] serves a drag-and-drop, [`import_path`] an OS +//! picker. use crate::error::{Error, Result}; use crate::ingest::{self, ProjectionCtx}; @@ -23,21 +13,16 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -/// What an import did: where the file landed — which, for a note and a resource -/// alike, is the whole of what arrived (invariants L1/L3: both are path-keyed). +/// Where the imported file landed (L1/L3). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ImportReport { pub path: String, - /// Whether the arriving file was routed as a **note** (a `.md`, projected with - /// chunks/FTS/edges) rather than a resource (one inventory row). The adapters - /// use it to decide whether the drop is openable in the editor. + /// Routed as a note rather than a resource, so the editor can open it. pub note: bool, } -/// Import `bytes` into the vault folder `dir` (`""` for the root) under `file_name` — -/// the drag-and-drop arm, where the OS hands the page content rather than a path. -/// Refuses [`Error::ImportDestination`] for a name/folder pair that isn't a valid -/// vault-relative path and [`Error::ImportTargetExists`] rather than clobber. +/// Import `bytes` into folder `dir` (`""` for the root) as `file_name`. Refuses an invalid +/// destination and an occupied one. pub fn import_bytes( ctx: ProjectionCtx, dir: &str, @@ -49,12 +34,8 @@ pub fn import_bytes( project_placed(ctx, rel, &abs) } -/// Import the file at `source` into the vault folder `dir`, keeping its name — the -/// OS-picker arm, and the reason it exists rather than `import_bytes(fs::read(source)?)` -/// at the call site: the adapter then holds no logic and the bytes never round-trip -/// through it. Same refusals as [`import_bytes`], plus [`Error::ImportDestination`] for a -/// source that is a folder or has no file name. A source *inside* the vault is a -/// duplicate, not an error — a copy landing at a free path is simply a second note. +/// Import the file at `source` into folder `dir`, keeping its name, so the adapter holds +/// no logic. A source inside the vault just makes a copy. pub fn import_path(ctx: ProjectionCtx, dir: &str, source: &Path) -> Result { if source.is_dir() { return Err(Error::ImportDestination(format!( @@ -69,20 +50,15 @@ pub fn import_path(ctx: ProjectionCtx, dir: &str, source: &Path) -> Result Result { let name = file_name.trim(); if name.is_empty() || name.contains('/') || name.contains('\\') { @@ -99,37 +75,22 @@ fn destination(dir: &str, file_name: &str) -> Result { crate::pathspec::normalize_rel(&joined).map_err(Error::ImportDestination) } -/// The validated destination as both halves the rest of the op needs: the -/// vault-relative path (what the index and the report speak in) and its absolute twin -/// (what the filesystem does). +/// The validated destination, vault-relative and absolute. fn destination_paths(vault_root: &Path, dir: &str, file_name: &str) -> Result<(String, PathBuf)> { let rel = destination(dir, file_name)?; let abs = vault_root.join(&rel); Ok((rel, abs)) } -/// Reserve the import's destination and fill it — [`place_new`], refusing an occupied -/// destination as [`Error::ImportTargetExists`]. That is also why `import_path` streams -/// instead of calling `fs::copy`, which would truncate an occupied destination. -/// -/// The destination is a file B2 created, so it carries ordinary new-file permissions -/// rather than the source's mode — byte-honesty is about content. +/// [`place_new`] with [`Error::ImportTargetExists`]. The file gets new-file permissions, +/// not the source's mode. fn place(rel: &str, abs: &Path, fill: impl FnOnce(&mut fs::File) -> io::Result<()>) -> Result<()> { place_new(abs, || Error::ImportTargetExists(rel.to_string()), fill) } -/// Create a new file at `abs` (missing parent folders included) and fill it — or leave -/// nothing behind. Shared by every op that writes a file B2 did not have before: an -/// import here, and `add`'s new note. -/// -/// **`create_new` is the refusal**, not a check before one: "does it exist" and "claim -/// it" are a single syscall, so a file appearing in between — another window, a sync -/// client, the CLI — cannot be overwritten. An [`io::ErrorKind::AlreadyExists`] *is* the -/// op's own target-exists error (`taken`), so the race and the ordinary "that name is -/// taken" reach the user as one message. -/// -/// A `fill` that fails partway takes the reserved file with it: half a file is not a -/// new file. +/// Create a new file at `abs` (with parent folders) and fill it, or leave nothing behind. +/// Shared with `add`. `create_new` is the refusal itself, one syscall, so a file appearing +/// concurrently can't be overwritten; `AlreadyExists` maps to `taken`. pub(crate) fn place_new( abs: &Path, taken: impl FnOnce() -> Error, @@ -144,22 +105,15 @@ pub(crate) fn place_new( Err(e) => return Err(e.into()), }; if let Err(e) = fill(&mut file) { - drop(file); // close before unlinking, so every platform agrees what happens + drop(file); // close before unlinking, for every platform let _ = fs::remove_file(abs); return Err(e.into()); } Ok(()) } -/// Project the just-placed file from disk, routing on its extension exactly as the vault -/// walk does: `.md` is a note (chunks, FTS, edges), everything else a resource (one -/// inventory row). B2 adds nothing to either. -/// -/// **On a refusal the placed file is removed again** — B2 undoing its own half-finished -/// write, not deleting vault material: the file becomes vault material only if this -/// returns `Ok`. The removal is best-effort and never masks the projection error, which -/// is the actionable one; a leftover file is then just an unindexed file, the state a -/// Finder copy produces and the next whole-vault pass picks up. +/// Project the placed file, routed as the walk routes it. On failure, B2 removes its own +/// half-finished write (best-effort, never masking the projection error). fn project_placed(ctx: ProjectionCtx, rel: String, abs: &Path) -> Result { match project_from_disk(ctx, &rel) { Ok(note) => Ok(ImportReport { path: rel, note }), @@ -170,18 +124,15 @@ fn project_placed(ctx: ProjectionCtx, rel: String, abs: &Path) -> Result Result { match ResourceClass::of_path(rel) { None => { ingest::project_file(ctx, rel)?; Ok(true) } - // `force`: the walk may skip a file whose `(size, mtime)` is unchanged, but an - // import never may. The row it would be trusting can describe a file that was - // deleted out of band and not yet pruned, and this one was written moments ago — - // so a same-size replacement inside the same second would keep a `content_hash` - // for bytes that no longer exist. Hash what was actually placed. + // `force`: an existing row may describe a file deleted out of band; hash what + // was actually placed. Some(class) => { ingest::project_resource_file(ctx.conn, ctx.root, rel, class, true)?; Ok(false) @@ -202,8 +153,6 @@ mod tests { #[test] fn a_file_name_is_a_name_never_a_path() { - // The whole point of the separator refusal: neither of these may relocate the - // import out of the folder the human dropped on. assert!(destination("papers", "../../etc/passwd").is_err()); assert!(destination("papers", "sub/a.pdf").is_err()); assert!(destination("papers", "sub\\a.pdf").is_err()); @@ -214,7 +163,7 @@ mod tests { fn the_shared_path_rules_still_apply_to_the_pair() { assert!(destination("..", "a.pdf").is_err()); // escaping folder assert!(destination("/abs", "a.pdf").is_err()); // absolute folder - assert!(destination("papers", ".hidden.pdf").is_err()); // never indexed, so never written + assert!(destination("papers", ".hidden.pdf").is_err()); // never indexed assert!(destination(".b2", "a.pdf").is_err()); } } diff --git a/crates/b2-core/src/ingest.rs b/crates/b2-core/src/ingest.rs index 0990e5c..0857844 100644 --- a/crates/b2-core/src/ingest.rs +++ b/crates/b2-core/src/ingest.rs @@ -1,14 +1,9 @@ -//! Ingest (flow ①): parse -> project into `notes`, `chunks` (+FTS) and -//! the typed `edges` graph, all keyed by the note's vault-relative path (ADR-0003). -//! **Ingest writes nothing to the vault** (ADR-0004) — it is a pure read of it. +//! Ingest (flow ①): parse the vault and project it into `notes`, `chunks` (+FTS) and +//! `edges`, keyed by vault-relative path (ADR-0003). Writes nothing to the vault (ADR-0004). //! -//! A full ingest is **two separately-invokable passes**: [`project_vault`], the -//! model-free one, which runs in two phases so link resolution never depends on file -//! order (phase 1 projects every note + its chunks, phase 2 derives edges against the -//! now-complete resolver); and [`embed_vault`], the model-bound one, which fills -//! whatever chunks still lack a vector — a pending set **derived from the DB**, never -//! handed over in memory. `Vault::reindex` is their composition; [`ingest_file`] -//! re-projects a single note inline against an already-built index. +//! Two passes: [`project_vault`] (model-free; notes and chunks first, then edges, so link +//! resolution never depends on file order) and [`embed_vault`] (fills chunks lacking a +//! vector, a pending set derived from the DB). [`ingest_file`] re-projects one note. use crate::chunk::{chunk_body, ChunkConfig}; use crate::db::{self, EdgeRow, NoteRow}; @@ -23,23 +18,13 @@ use std::fs; use std::ops::ControlFlow; use std::path::Path; -/// How many chunks to embed per forward pass. Batching amortizes one matmul over many -/// texts, traded against the **padding waste** of batching short chunks with long ones -/// — the tokenizer pads every chunk to the batch's longest. Measured on a real vault, -/// 16 beat 32 (~40% faster) and 8. It is also the reindex **cancel granularity**: the -/// flag is checked once per batch. +/// Chunks per forward pass. Larger batches pad short chunks to the longest; measured, 16 +/// beat 8 and 32. Also the reindex cancel granularity. const EMBED_BATCH: usize = 16; -/// Everything a **projection** needs: the index connection, the vault root, and the -/// vault's chunking policy. The three travel together through every write-side op, so -/// they are one parameter rather than three in two orders (GH #134). -/// -/// It carries a **posture**, and that is the point: an op that takes a `ProjectionCtx` -/// holds no embedder and therefore *cannot* embed. The model-free rule for -/// `rm`/`create_note`/`write` is the type system's to keep. -/// -/// A short-lived, `Copy`, borrow-only view struct never stored — the sanctioned -/// exception to "prefer owned fields" (CLAUDE.md), as `NoteRow` is. +/// What a projection needs: connection, vault root and chunking policy (GH #134). It holds +/// no embedder, so an op taking it cannot embed: that keeps `rm`/`create_note`/`write` +/// model-free. A short-lived `Copy` view, like `NoteRow`. #[derive(Clone, Copy)] pub struct ProjectionCtx<'a> { pub(crate) conn: &'a Connection, @@ -48,18 +33,14 @@ pub struct ProjectionCtx<'a> { } impl<'a> ProjectionCtx<'a> { - /// Bundle the three projection inputs. `cfg` is the vault's *one* chunking policy - /// (never re-defaulted per call), so every path that chunks a given vault cuts - /// identically and `incremental ≡ full rebuild` holds by construction. + /// `cfg` is the vault's one chunking policy, never re-defaulted per call, so + /// `incremental ≡ full rebuild` holds. pub fn new(conn: &'a Connection, root: &'a Path, cfg: &'a ChunkConfig) -> Self { Self { conn, root, cfg } } } -/// A [`ProjectionCtx`] plus the embedder — the **embedding** posture, and the other -/// half of GH #134's split. The ops that re-embed what they touch take this; the -/// model-free ones take the projection context alone, so the two are unmixable by the -/// compiler. Same view-struct rules as [`ProjectionCtx`]. +/// A [`ProjectionCtx`] plus the embedder, for ops that re-embed what they touch (GH #134). #[derive(Clone, Copy)] pub struct EmbedCtx<'a> { pub(crate) proj: ProjectionCtx<'a>, @@ -73,48 +54,40 @@ impl<'a> EmbedCtx<'a> { } } -/// A file's mtime as Unix seconds — the projection's shared stat reading -/// (notes, resources, and a moved resource's repoint all record the same -/// shape). `None` when the platform clock can't supply one. +/// A file's mtime as Unix seconds, or `None` when the platform can't supply one. pub(crate) fn unix_mtime(meta: &fs::Metadata) -> Option { let modified = meta.modified().ok()?; let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?; Some(since_epoch.as_secs() as i64) } -/// What [`project_note_and_chunks`] returns for one note: the material later phases -/// need — the body for edge derivation, and the `(text_hash, text)` pairs still -/// needing a vector for the embed step. +/// One projected note: the body for edge derivation, and `(text_hash, text)` pairs still +/// needing a vector. struct ProjectedNote { body: String, relations: Vec, pending: Vec<(String, String)>, } -/// A [`plan_reindex`] preview (`reindex --dry-run`): what a real reindex *would* do, -/// decided read-only. Only one question is left to preview — a run that writes nothing -/// (ADR-0004) has nothing to warn about beyond the work it will do. +/// A [`plan_reindex`] preview (`reindex --dry-run`), decided read-only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Plan { - /// Notes a real reindex would project (every readable `.md` file the walk collects). + /// Notes a real reindex would project. pub notes: usize, /// …of which this many would be (re)embedded (changed, fresh, or forced). pub would_embed: usize, } -/// When [`project_note_and_chunks`] re-cuts a note's chunks. Three callers, three -/// questions — which is why this is not a pair of booleans. +/// When [`project_note_and_chunks`] re-cuts a note's chunks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Rechunk { - /// The projection pass: re-chunk a new or body-changed note, reading only `notes`, - /// never vector state ("unchanged body but missing vectors" is [`embed_vault`]'s - /// job, which keeps [`project_vault`] free of the vector tables). + /// The projection pass: re-chunk a new or body-changed note. Reads only `notes`, never + /// vector state (missing vectors are [`embed_vault`]'s job). IfBodyChanged, /// A forced projection (`reindex --force`): re-chunk every note. Always, - /// The inline path ([`ingest_file`]), which embeds what it re-cuts: also re-chunk a - /// note left mid-embed ([`would_reembed`]'s vector-state check), and hand back the - /// chunks still missing a vector. + /// The inline path ([`ingest_file`]): also re-chunk a note left mid-embed, and hand + /// back the chunks still missing a vector. IfBodyChangedOrUnembedded, } @@ -128,45 +101,31 @@ impl Rechunk { } } -/// A note body's content hash — the incremental pass's "did the body change?" key, -/// shared by the real projection and the dry-run so the forecast cannot drift from it. +/// The incremental "did the body change?" key, shared with the dry-run so they can't drift. fn body_hash(body: &str) -> String { blake3::hash(body.as_bytes()).to_hex().to_string() } -/// Progress during the embed phase, reported **per batch** so a large vault never -/// looks frozen while it embeds. Purely observational. -/// -/// The counts describe the notes that actually (re)embed this run, not every note: an -/// incremental reindex reuses most notes' vectors, so `notes_to_embed` is the real unit -/// of work. Reporting position in the full note list would jump to "note 14/18" while -/// only a handful are doing anything. `Serialize` so the desktop can stream it to the -/// webview; the field names are the JSON keys the frontend reads. +/// Embed-phase progress, reported per batch. Counts cover only the notes that (re)embed +/// this run, not the whole vault. Field names are the JSON keys the desktop frontend reads. #[derive(Debug, Clone, Serialize)] pub struct ReindexProgress { /// Vault-relative path of the note currently embedding. pub note_path: String, - /// Number of chunks in the current note (this file's own chunk count). + /// Chunks in the current note. pub note_chunks: usize, /// How many notes have begun embedding so far (1-based)… pub notes_embedded: usize, - /// …out of this many notes that need (re)embedding this run — the changed/fresh - /// notes (or every note under `force`), not the whole vault. + /// …out of this many notes that need (re)embedding this run. pub notes_to_embed: usize, /// Chunks embedded so far, cumulative across every note this run. pub chunks_done: usize, } -/// Project one note's frontmatter + chunks — everything derivable without resolving -/// links. Returns its body (kept so phase 2 derives edges without re-reading), its -/// frontmatter relations, and the `(text_hash, text)` pairs still needing a vector; -/// embedding is deferred. No embedder here, and **no write to the vault**. -/// -/// **Incremental:** unless [`Rechunk::Always`], a note whose body hash is unchanged is -/// left untouched and `pending` comes back empty; frontmatter-only edits still -/// re-project the note row and its edges. The invariant `incremental ≡ full rebuild` -/// holds because the re-used rows are byte-for-byte what a fresh projection would -/// produce. `pending` is only ever filled for [`Rechunk::IfBodyChangedOrUnembedded`]. +/// Project one note's row and chunks: everything derivable without resolving links. +/// Unless [`Rechunk::Always`], an unchanged body keeps its chunks (`incremental ≡ full +/// rebuild`: they are what a fresh projection would produce). `pending` is filled only for +/// [`Rechunk::IfBodyChangedOrUnembedded`]. fn project_note_and_chunks( ctx: ProjectionCtx, rel_path: &str, @@ -181,9 +140,8 @@ fn project_note_and_chunks( let body_hash = body_hash(&body); let mtime = fs::metadata(&abs).ok().as_ref().and_then(unix_mtime); - // Decide the re-chunk BEFORE the upsert overwrites `body_hash`. The inline path - // also reads vector state (its caller ensured the space, hence - // `space_exists = true`); the projection pass reads only `notes`. + // Decide before the upsert overwrites `body_hash`. The inline path's caller ensured + // the embedding space, hence `space_exists = true`. let rechunk = match when { Rechunk::Always => true, Rechunk::IfBodyChanged => { @@ -195,8 +153,7 @@ fn project_note_and_chunks( }; let fields = parsed.fields(); - // A note's display title is its **filename**; a frontmatter `title:` is inert. - // Projected here so every read path shows it with no per-call derivation. + // The display title is the filename; a frontmatter `title:` is inert. let title = note::display_title(rel_path); db::upsert_note( conn, @@ -211,18 +168,15 @@ fn project_note_and_chunks( let relations = fields.relations.clone(); - // Incremental fast path: an unchanged body means identical chunks — reuse them and - // return no pending work. A re-chunk hands back pairs only for chunks with **no - // stored vector**: the store is content-addressed (ADR-0006), so re-chunking a note - // whose text is unchanged — what a move produces — yields nothing pending at all. + // Vectors are content-addressed (ADR-0006), so re-chunking unchanged text (a move) + // yields nothing pending. let pending = if rechunk { let chunks = chunk_body(&body, cfg); db::replace_chunks(conn, rel_path, &chunks)?; if when == Rechunk::IfBodyChangedOrUnembedded { pending_for_note(conn, rel_path)? } else { - // The projection pass never reads vector state; the embed pass derives - // its own pending set from the DB. + // The embed pass derives its own pending set from the DB. Vec::new() } } else { @@ -236,10 +190,8 @@ fn project_note_and_chunks( }) } -/// The `(text_hash, text)` pairs of one note's chunks that still lack a vector — the -/// single-note form of [`db::chunks_missing_vectors`], for the inline path. Its own -/// query rather than a filter over the whole-vault set, because `move_dir` calls this -/// once per moved note. Requires the embedding space to exist. +/// One note's chunks still lacking a vector. Its own query, not a filter over the whole +/// vault, because `move_dir` calls it per note. Requires the embedding space. fn pending_for_note(conn: &Connection, note_path: &str) -> Result> { Ok(db::note_chunks_missing_vectors(conn, note_path)? .into_iter() @@ -247,13 +199,9 @@ fn pending_for_note(conn: &Connection, note_path: &str) -> Result Result<()> { - // Gather authored links: body first (inline), then frontmatter (frontmatter). let mut staged: Vec<(crate::link::ParsedLink, &'static str)> = Vec::new(); for link in crate::link::parse_links(body) { staged.push((link, "inline")); @@ -352,11 +281,9 @@ fn project_edges( } } - // The source note's directory — the base for a Markdown-form relative target, read - // straight off the path now that the path *is* the identity (ADR-0003). + // The base for a Markdown-form relative target. let src_dir = crate::pathspec::parent_dir(src_path); - // Resolve targets; record which (target, type) the frontmatter authors. let mut fm_keys: HashSet<(String, String)> = HashSet::new(); let mut resolved = Vec::with_capacity(staged.len()); for (link, origin) in staged { @@ -399,9 +326,8 @@ fn project_edges( db::replace_authored_edges(conn, src_path, &rows) } -/// Resolve one parsed link to `(dst_path, dst_resource_path)` — at most one is -/// `Some`; both `None` means dangling. The lookup path is the authored target -/// minus any `#fragment`; kind dispatch is extension-only (see [`project_edges`]). +/// Resolve a link to `(dst_path, dst_resource_path)`: at most one is `Some`, both `None` +/// means dangling. A `#fragment` is stripped for the lookup only. fn resolve_target( conn: &Connection, src_dir: &str, @@ -417,9 +343,8 @@ fn resolve_target( return Ok((None, None)); // fragment-only wikilink — dangling } - // Candidate paths, most specific first: a Markdown-form target is - // note-relative per standard Markdown, falling back to vault-root (the - // wikilink habit); wikilinks are vault-root only, as today. + // A Markdown-form target tries note-relative first, then vault-root; wikilinks are + // vault-root only. let mut candidates: Vec = Vec::with_capacity(2); if link.md_form { if let Some(joined) = crate::pathspec::join_relative(src_dir, lookup) { @@ -430,8 +355,7 @@ fn resolve_target( candidates.push(lookup.to_string()); } - // Extension-only kind dispatch, the one rule, shared with the adapters' argument - // dispatch: an extension other than `md` means resource; `.md` or none means note. + // Extension-only kind dispatch, shared with the adapters: non-`md` means resource. let is_resource = crate::resource::doc_kind(lookup) == crate::resource::DocKind::Resource; for candidate in &candidates { if is_resource { @@ -445,10 +369,8 @@ fn resolve_target( Ok((None, None)) } -/// Deterministic id for an authored edge from its identity tuple (ADR-0010): stable -/// across re-index, so the same body at the same path always yields the same id — and -/// deliberately *not* stable across a move, since both ends are paths. Edge ids live -/// only in the disposable index and are re-derived by a move's re-projection. +/// Deterministic edge id from its identity tuple (ADR-0010): stable across re-index, not +/// across a move, since both ends are paths. fn derive_edge_id(src_path: &str, target_key: &str, edge_type: &str, occurrence: i64) -> String { let mut h = blake3::Hasher::new(); for part in [src_path, target_key, edge_type] { @@ -459,19 +381,14 @@ fn derive_edge_id(src_path: &str, target_key: &str, edge_type: &str, occurrence: h.finalize().to_hex()[..32].to_string() } -/// Ingest a single note at `ctx.root/rel_path` against an already-built index -/// (the incremental path). Projects note + chunks + edges. The context's chunking -/// policy is the same one every other path uses, so a single-note re-projection cuts -/// identically to a full rebuild. +/// Ingest a single note against an already-built index: note, chunks, vectors, edges. pub fn ingest_file(ctx: EmbedCtx, rel_path: &str) -> Result<()> { let EmbedCtx { proj, embedder } = ctx; let conn = proj.conn; db::ensure_embedding_space(conn, embedder.model_id(), embedder.dim())?; - // Incremental (force=false): a frontmatter-only edit leaves the body unchanged, so - // this re-projects note + edges without re-embedding. Vector state IS consulted — - // this path embeds inline, so a note left mid-embed re-chunks and re-embeds here. + // A frontmatter-only edit re-projects without re-embedding; a note left mid-embed + // re-chunks and re-embeds here. let p = project_note_and_chunks(proj, rel_path, Rechunk::IfBodyChangedOrUnembedded)?; - // A single-note re-projection is never cancelled — always run to completion. embed_pending(conn, embedder, rel_path, &p.pending, |_| { ControlFlow::Continue(()) })?; @@ -479,10 +396,8 @@ pub fn ingest_file(ctx: EmbedCtx, rel_path: &str) -> Result<()> { Ok(()) } -/// Reindex every `.md` file under `vault_root` — [`project_vault`] then [`embed_vault`], -/// incrementally and with no progress reporting; dot-folders are skipped. A convenience -/// wrapper the test suite drives: it builds the [`EmbedCtx`] itself around the default -/// [`ChunkConfig`], which is why it takes loose arguments. +/// [`project_vault`] then [`embed_vault`] with the default [`ChunkConfig`] and no +/// progress. A convenience for the test suite. pub fn ingest_vault(conn: &Connection, vault_root: &Path, embedder: &dyn Embedder) -> Result<()> { let cfg = ChunkConfig::default(); project_vault(ProjectionCtx::new(conn, vault_root, &cfg), false)?; @@ -490,28 +405,21 @@ pub fn ingest_vault(conn: &Connection, vault_root: &Path, embedder: &dyn Embedde Ok(()) } -/// Re-project a single note **model-free** — the single-note sibling of -/// [`project_vault`], and the pass `Vault::write` runs after its body splice. A changed -/// body re-chunks, and the chunks join the DB-derived pending set for any later embed -/// pass, so the save path needs no embedder. Contrast [`ingest_file`], which embeds -/// inline for `add`/`link`/`mv` — ops that already require the model. +/// Re-project a single note model-free (what `Vault::write` runs). New chunks join the +/// DB-derived pending set for a later embed pass, so saving needs no embedder. pub fn project_file(ctx: ProjectionCtx, rel_path: &str) -> Result<()> { let p = project_note_and_chunks(ctx, rel_path, Rechunk::IfBodyChanged)?; project_edges(ctx.conn, rel_path, &p.body, &p.relations) } -/// A vault file (note **or** resource) the projection pass could not read, and -/// therefore skipped, so one unreadable file never aborts a whole-vault reindex. -/// `reason` is about the *file itself* ("not valid UTF-8 text", "permission denied"), -/// never a B2 internal, so it is safe to show and to log. Only a *filesystem* failure -/// on one file is recoverable this way; a systemic error still aborts the pass. +/// A vault file the projection pass could not read and skipped. `reason` describes the +/// file, never a B2 internal, so it is safe to show. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SkippedNote { pub path: String, pub reason: String, } -/// A file the walk met but could not read, as the pass reports it. fn skipped(path: &str, err: &std::io::Error) -> SkippedNote { SkippedNote { path: path.to_string(), @@ -519,10 +427,7 @@ fn skipped(path: &str, err: &std::io::Error) -> SkippedNote { } } -/// Classify an I/O error hit while reading one file into a short, clean, -/// user-appropriate reason — the file's problem stated plainly, with no raw OS jargon -/// (e.g. "stream did not contain valid UTF-8") and no B2 internal. Anything unusual -/// falls back to a generic "could not be read". +/// A short, user-facing reason for an I/O error on one file, free of OS jargon. fn skip_reason(err: &std::io::Error) -> String { use std::io::ErrorKind; match err.kind() { @@ -533,41 +438,32 @@ fn skip_reason(err: &std::io::Error) -> String { } } -/// The result of the model-free **projection pass** over the whole vault -/// ([`project_vault`]): every projected note's path, in the deterministic (sorted-path) -/// walk order, plus any files skipped as unreadable (empty on a clean vault). +/// The result of [`project_vault`]: projected note paths in sorted walk order, plus +/// files skipped as unreadable. #[derive(Debug, Clone)] pub struct ProjectOutcome { pub notes: Vec, pub skipped: Vec, - /// Ghost rows pruned this pass (#31) — notes whose files were deleted outside - /// b2 with no replacement. Zero on a vault with no out-of-band deletions. + /// Rows pruned for notes deleted outside b2 (#31). pub notes_pruned: usize, - /// Resources inventoried this pass (unchanged ones included), and stale - /// inventory rows pruned. + /// Resources inventoried (unchanged included), and stale rows pruned. pub resources_indexed: usize, pub resources_pruned: usize, } -/// The result of a (possibly cancelled) **embed pass** ([`embed_vault`]): which -/// notes fully embedded this run, and whether a cooperative cancel cut it short. +/// The result of a (possibly cancelled) [`embed_vault`]. #[derive(Debug, Clone)] pub struct EmbedOutcome { - /// Paths of the notes that fully embedded this run, in the order they were - /// worked (path order). Notes whose vectors were already complete do no work and - /// are not listed. + /// Notes that fully embedded this run, in path order. Already-complete notes are + /// not listed. pub embedded: Vec, /// The pass stopped early because `on_progress` returned [`ControlFlow::Break`]. pub cancelled: bool, } -/// The **projection pass**: project every `.md` file under `ctx.root` — phase 1 -/// (note, chunks, FTS) then phase 2 (the typed edges) — with **no embedder and no embedding -/// space**, so a projected-but-unembedded index is already complete for keyword search -/// and the graph. Incremental: unless `force`, a note is re-chunked only when its body -/// changed or it is new, read purely from `notes` and never from vector state (missing -/// vectors are [`embed_vault`]'s job), so `project(force)` then `embed()` is the full -/// rebuild. It writes nothing to the vault (ADR-0004). +/// The projection pass: notes, chunks and FTS, then edges, with no embedder, so an +/// unembedded index already serves keyword search and the graph. Unless `force`, only new +/// or body-changed notes re-chunk. Writes nothing to the vault (ADR-0004). pub fn project_vault(ctx: ProjectionCtx, force: bool) -> Result { let ProjectionCtx { conn, root, .. } = ctx; let VaultWalk { @@ -575,29 +471,21 @@ pub fn project_vault(ctx: ProjectionCtx, force: bool) -> Result resources: resource_files, } = walk_vault(root)?; - // Phase 1: project every note + its chunks, which fills the resolver so phase 2 - // never depends on file order. No pending pairs come back — the embed pass derives - // its work from the DB, so nothing is handed over in memory. + // Phase 1: notes and chunks, filling the resolver so phase 2 is order-independent. let mut staged = Vec::with_capacity(rel_paths.len()); let mut skipped = Vec::new(); for rel in &rel_paths { match project_note_and_chunks(ctx, rel, Rechunk::forced(force)) { Ok(p) => staged.push((rel.clone(), p.body, p.relations)), - // A note we cannot read is *skipped*, not fatal: one bad file must never - // abort a whole-vault reindex. Only filesystem failures reading THIS note - // land here — the DB layer's `Error::Sqlite` is systemic and still aborts. - // The read fails before any upsert, so no partial row is written. + // An unreadable note is skipped, not fatal; a DB error still aborts. The read + // fails before any upsert, so no partial row is written. Err(Error::Io(e)) => skipped.push(self::skipped(rel, &e)), Err(other) => return Err(other), } } - // Deletion reconciliation (#31): prune the rows of notes whose files are gone, so - // an incremental reindex converges on what a from-scratch rebuild would hold instead - // of serving ghosts. "Gone" means "the walk did not meet this path" — a file it saw - // but could not read is kept, since evicting it would lie. Runs before phase 2 so - // links at a deleted note re-dangle, exactly as a full rebuild resolves them. The - // single-note paths touch one note and never prune. + // Prune notes the walk did not meet (#31); a seen-but-unreadable file is kept. Before + // phase 2 so links at a deleted note re-dangle, as in a full rebuild. let seen: HashSet<&str> = staged .iter() .map(|(path, ..)| path.as_str()) @@ -605,22 +493,18 @@ pub fn project_vault(ctx: ProjectionCtx, force: bool) -> Result .collect(); let notes_pruned = db::prune_notes_except(conn, &seen)?; - // Vectors are content-addressed (ADR-0006), so they do NOT die with the chunk rows - // just removed — that survival is what lets a moved note re-use them. Collecting - // what is now unreferenced belongs here, where the run's chunk set is final, and is - // guarded so the model-free pass never touches a never-embedded vault. + // Content-addressed vectors (ADR-0006) outlive their chunk rows; collect the + // unreferenced ones now the chunk set is final. if db::embedding_space_exists(conn)? { db::prune_orphan_vectors(conn)?; } - // Resource inventory — between the phases so the rows exist before phase 2 - // resolves links (a `![[img.png]]` edge resolves against `resources`, spec §3). + // Resources before phase 2, which resolves `![[img.png]]` against them (spec §3). let (resources_indexed, resources_pruned, mut resource_skips) = project_resources(conn, root, &resource_files)?; skipped.append(&mut resource_skips); - // Phase 2: edges, resolved against the now-complete resolver. A skipped note has no - // rows and no edges; a link pointing at it stays unresolved, as for any absent target. + // Phase 2: edges. A link at a skipped note stays unresolved. let mut notes = Vec::with_capacity(staged.len()); for (path, body, relations) in staged { project_edges(conn, &path, &body, &relations)?; @@ -645,14 +529,9 @@ pub fn project_vault(ctx: ProjectionCtx, force: bool) -> Result }) } -/// The **embed pass**: fill a vector for every chunk that lacks one. Ensures the -/// embedding space first (a model swap drops and resets it, so *all* chunks then count -/// as missing), then works the DB-derived pending set note by note through the batched -/// [`embed_pending`] loop, firing `on_progress` per batch and honoring its -/// [`ControlFlow::Break`] as the cancel checkpoint. Takes **no `force`**: re-chunking is -/// a projection concern, so this pass is purely "fill what's missing" — which is also -/// why any interruption heals on the next call. Pending notes are counted before any -/// work starts, so progress is determinate from the first batch. +/// The embed pass: fill a vector for every chunk lacking one (after a model swap, all of +/// them). `on_progress` fires per batch; [`ControlFlow::Break`] cancels. No `force`: it only +/// fills what's missing, so an interruption heals on the next call. pub fn embed_vault( conn: &Connection, embedder: &dyn Embedder, @@ -660,13 +539,9 @@ pub fn embed_vault( ) -> Result { db::ensure_embedding_space(conn, embedder.model_id(), embedder.dim())?; - // Group the (path, seq)-ordered pending chunks by note; consecutive rows share a - // note, so per-note batching and progress reproduce the fused reindex's shape. - // - // A hash is embedded **once per run** however many notes hold that text: the store - // is content-addressed (ADR-0006). Notes are worked in order and each batch is - // written before the next note starts, so a de-duplicated note finds its vectors - // already in the table, completes immediately, and still refreshes its centroid. + // Group pending chunks by note. Each hash is embedded once per run (ADR-0006); a note + // whose chunks were all claimed earlier finds its vectors stored and still refreshes + // its centroid. type PendingNote = (String, Vec<(String, String)>); let mut by_note: Vec = Vec::new(); let mut claimed: HashSet = HashSet::new(); @@ -690,14 +565,13 @@ pub fn embed_vault( let mut chunks_done = 0usize; let mut cancelled = false; for (i, (path, pending)) in by_note.iter().enumerate() { - // Per-note span: under a span-close subscriber each note reports how long - // its embed took — the kernel's slowest step, hence the one worth plotting. + // Per-note span, for timing the slowest step. let _note_span = tracing::debug_span!( target: "b2::ingest", "embed_note", path = path.as_str(), chunks = pending.len() ) .entered(); - let notes_embedded = i + 1; // 1-based position for the progress line + let notes_embedded = i + 1; // 1-based let note_chunks = pending.len(); let outcome = embed_pending(conn, embedder, path, pending, |n| { chunks_done += n; @@ -714,14 +588,12 @@ pub fn embed_vault( } if outcome.cancelled { cancelled = true; - break; // cooperative cancel: stop starting new notes + break; } } - // The centroid half of the pass. A note re-cut but textually unchanged has every - // vector already stored, so it never enters the loop above — yet `replace_chunks` - // dropped the centroid summarizing its old chunks. Left unrefreshed it would vanish - // from discovery's coarse scan while looking fully indexed (S3). Runs after a cancel - // too: the query offers only *fully* embedded notes. + // A re-cut but unchanged note skips the loop above, yet `replace_chunks` dropped its + // centroid; without one it vanishes from discovery (S3). Safe after a cancel: only + // fully embedded notes are offered. for note_path in db::notes_missing_centroids(conn)? { db::refresh_note_centroid(conn, ¬e_path)?; } @@ -738,34 +610,22 @@ pub fn embed_vault( }) } -/// A **read-only** preview of a reindex (`reindex --dry-run`). Walks every `.md` file -/// in the same order as [`project_vault`] and decides, per note, whether a real run would -/// (re)embed its body. That is the *whole* preview, and the shrinkage is the feature: -/// the dry-run existed largely because a real run wrote to the vault, and one that -/// writes nothing (ADR-0004) has only work to forecast. -/// -/// The embed decision reads the *currently stored* vectors, so it previews an -/// incremental run under the embedder the index was built with; it does **not** detect a -/// pending model swap, which would need the real model a dry-run avoids loading. +/// Read-only preview of a reindex (`reindex --dry-run`): per note, would a real run +/// (re)embed it? Reads the stored vectors, so it does not detect a pending model swap. pub fn plan_reindex(conn: &Connection, vault_root: &Path, force: bool) -> Result { let space_exists = db::embedding_space_exists(conn)?; - // The dry-run previews *notes* (the embed decision); the resource inventory has - // no per-file decisions to preview, so its half of the walk is unused here. let rel_paths = walk_vault(vault_root)?.notes; let mut plan = Plan { notes: 0, would_embed: 0, }; for rel in rel_paths { - // Skip an unreadable file rather than abort — a real reindex would skip it too, - // so the dry-run must not be the one place it still crashes the run. + // A real reindex skips an unreadable file too. let raw = match fs::read_to_string(vault_root.join(&rel)) { Ok(raw) => raw, Err(_) => continue, }; let body_hash = body_hash(note::parse(&raw).body()); - // An unindexed path has no stored body hash, so this reads `true` for a note - // new to the index — exactly as the real run decides it. plan.notes += 1; if would_reembed(conn, &rel, &body_hash, force, space_exists)? { plan.would_embed += 1; @@ -774,8 +634,7 @@ pub fn plan_reindex(conn: &Connection, vault_root: &Path, force: bool) -> Result Ok(plan) } -/// What one vault walk found: every note path and every resource with its class, each -/// list sorted so the passes that share the walk run in one deterministic order. +/// What one vault walk found, each list sorted for a deterministic order. struct VaultWalk { notes: Vec, resources: Vec<(String, ResourceClass)>, @@ -791,14 +650,9 @@ fn walk_vault(root: &Path) -> Result { Ok(VaultWalk { notes, resources }) } -/// Walk the vault once, routing every file: `.md` (case-insensitive) to `notes`, -/// everything else to `resources` with its class — the `index = projection of the vault -/// directory` walk (ADR-0002). -/// -/// **Hidden means hidden** (GH #136): a dot-prefixed entry is not vault material, so -/// [`is_hidden`](crate::pathspec::is_hidden) is applied *above* the note/resource -/// dispatch and the recursion alike. A `.DS_Store` and a `.scratch.md` are equally -/// invisible; the files stay on disk untouched, simply outside the projection. +/// Walk the vault, routing `.md` (case-insensitive) to `notes` and everything else to +/// `resources` (ADR-0002). Dot-prefixed entries, files and folders alike, are skipped +/// (GH #136). fn collect_vault_files( root: &Path, dir: &Path, @@ -815,8 +669,7 @@ fn collect_vault_files( collect_vault_files(root, &path, notes, resources)?; continue; } - // `path` was produced by walking `root`, so `strip_prefix` cannot fail; - // handle it gracefully anyway rather than panic on the invariant. + // Cannot fail under `root`; skip rather than panic. let Ok(rel) = path.strip_prefix(root) else { continue; }; @@ -829,13 +682,9 @@ fn collect_vault_files( Ok(()) } -/// The **resource inventory pass**: stat every walked resource, short-circuit on an -/// unchanged `(size, mtime)`, otherwise read the bytes once to blake3 them and upsert; -/// then prune the rows the walk no longer saw (inbound edges re-dangle via the schema's -/// `ON DELETE SET NULL`). Model-free and chunk-free — hashing is the only byte-read. An -/// unreadable file is skipped and any prior row survives: the file was seen on disk, so -/// pruning it would lie. Returns `(indexed, pruned, skipped)`, `indexed` counting -/// unchanged resources too. +/// The resource inventory pass: inventory every walked resource, then prune rows the walk +/// no longer saw (inbound edges re-dangle via `ON DELETE SET NULL`). An unreadable file is +/// skipped and its prior row kept. Returns `(indexed, pruned, skipped)`. fn project_resources( conn: &Connection, vault_root: &Path, @@ -845,13 +694,11 @@ fn project_resources( let mut seen: HashSet = HashSet::with_capacity(resources.len()); let mut indexed = 0; for (rel, class) in resources { - // The walk saw the file, so it exists: it is never pruned this pass, even - // if reading it fails below. + // Seen, so never pruned, even if reading it fails. seen.insert(rel.clone()); match project_resource_file(conn, vault_root, rel, *class, false) { Ok(()) => indexed += 1, - // Only a *filesystem* failure on this one file is recoverable — anything - // else (SQLite, …) is systemic and aborts the pass, as everywhere else. + // Only a filesystem failure is recoverable; anything else aborts. Err(Error::Io(e)) => skipped.push(self::skipped(rel, &e)), Err(e) => return Err(e), } @@ -860,19 +707,9 @@ fn project_resources( Ok((indexed, pruned, skipped)) } -/// Inventory **one** resource: short-circuit on an unchanged `(size, mtime)`, otherwise -/// read the bytes once to blake3 them and upsert. The per-file kernel -/// [`project_resources`] loops over, and the resource arm of an import. -/// -/// `force` skips that short-circuit, and the two callers differ on it because they know -/// different things. The **walk** meets files it has seen before, so an unchanged stat -/// means "the row already describes this" — the optimization that keeps a reindex from -/// re-reading every PDF. An **import** just created the file, so any row at that path is -/// about a *different* file, and trusting a matching stat would keep a `content_hash` -/// for bytes that are gone. -/// -/// I/O failures travel as [`Error::Io`] and each caller decides what they mean: the walk -/// classifies one into a skip, the import treats it as the failure it is. +/// Inventory one resource: skip on an unchanged `(size, mtime)`, else blake3 and upsert. +/// `force` bypasses the stat check: an import just created the file, so an existing row +/// describes different bytes. I/O errors return as [`Error::Io`] for the caller to judge. pub(crate) fn project_resource_file( conn: &Connection, vault_root: &Path, @@ -885,7 +722,7 @@ pub(crate) fn project_resource_file( let size = meta.len() as i64; let mtime = unix_mtime(&meta); if !force && db::resource_stat(conn, rel)? == Some((size, mtime)) { - return Ok(()); // unchanged — inventoried without touching the bytes + return Ok(()); // unchanged } let bytes = fs::read(&abs)?; let content_hash = blake3::hash(&bytes).to_hex().to_string(); @@ -906,13 +743,8 @@ pub(crate) fn project_resource_file( mod tests { use super::*; - /// The `(size, mtime)` shortcut is the **walk's**, and an import must not inherit - /// it: a row can outlive the file it describes, so a same-size replacement landing - /// inside the same second would keep a `content_hash` for bytes that are gone — and - /// that hash is what the out-of-band move repair matches a dangling link against. - /// - /// The stat equality is **constructed**, not raced for: `set_modified` pins the - /// replacement's mtime to the original's, so the case under test runs every time. + /// A same-size replacement within the same second must not keep a stale `content_hash` + /// on import (the move repair matches on it). `set_modified` pins the mtime. #[test] fn the_unchanged_stat_shortcut_is_the_walks_alone() { let tmp = tempfile::TempDir::new().unwrap(); @@ -934,8 +766,7 @@ mod tests { let first = hash(&conn); let stamped = fs::metadata(&abs).unwrap().modified().unwrap(); - // Different bytes, same length, same mtime — the one state a stat cannot tell - // apart from "nothing happened". + // Different bytes, same length, same mtime. fs::write(&abs, b"BBBB").unwrap(); fs::File::options() .write(true) diff --git a/crates/b2-core/src/link.rs b/crates/b2-core/src/link.rs index 3c23ae4..cb69511 100644 --- a/crates/b2-core/src/link.rs +++ b/crates/b2-core/src/link.rs @@ -1,23 +1,14 @@ -//! Parse a note body into the links that become edges — B2's **one link grammar**. +//! B2's one link grammar: parse a note body into the links that become edges. //! -//! **The body carries no B2 syntax** (ADR-0010): every body construct is ordinary -//! Obsidian Markdown and yields an untyped `references` edge — prose around a link is just -//! prose. Three body constructs: a bare `[[path|alias]]`; Markdown's own `[text](path)` / -//! `![alt](path)` (relative vault targets only, the `!` marking an **embed** and the -//! text/alt captured as the edge's **caption**); and the `![[file.ext|alias]]` embed. A -//! *typed* edge — ` [[path|alias]] — explanation` — exists only as a frontmatter -//! `b2_relations:` entry, parsed by [`parse_relation`] and written by [`render_relation`]. +//! The body carries no B2 syntax (ADR-0010): `[[path|alias]]`, `![[file|alias]]`, +//! `[text](path)` and `![alt](path)` all yield untyped `references` edges. A typed edge +//! (` [[path]] — explanation`) exists only in frontmatter `b2_relations:` +//! ([`parse_relation`], [`render_relation`]). //! -//! Everything that reads link text goes through [`link_spans`]: ingest projects edges from -//! it ([`parse_links`]) and a move rewrites targets in place from it (`mv.rs`), so what a -//! move repairs is exactly what ingest projected. The scan is **per line**: a stray `[[` -//! never pairs with a `]]` on a later line. -//! -//! Hand-rolled and deliberately minimal. Known simplifications, to revisit when queries -//! need them: a typed frontmatter entry yields exactly one edge (extra wikilinks in its -//! trailing text read as explanation); links inside code spans/fences are not excluded; -//! only `—`/`:` introduce an explanation; a Markdown link's text stops at the first `]` -//! and its target at the first `)`. +//! Ingest and `mv.rs` both read links via [`link_spans`], so a move repairs exactly what +//! ingest projected. The scan is per line. Known simplifications: links in code are not +//! excluded; only `—`/`:` introduce an explanation; a Markdown link's text stops at the +//! first `]` and its target at the first `)`. use std::ops::Range; @@ -26,21 +17,15 @@ use std::ops::Range; pub struct ParsedLink { /// `references` for a bare link, otherwise the relation verb. pub edge_type: String, - /// The target as written — `[[path|alias]]`'s path part or `[…](path)`'s - /// parenthesized target (becomes `dst_path_raw`; a `#fragment` suffix is - /// stripped at *resolution*, never here). + /// The target as written (`dst_path_raw`); a `#fragment` is stripped at resolution. pub target_path: String, /// Trailing text after `—`/`:` on a typed frontmatter entry. pub explanation: Option, - /// True for an embed form (`![alt](path)` / `![[file]]`) — recorded on the - /// edge as a display nicety, never a distinct verb (data-model.md §3). + /// An embed form (`![…]`): a display nicety, never a distinct verb (data-model.md §3). pub embed: bool, - /// True when the target came from a Markdown-form link (`[…](target)`). - /// Resolution treats these with standard Markdown semantics — note-relative - /// first, then vault-root — while wikilink targets stay vault-root. + /// A Markdown-form link, resolved note-relative first, then vault-root. pub md_form: bool, - /// The authored display text — `![alt](…)`'s alt, `[text](…)`'s text, or a - /// wikilink's alias. Stored as the edge's `caption` (data-model.md §3). + /// The alt, link text or alias; the edge's `caption` (data-model.md §3). pub caption: Option, } @@ -53,25 +38,20 @@ pub(crate) enum LinkForm { Markdown, } -/// Where one link sits in the scanned text, as byte ranges into it — so the one grammar -/// serves both reading a link ([`parse_links`]) and splicing a new target into it in -/// place (the move's rewrite). +/// Where one link sits in the text, as byte ranges, so a move can splice in place. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LinkSpan { pub form: LinkForm, /// A `!` directly before the opening bracket. pub embed: bool, - /// The target token, trimmed: `concepts/memory` in `[[ concepts/memory | M ]]`, - /// `img.png` in `![a]( img.png )`. Never empty. + /// The target token, trimmed. Never empty. pub target: Range, - /// The display text, trimmed: a wikilink's alias (present — possibly empty — whenever - /// the link has a `|`), or a Markdown link's text (absent when blank). + /// Trimmed: a wikilink's alias (present, maybe empty, when there is a `|`), or a + /// Markdown link's text (absent when blank). pub caption: Option>, } -/// Every vault link in `text`, in document order, scanned line by line. A Markdown-form -/// link at an external target (a scheme, an absolute path, a fragment-only anchor) is -/// not a vault link and yields no span; nor does a wikilink with an empty target. +/// Every vault link in `text`, in document order, scanned line by line. pub(crate) fn link_spans(text: &str) -> Vec { let mut spans = Vec::new(); let mut line_start = 0; @@ -86,8 +66,7 @@ pub(crate) fn link_spans(text: &str) -> Vec { spans } -/// Parse every link in `body`, in document order — all untyped `references` -/// edges: the body carries no typed syntax (data-model §2). +/// Every link in `body` as an untyped `references` edge (data-model §2). pub fn parse_links(body: &str) -> Vec { link_spans(body) .iter() @@ -107,11 +86,9 @@ fn reference(text: &str, span: &LinkSpan) -> ParsedLink { } } -/// Parse a typed spec ` [[path|alias]] [— explanation]` — the shape of a -/// frontmatter `b2_relations:` entry (data-model §2). `None` if it isn't -/// ` `. +/// Parse a typed spec ` [[path|alias]] [— explanation]` (data-model §2). fn parse_typed_spec(rest: &str) -> Option { - // The verb: a lowercase-kebab token immediately before the wikilink. + // A lowercase-kebab verb. let verb_end = rest.find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); let verb_end = match verb_end { Some(0) | None => return None, // no verb (e.g. "[[..]]") or no following token @@ -122,7 +99,6 @@ fn parse_typed_spec(rest: &str) -> Option { return None; } - // The wikilink must follow the verb directly (whitespace allowed). let after_verb = rest[verb_end..].trim_start(); let inner_and_rest = after_verb.strip_prefix("[[")?; let close = inner_and_rest.find("]]")?; @@ -143,27 +119,21 @@ fn parse_typed_spec(rest: &str) -> Option { }) } -/// Parse one frontmatter `b2_relations:` entry (the string value of one YAML -/// list item): a typed spec ` [[path|alias]] — …`, or a bare -/// `[[path|alias]]` ⇒ `references`. `None` if it holds no wikilink. The caller -/// assigns `origin=frontmatter`. This is the **only** parser that yields a verb -/// or explanation — the body never does (data-model §2). +/// Parse one `b2_relations:` entry: a typed spec, or a bare `[[…]]` ⇒ `references`. The +/// only parser that yields a verb or explanation (data-model §2). pub fn parse_relation(spec: &str) -> Option { let spec = spec.trim(); if let Some(link) = parse_typed_spec(spec) { return Some(link); } - // bare link fallback → references (the entry is one YAML value, scanned whole) + // Bare link → references. let mut spans = Vec::new(); scan_line(spec, 0, &mut spans); spans.first().map(|span| reference(spec, span)) } -/// Render a typed frontmatter `b2_relations:` entry — the inverse of [`parse_relation`] -/// for a typed spec: ` [[target_path]]`, then ` — explanation` when one is given -/// (data-model §2). `target_path` is written verbatim as the link text, so the caller -/// chooses the convention (a note's `.md` dropped, as Obsidian writes links). A blank -/// explanation is omitted and a given one trimmed, exactly as parsing reads it back. +/// Render a typed `b2_relations:` entry, the inverse of [`parse_relation`] (data-model +/// §2). `target_path` is written verbatim; a blank explanation is omitted. pub fn render_relation(verb: &str, target_path: &str, explanation: Option<&str>) -> String { match explanation.map(str::trim).filter(|e| !e.is_empty()) { Some(e) => format!("{verb} [[{target_path}]] — {e}"), @@ -171,11 +141,8 @@ pub fn render_relation(verb: &str, target_path: &str, explanation: Option<&str>) } } -/// Collect every link in `line` as a [`LinkSpan`] offset by `base`, in written order: -/// `[[path|alias]]` and `![[path|alias]]` (wikilink + embed), and Markdown's own -/// `[text](target)` / `![alt](target)` — the latter only for **vault** targets -/// (a scheme, an absolute path, or a fragment-only target is not a vault member -/// and yields nothing; data-model.md §10). +/// Collect every link in `line` as a [`LinkSpan`] offset by `base`. Markdown-form links +/// count only for vault targets (data-model.md §10). fn scan_line(line: &str, base: usize, out: &mut Vec) { let mut i = 0; while i < line.len() { @@ -185,7 +152,7 @@ fn scan_line(line: &str, base: usize, out: &mut Vec) { Some(r) if r.starts_with('[') => (true, r), _ if rest.starts_with('[') => (false, rest), _ => { - // Not a link start — skip one char (multi-byte safe). + // Skip one char, multi-byte safe. i += rest.chars().next().map_or(1, char::len_utf8); continue; } @@ -241,8 +208,7 @@ struct MdLink { consumed: usize, } -/// Parse a leading `[text](target)`. Minimal by design (module doc): text stops at the -/// first `]`, the target at the first `)`, and `](` must be adjacent. +/// Parse a leading `[text](target)`. Minimal by design (module doc); `](` must be adjacent. fn parse_md_link(s: &str) -> Option { let inner = s.strip_prefix('[')?; let close = inner.find(']')?; @@ -261,8 +227,7 @@ fn parse_md_link(s: &str) -> Option { }) } -/// A wikilink's inner text (between `[[` and `]]`, starting at byte `base`) split at the -/// first `|` into the trimmed target and, when a `|` is present, the trimmed alias. +/// Split a wikilink's inner text (at byte `base`) at the first `|` into target and alias. fn split_wiki_inner(inner: &str, base: usize) -> (Range, Option>) { match inner.split_once('|') { Some((path, alias)) => ( @@ -279,15 +244,11 @@ fn trimmed(s: &str, base: usize) -> Range { start..start + s.trim().len() } -/// `r` moved `by` bytes to the right. fn shift(r: &Range, by: usize) -> Range { r.start + by..r.end + by } -/// A Markdown-form target that is **not** a vault member: any scheme -/// (`https://…`, `mailto:…`), an absolute path, or a fragment-only anchor. -/// Wikilink targets never come through here — they are vault paths by -/// construction. +/// A Markdown-form target outside the vault: a scheme, an absolute path, or `#anchor`. fn is_external_target(target: &str) -> bool { if target.starts_with('/') || target.starts_with('#') { return true; @@ -298,8 +259,7 @@ fn is_external_target(target: &str) -> bool { } } -/// Read the explanation after a typed link: trailing text introduced by an -/// em-dash or a colon (data-model §2). Anything else means no explanation. +/// The explanation after a typed link, introduced by `—` or `:` (data-model §2). fn extract_explanation(tail: &str) -> Option { let t = tail.trim_start(); let body = t.strip_prefix('—').or_else(|| t.strip_prefix(':'))?; @@ -311,10 +271,9 @@ fn extract_explanation(tail: &str) -> Option { mod tests { use super::*; - /// One expected link, as the case tables spell it: `(type, target, caption, embed)`. + /// `(type, target, caption, embed)`. type Expected<'a> = (&'a str, &'a str, Option<&'a str>, bool); - /// Shorthand: parse one line, return `(type, target, caption, embed)`. fn parsed(line: &str) -> Vec<(String, String, Option, bool)> { parse_links(line) .into_iter() @@ -407,8 +366,7 @@ mod tests { #[test] fn a_verb_shaped_body_list_item_is_just_a_reference() { - // The body carries no typed syntax (data-model §2): a list item opening - // with a verb-looking word is prose, and only its wikilink projects. + // The body carries no typed syntax (data-model §2). let links = parse_links("- supports [[papers/x.pdf|the paper]] — key evidence"); assert_eq!(links.len(), 1); let l = &links[0]; @@ -419,10 +377,7 @@ mod tests { assert!(!l.embed); } - /// The exact hazard that killed the body typed-line syntax (decision - /// 2026-07-21): a *lowercase* verb lookalike opening a list item must stay - /// prose. `- see [[x]]` becoming a typed edge of verb "see" is the failure - /// mode; only the wikilink may project, always untyped. + /// The hazard that retired body typed-line syntax (decision 2026-07-21). #[test] fn lowercase_verb_lookalikes_in_prose_stay_prose() { let links = parse_links("- see [[concepts/memory|Human memory]] for the mechanism\n"); @@ -430,9 +385,6 @@ mod tests { assert_eq!(links[0].edge_type, "references"); } - /// A prose link and a verb-led list item are the same thing to the parser: two - /// `references` edges in document order, neither carrying an explanation — no - /// body shape is ever "special". #[test] fn body_links_never_gain_a_type_from_surrounding_prose() { let body = "Spaced repetition exploits the [[concepts/memory|Human memory]] retrieval curve.\n\n## Relations\n- supports [[concepts/memory|Human memory]] — applies the forgetting curve\n"; @@ -442,8 +394,6 @@ mod tests { assert!(links.iter().all(|l| l.explanation.is_none())); } - /// A wikilink's caption is its own `|`-part: absent means `None`, never an empty - /// string. #[test] fn a_wikilink_without_an_alias_has_no_caption() { let links = parse_links("Refer to [[concepts/memory]].\n"); @@ -459,18 +409,13 @@ mod tests { assert_eq!(l.caption.as_deref(), Some("the paper")); assert_eq!(l.explanation.as_deref(), Some("key evidence")); - // A bare entry (no verb) falls back to an untyped reference. let bare = parse_relation("[[notes/a|A]]").unwrap(); assert_eq!(bare.edge_type, "references"); assert_eq!(bare.target_path, "notes/a"); - // No wikilink at all ⇒ no edge. assert!(parse_relation("just some words").is_none()); } - /// The two accepted explanation separators and the tolerated verb tail - /// (relation.rs): `—` is asserted above, `:` here, and a non-core verb is - /// stored verbatim rather than coerced into the closed core. #[test] fn relation_accepts_a_colon_separator_and_keeps_a_tail_verb_verbatim() { let colon = @@ -483,9 +428,6 @@ mod tests { assert_eq!(tail.explanation, None); } - /// What `b2 link` writes is what ingest reads back: rendering then parsing a - /// relation returns the verb, target and explanation it was rendered from — and a - /// blank explanation renders as none at all. #[test] fn a_rendered_relation_parses_back_to_what_it_was_rendered_from() { let cases: &[(&str, &str, Option<&str>)] = &[ @@ -530,8 +472,7 @@ mod tests { false )] ); - // same-line recovery: the link is still found (its caption may swallow - // the stray bracket — the documented first-`]` minimalism) + // same-line recovery (its caption may swallow the stray bracket) let got = parsed("broken [[x then fine [ok](a.png)"); assert_eq!(got.len(), 1); assert_eq!(got[0].1, "a.png"); @@ -541,8 +482,6 @@ mod tests { assert_eq!(parsed("hey! [x](a.png)").len(), 1); } - /// The spans are what a move splices into, so their ranges must name exactly the - /// trimmed target (and caption) bytes, offset across lines — CRLF endings included. #[test] fn spans_address_the_trimmed_target_bytes_across_lines() { let text = "a [[ x/y | Why ]] b\r\n![alt]( img.png ) and [t](https://e.x)\n[[z]]"; @@ -568,8 +507,6 @@ mod tests { ); } - /// A stray `[[` never pairs with a `]]` on a later line, so the later line's link - /// is found — by ingest and, through the same spans, by a move's rewrite. #[test] fn a_stray_open_bracket_never_swallows_the_next_lines_link() { let text = "broken [[x\nsee [[old]]\n"; diff --git a/crates/b2-core/src/llm.rs b/crates/b2-core/src/llm.rs index dbb391b..c7d52bc 100644 --- a/crates/b2-core/src/llm.rs +++ b/crates/b2-core/src/llm.rs @@ -1,26 +1,16 @@ -//! The chat seam — `LlmProvider`, the second enumerated AI seam (ADR-0005). Sibling of -//! [`crate::embed`]: the engine is built and tested against the deterministic [`FakeLlm`], -//! and `b2-llm`'s real provider drops in through the same trait with no schema or flow -//! change. +//! The chat seam, `LlmProvider` (ADR-0005). The engine is tested against [`FakeLlm`]; +//! `b2-llm`'s real provider drops in through the same trait. //! -//! Two deliberate contrasts with the embedder seam: -//! -//! - **No index identity** (contrast ADR-0007): chat output is never stored, so -//! [`LlmProvider::model_id`] is display only — no `meta` row, no reindex on a model -//! swap, which is what makes "change models at any time" true by construction. -//! - **Streaming is the contract, not a nicety**: tokens flow up through a callback, whose -//! return steers cooperative cancellation at token granularity. In the trait from day -//! one because retrofitting it would touch every implementor and call site. -//! -//! Sync, no runtime (ADR-0011): cancellation is returning early from a blocking read loop. +//! Unlike the embedder, chat has no index identity (contrast ADR-0007): output is never +//! stored, so a model swap never reindexes. Streaming is the contract: the token callback's +//! return value cancels. Sync (ADR-0011): cancelling returns early from a blocking read. use crate::error::Result; use serde::{Deserialize, Serialize}; use std::ops::ControlFlow; -/// One side of a chat turn. `User` turns are the human's; `Assistant` turns are -/// prior model answers the adapter carried forward (session-only history — a -/// persisted transcript would be B2-derived state outside Markdown, S4). +/// One side of a chat turn. History is session-only: a persisted transcript would be +/// B2-derived state outside Markdown (S4). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -28,12 +18,8 @@ pub enum Role { Assistant, } -/// One turn of the conversation, oldest first in [`ChatRequest::turns`]. -/// -/// Serializable **both ways**, unlike the read-only view types: history is the adapter's -/// and session-only, so a GUI carrying a conversation across the IPC hands it back turn by -/// turn rather than defining a parallel DTO. It crosses a process boundary; it never -/// reaches disk. +/// One turn of the conversation. Deserializable too, so the desktop hands history back +/// over IPC; it never reaches disk. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ChatTurn { pub role: Role, @@ -41,7 +27,6 @@ pub struct ChatTurn { } impl ChatTurn { - /// A turn the human typed — the shape adapters append per question asked. pub fn user(content: &str) -> Self { Self { role: Role::User, @@ -49,7 +34,6 @@ impl ChatTurn { } } - /// A prior model answer the adapter carries forward as context. pub fn assistant(content: &str) -> Self { Self { role: Role::Assistant, @@ -58,25 +42,21 @@ impl ChatTurn { } } -/// Which flow-④ step a request serves — carried **structurally** so a provider -/// (or the fake) never infers it from prompt text, and so a grounded request -/// whose retrieval came back empty is never mistaken for condensation (they -/// both carry no passages, for different reasons). +/// Which flow-④ step a request serves, carried structurally so nothing infers it from +/// prompt text or from an empty passage list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RequestKind { /// Step 0: rewrite the latest turn into a standalone retrieval query. Condense, /// Steps 2–3: answer the question from the numbered passages. Chat, - /// A tool-using turn ([`ChatRequest::tools`] is what the model may call): the - /// passages reach the model inside tool results, so [`ChatRequest::passages`] is - /// the *citation ledger* for this kind and is not rendered into the system message. + /// A tool-using turn. Passages reach the model in tool results, so + /// [`ChatRequest::passages`] is only the citation ledger here. Agent, } -/// One B2 tool the model may call — a read-only `Vault` op described for the model. -/// `parameters` is a JSON Schema object, passed to the provider as is (the OpenAI -/// `function.parameters` shape). +/// One read-only `Vault` op the model may call. `parameters` is a JSON Schema object +/// (OpenAI's `function.parameters`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolSpec { pub name: String, @@ -84,9 +64,8 @@ pub struct ToolSpec { pub parameters: serde_json::Value, } -/// One call the model asked for. `arguments` is the model's JSON **text**, unparsed: -/// it is untrusted output, so whoever runs the tool parses it and answers a malformed -/// call with an error *result* rather than failing the turn. +/// One call the model asked for. `arguments` is untrusted, unparsed JSON text: a +/// malformed call gets an error result, not a failed turn. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolCall { /// The provider's id for the call, echoed back with its result. @@ -95,69 +74,48 @@ pub struct ToolCall { pub arguments: String, } -/// A call and what B2 answered — one step of a tool-using turn, replayed to the model -/// on the next round so it can read what it asked for. +/// A call and B2's answer, replayed to the model on the next round. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolExchange { pub call: ToolCall, pub result: String, } -/// What the grounded prompt instructs the model to say when the passages don't -/// support an answer. Declared beside the seam because both sides must agree on -/// it: the flow's system prompt cites it (`chat::GROUNDED_SYSTEM_PROMPT` -/// contains it verbatim — asserted by the suite), and [`FakeLlm`] obeys it for -/// a chat request with no passages. +/// What the model says when the passages don't support an answer. Shared by +/// `chat::GROUNDED_SYSTEM_PROMPT` and [`FakeLlm`]. pub const NO_EVIDENCE_ANSWER: &str = "I don't find that in your notes."; -/// One numbered context passage handed to the model — the retrieval unit of -/// flow ④, carried structured (not pre-rendered) so a fake can read it and a -/// wire client can render it once, via [`ChatRequest::system_message`]. -/// Numbering is positional and 1-based: passage `i` is cited as `[i + 1]`. +/// One context passage for flow ④, kept structured until [`ChatRequest::system_message`] +/// renders it. Passage `i` is cited as `[i + 1]`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextPassage { - /// Vault-relative path of the note the passage came from — its identity (L1), - /// and what a citation resolves back to. + /// The source note (L1), what a citation resolves to. pub path: String, - /// The chunk's heading breadcrumb, when the chunker recorded one. pub heading_path: Option, - /// The passage text, verbatim (the chunk's stored text). + /// The chunk's stored text, verbatim. pub text: String, } -/// What one provider call is asked to complete: a system prompt, the -/// conversation so far (the final turn is the current user message), and the -/// numbered context passages grounding the answer. A condensation request -/// (flow ④ step 0) carries **no passages** — nothing has been retrieved yet; -/// retrieval is what condensation feeds. +/// What one provider call is asked to complete. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChatRequest { - /// Which step this request serves (condense vs. grounded chat). pub kind: RequestKind, - /// The instruction text (prompt assembly is core logic — `chat.rs` authors - /// it, so it is testable against [`FakeLlm`]). + /// The instruction text, authored in `chat.rs`. pub system: String, - /// The conversation, oldest first; the final turn is the current user - /// message. + /// The conversation, oldest first; the last turn is the current message. pub turns: Vec, - /// The numbered passages grounding a chat answer; empty for condensation. For - /// [`RequestKind::Agent`] these are the passages tool results have handed over so - /// far — what `[n]` markers resolve against — and are not rendered again. + /// The passages grounding the answer; empty for condensation. For an agent turn, + /// those tool results handed over so far. pub passages: Vec, - /// The tools the model may call this round; empty for every non-agent request, and - /// for the last round of an agent turn (which must answer). + /// Tools the model may call; empty outside agent turns and on an agent's last round. pub tools: Vec, - /// The calls made so far this turn with their results, oldest first — replayed - /// after [`turns`](Self::turns). + /// Calls made so far this turn, replayed after [`turns`](Self::turns). pub exchanges: Vec, } impl ChatRequest { - /// Render the system prompt plus the numbered passage block into the one - /// system message a wire client sends. Rendering lives here — beside the - /// structured passages — so every provider numbers passages exactly as - /// citation resolution ([`crate::chat::cited_markers`]) counts them: - /// 1-based `[n]`, in passage order. + /// The system prompt plus the numbered passages, as one system message. Numbered here so + /// every provider matches [`crate::chat::cited_markers`]. pub fn system_message(&self) -> String { let mut out = self.system.clone(); // An agent turn's passages already reached the model inside tool results. @@ -174,9 +132,8 @@ impl ChatRequest { } impl ContextPassage { - /// The passage as the model reads it, cited as `[marker]`: the marker, the path and - /// any heading breadcrumb on one line, then the text. The one layout, whichever way - /// a passage reaches the model — the system message's block or a tool result. + /// The passage as the model reads it, cited as `[marker]`, in the system message or a + /// tool result alike. pub fn block(&self, marker: usize) -> String { let heading = match &self.heading_path { Some(h) => format!(" — {h}"), @@ -186,36 +143,25 @@ impl ContextPassage { } } -/// How a completion ended and what it produced: everything delivered to the -/// callback so far, plus the completed-vs-cancelled marker — so an adapter can -/// render a truncated answer honestly rather than passing it off as whole. +/// A completion's text and whether it was cut short, so a truncated answer is shown as one. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Completion { - /// The accumulated text: every token handed to `on_token`, in order, - /// including the one whose callback returned `Break`. + /// Every token handed to `on_token`, including the one that returned `Break`. pub text: String, - /// `true` when the stream was cut short — by the callback breaking, or by - /// the provider's own early stop. `text` is then an honest prefix. + /// Cut short by the callback or the provider; `text` is then a prefix. pub cancelled: bool, - /// The tools the model asked to run instead of (or before) answering. Empty for a - /// plain answer, and always empty when the request offered no tools. + /// Tools the model asked to run instead of answering. pub tool_calls: Vec, } -/// The chat seam (sibling of `Embedder`; invariant M1). Messages in, streamed -/// text out. Unlike `Embedder::model_id`, `model_id` here is for display and -/// logging only — chat carries **no** index identity (contrast M2): nothing is -/// recorded in `meta`, and swapping models never touches the index. +/// The chat seam (invariant M1): messages in, streamed text out. No index identity +/// (contrast M2). pub trait LlmProvider { - /// The provider's display name for logs and UI badges — never an identity - /// anything keys on (no `meta` row; contrast `Embedder::model_id`). + /// Display name for logs and UI; nothing keys on it. fn model_id(&self) -> &str; - /// Stream a completion. `on_token` receives tokens as they arrive and - /// steers the stream: returning `ControlFlow::Break(())` cancels — the - /// implementation must stop promptly and drop the connection (the pane's - /// Esc, a closed pane, the CLI's cancel all land here). Returns the text - /// accumulated so far plus how the stream ended (completed / cancelled). + /// Stream a completion. `on_token` returning `Break` cancels: stop promptly and drop + /// the connection. fn complete( &self, req: &ChatRequest, @@ -223,24 +169,17 @@ pub trait LlmProvider { ) -> Result; } -/// The fake provider's display id — the `FAKE_MODEL_ID` sibling. Nothing keys -/// on it (chat carries no index identity); it exists so logs read honestly. +/// The fake provider's display id. pub const FAKE_LLM_MODEL_ID: &str = "fake-llm-v1"; -/// Deterministic provider for tests/dev — the [`crate::embed::FakeEmbedder`] sibling, -/// keyed on [`ChatRequest::kind`]. A **chat** request streams a fixed grounded answer -/// citing every passage it was handed, one `[n]` marker per token, so the suite can assert -/// the whole flow-④ pipeline — and mid-stream cancellation at an exact token — model-free; -/// handed no passages it answers [`NO_EVIDENCE_ANSWER`]. A **condensation** request echoes -/// the latest user turn verbatim. An **agent** request is a two-step script read off the -/// request's structure: while an offered tool that needs no arguments has not been called -/// yet, call each such tool once with `{}`; after that, answer as a chat request does. +/// Deterministic provider for tests and dev. Chat streams an answer citing every passage, +/// one `[n]` per token (so cancellation can land on an exact token), or +/// [`NO_EVIDENCE_ANSWER`] with none. Condense echoes the last user turn. Agent calls each +/// uncalled no-argument tool once with `{}`, then answers as chat. #[derive(Debug, Clone, Copy, Default)] pub struct FakeLlm; impl LlmProvider for FakeLlm { - /// The fixed display id [`FAKE_LLM_MODEL_ID`] — logging only, like every - /// provider's. fn model_id(&self) -> &str { FAKE_LLM_MODEL_ID } @@ -279,8 +218,7 @@ impl LlmProvider for FakeLlm { } let tokens: Vec = match req.kind { RequestKind::Condense => { - // Echo the question. One token — cancellation scripting - // belongs to the multi-token chat branch. + // Echo the question as one token. let echo = req .turns .iter() @@ -294,13 +232,10 @@ impl LlmProvider for FakeLlm { vec![echo] } } - // Nothing retrieved, nothing to cite: the grounded prompt's - // no-evidence response, as a real model would give it. RequestKind::Chat | RequestKind::Agent if req.passages.is_empty() => { vec![NO_EVIDENCE_ANSWER.to_string()] } RequestKind::Chat | RequestKind::Agent => { - // A grounded answer citing every passage, marker-per-token. let mut t: Vec = vec!["Grounded".into(), " in".into()]; t.extend((1..=req.passages.len()).map(|n| format!(" [{n}]"))); t.push(".".into()); diff --git a/crates/b2-core/src/mv.rs b/crates/b2-core/src/mv.rs index e7d29e7..c9b6cdc 100644 --- a/crates/b2-core/src/mv.rs +++ b/crates/b2-core/src/mv.rs @@ -1,41 +1,19 @@ //! Move / rename a note, a resource or a folder, and repair inbound links. //! -//! **A move is where "rename keeps every backlink resolving" is earned.** Identity is -//! the vault-relative path (ADR-0003, L1/L3), so a move changes the moved member's -//! identity, and this module makes that a re-key rather than a break. Two halves, both -//! bounded by the moved set's backlink count: the human-facing copy — the link text in -//! every file linking *at* a moved member, rewritten in place — and the index: each moved -//! note re-keyed by one [`db::repoint_note_path`] whose `ON UPDATE CASCADE` FKs carry -//! chunks, centroid and outbound edges atomically, each moved resource by one -//! [`db::repoint_resource`], then a re-projection of the inbound sources so their edges -//! (`edges.dst_path` has no FK — it must be free to dangle) point at the new paths. A -//! moved note's **vectors are not touched at all**: content-addressed (ADR-0006), they -//! belong to the chunk text, which a move does not change. +//! Identity is the path (ADR-0003, L1/L3), so a move re-keys: it rewrites the link text in +//! every file linking at a moved member, repoints the index rows (`ON UPDATE CASCADE` +//! carries chunks, centroid and outbound edges), and re-projects the inbound sources, +//! whose `edges.dst_path` has no FK. Vectors are untouched (ADR-0006). //! -//! **One pipeline for every move.** `move_note`, `move_resource` and `move_dir` only -//! validate and build a [`MoveSet`] — the one rename, and the indexed members travelling -//! with it — and [`execute`] runs it. It is **Markdown-first**: rewrite the inbound text, -//! rename on disk, *then* re-project from the now-current Markdown. And bounded, not a -//! scan: [`db::inbound_edges_of`] names exactly the files to touch, so the cost is -//! O(inbound links). The link text is found by [`crate::link`]'s own scanner, so a move -//! reads links exactly as ingest does. Known gap: within one inbound file the rewrite is -//! keyed by a link's *written* text, so a second link with the same text that resolves -//! elsewhere (a note-relative Markdown link and a vault-root wikilink both written -//! `img.png`, naming two different files) is rewritten too. +//! Every move builds a [`MoveSet`] and runs [`execute`]: Markdown first, then re-project. +//! Cost is O(inbound links) via [`db::inbound_edges_of`]. Known gap: rewrites are keyed by +//! a link's written text, so two same-text links resolving to different files in one +//! inbound file are both rewritten. //! -//! **The vault half is all or nothing** (GH #230). Reindexing can't repair rewritten -//! link text (it projects whatever the Markdown says), so a failed move must leave every -//! file as it was. Three layers: the destination is checked before anything is written -//! ([`refuse_occupied`], [`refuse_file_ancestor`]); every rewrite is read and computed -//! in memory before the first write ([`plan_inbound`]); and the writes, the folders the -//! move creates and the rename run as one [`commit`] that undoes whatever it did if a -//! later step fails. Once the rename lands the vault is final: a failure re-keying or -//! re-projecting the index leaves correct Markdown, which `b2 reindex` projects. -//! -//! No process can undo a crash, so the one unguarded window is between the first -//! rewrite and the rename: the rewritten links already name the destination, the note -//! is still at its source. Re-running the same move finishes it (the rewrites are -//! already done, so only the rename is left). +//! The vault half is all or nothing (GH #230), since a reindex can't repair link text: the +//! destination is checked and every rewrite computed before the first write, then +//! [`commit`] undoes itself on failure. A crash between the first rewrite and the rename +//! is the one unguarded window; re-running the move finishes it. use crate::db::{self, InboundTarget}; use crate::error::{Error, Result}; @@ -49,35 +27,22 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -/// What [`move_note`] did: the note's old and new vault-relative paths, the inbound -/// files whose link text was rewritten, and the total number of `[[…]]` targets -/// repaired across them. `to` is the note's identity after the move (L1). +/// What [`move_note`] did. `to` is the note's identity after the move (L1). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MoveReport { pub from: String, pub to: String, - /// Post-move vault-relative paths of the files whose link text was rewritten - /// (sorted, deduped) — the moved note itself under `to`, when it links to itself. - /// Empty when nothing linked to the moved note. + /// Post-move paths of the files whose link text was rewritten (sorted, deduped), + /// including the moved note itself when it self-links. pub rewrote: Vec, /// Total individual `[[…]]` link targets rewritten across `rewrote`. pub links_rewritten: usize, } -/// Move the note at `old_rel` to `new_rel_input`, rewriting every inbound -/// `[[oldpath|alias]]` link and re-keying the index. `old_rel` is the note's current -/// path (as the façade resolved it); `new_rel_input` is the raw destination the user -/// gave (a `.md` suffix is optional). -/// -/// Re-projection **re-embeds the inbound files** — their bodies changed — so the caller -/// must open the vault with the embedder the index was built with. The *moved* note -/// re-embeds nothing (ADR-0006). Errors with [`Error::MoveDestination`] for an invalid -/// destination and [`Error::MoveTargetExists`] rather than clobber. -/// -/// Only the `[[…]]` form is rewritten at a note. A Markdown-form link at one -/// (`[x](notes/a.md)`) keeps its text, but its source is re-projected like every inbound -/// source, so its edge is exactly what a rebuild would project — dangling, or resolved to -/// whatever note that text now names — rather than keep naming a path that is gone. +/// Move the note at `old_rel` to `new_rel_input` (`.md` optional), rewriting inbound +/// `[[…]]` links and re-keying the index. Inbound files re-embed, so the caller needs the +/// index's embedder. A Markdown-form link at a note keeps its text; its source is still +/// re-projected, so its edge matches what a rebuild would project. pub fn move_note(ctx: EmbedCtx, old_rel: &str, new_rel_input: &str) -> Result { let new_rel = pathspec::normalize_rel_md(new_rel_input).map_err(Error::MoveDestination)?; refuse_same_path(&new_rel, old_rel, "note")?; @@ -94,28 +59,21 @@ pub fn move_note(ctx: EmbedCtx, old_rel: &str, new_rel_input: &str) -> Result, /// Total individual link targets rewritten across `rewrote`. pub links_rewritten: usize, } -/// Move the resource at `old_rel` to `new_rel_input` — the note move minus the identity -/// step: rewrite every inbound link's authored text (both syntaxes, each keeping its own -/// relative-vs-root convention), move the file, update the inventory, re-project the -/// inbound notes. B2 never touches the resource's bytes; the move is path-only. Errors -/// mirror [`move_note`] (the façade owns [`Error::ResourceNotFound`]), plus -/// [`Error::MoveDestination`] for a `.md` destination: that path names a note, so the -/// file would stop being this resource (and a rebuild would index it as a note). +/// Move the resource at `old_rel` to `new_rel_input`, rewriting inbound links in both +/// syntaxes (each keeping its relative-vs-root convention). Path-only; the bytes are never +/// touched. A `.md` destination is refused: a rebuild would index it as a note. pub fn move_resource( ctx: EmbedCtx, old_rel: &str, @@ -145,40 +103,23 @@ pub fn move_resource( }) } -/// What [`move_dir`] did: the folder's old and new vault-relative paths, how many -/// **indexed** notes/resources travelled (unindexed files travel too — the whole -/// directory is renamed — but only indexed rows are counted), the files whose -/// link text was rewritten (reported at their **post-move** paths, sorted), and -/// the total link targets repaired. +/// What [`move_dir`] did. The counts are of indexed members; unindexed files travel too. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct DirMoveReport { pub from: String, pub to: String, pub moved_notes: usize, pub moved_resources: usize, - /// Post-move vault-relative paths of the files whose link text was rewritten - /// (sorted, deduped). Empty when no links referenced the moved set. + /// Post-move paths of the files whose link text was rewritten (sorted, deduped). pub rewrote: Vec, /// Total individual link targets rewritten across `rewrote`. pub links_rewritten: usize, } -/// Move/rename the whole directory `from_input` to `to_input`. One `fs::rename` moves -/// the directory — so **unindexed** files inside travel too — after every inbound link -/// at the moved set is rewritten, exactly as the per-file moves do: -/// -/// - wikilinks are vault-root-anchored, so links *between* co-moved notes are rewritten -/// just like links from outside the set; -/// - note-relative Markdown targets between co-moved files survive unchanged (a computed -/// replacement equal to the authored text is skipped); -/// - after the rename every moved note's `notes.path` is repointed **first**, then each -/// moved file re-projects — so path-based link resolution never depends on -/// re-projection order, the same reason full ingest is two-phase. -/// -/// Re-projection re-embeds only genuinely rewritten bodies, but still requires the real -/// embedder. Errors: [`Error::DirNotFound`], [`Error::MoveDestination`] (including a -/// destination inside the moved folder), [`Error::MoveTargetExists`] rather than merge -/// (with the case-only-rename carve-out on case-insensitive filesystems). +/// Move/rename the directory `from_input` to `to_input` with one `fs::rename`, after +/// rewriting inbound links as the per-file moves do. Wikilinks between co-moved notes are +/// rewritten (they are vault-root); note-relative Markdown links between them compute to +/// themselves and are skipped. Requires the real embedder. pub fn move_dir(ctx: EmbedCtx, from_input: &str, to_input: &str) -> Result { let conn = ctx.proj.conn; let from = pathspec::normalize_rel_dir(from_input).map_err(Error::MoveDestination)?; @@ -193,8 +134,7 @@ pub fn move_dir(ctx: EmbedCtx, from_input: &str, to_input: &str) -> Result = db::notes_under_dir(conn, &from)? .into_iter() @@ -228,17 +168,12 @@ pub fn move_dir(ctx: EmbedCtx, from_input: &str, to_input: &str) -> Result Self { Self { from: from.to_string(), @@ -265,8 +199,7 @@ impl MoveSet { } } - /// Where the note at `path` is after the move — its new path if it travels, - /// otherwise `path` itself. + /// Where the note at `path` is after the move. fn after<'a>(&'a self, path: &'a str) -> &'a str { match self .notes @@ -277,7 +210,6 @@ impl MoveSet { } } - /// Whether the note at `path` travels with this set. fn moves_note(&self, path: &str) -> bool { self.notes .binary_search_by(|(old, _)| old.as_str().cmp(path)) @@ -285,17 +217,14 @@ impl MoveSet { } } -/// What [`execute`] did: the rewritten files at their post-move paths (sorted), and -/// how many link targets it rewrote across them. +/// What [`execute`] did: rewritten files at post-move paths (sorted), and the link count. #[derive(Debug)] struct Moved { rewrote: Vec, links_rewritten: usize, } -/// Run one move: refuse an occupied or unreachable destination, plan every inbound -/// rewrite, commit the rewrites and the rename (all or nothing), re-key every moved -/// member, then re-project from the now-current Markdown. +/// Run one move: refuse, plan, commit (all or nothing), re-key, re-project. fn execute(ctx: EmbedCtx, set: &MoveSet) -> Result { let (conn, root) = (ctx.proj.conn, ctx.proj.root); let old_abs = root.join(&set.from); @@ -303,19 +232,14 @@ fn execute(ctx: EmbedCtx, set: &MoveSet) -> Result { refuse_occupied(&old_abs, &new_abs, &set.to)?; refuse_file_ancestor(root, &set.to)?; - // The graph names the bounded inbound set: for each edge pointing at a moved - // member, its source file and the exact link text (`dst_path_raw`) written there. - // Group by file into per-syntax target→replacement maps. A note is rewritten in - // its `[[…]]` form only, each link keeping its own `.md`-or-not convention; a - // resource is linked as `![[img.png]]` *or* `![](img.png)`, so its replacement - // feeds both, re-relativized against the source's **post-move** folder so a - // relative link between two co-moved files computes to itself and is skipped. + // Per-file, per-syntax target→replacement maps. A note is rewritten in its `[[…]]` + // form only; a resource in both, re-relativized against the source's post-move folder + // so a link between co-moved files computes to itself. let note_paths: Vec<&str> = set.notes.iter().map(|(old, _)| old.as_str()).collect(); let resource_paths: Vec<&str> = set.resources.iter().map(|r| r.from.as_str()).collect(); let (mut wiki, mut md) = (ByFile::new(), ByFile::new()); - // Every inbound source is re-projected below, whether or not its *text* changes: - // its edges name paths that are about to change, and `edges.dst_path` carries no FK - // to cascade (it must be free to be NULL — the dangling case, G5). + // Every inbound source is re-projected, text changed or not: `edges.dst_path` has no + // FK to cascade (it must be free to dangle, G5). let mut sources = BTreeSet::new(); for (target, e) in db::inbound_edges_of(conn, ¬e_paths, &resource_paths)? { let (replacement, both_forms) = match target { @@ -340,17 +264,12 @@ fn execute(ctx: EmbedCtx, set: &MoveSet) -> Result { sources.insert(e.src_path); } - // 1. Markdown first: rewrite inbound link text in place at the pre-move paths (a - // self-link or a co-moved linker included), then one rename moves the note, - // resource or folder — unindexed files in a folder travel for free — all or - // nothing. + // 1. Markdown first: rewrite inbound links at the pre-move paths, then rename. let plan = plan_inbound(root, &wiki, &md)?; commit(root, &plan.rewrites, &old_abs, &new_abs)?; - // 2. Re-key the index before anything re-projects, so path-based link resolution - // is independent of re-projection order (the same reason full ingest is - // two-phase). Old and new paths are disjoint — the destination didn't exist — so - // the UNIQUE(path) constraints can't trip. + // 2. Re-key before re-projecting, so resolution is order-independent. Old and new + // paths are disjoint, so UNIQUE(path) can't trip. for (old, new) in &set.notes { db::repoint_note_path(conn, old, new)?; } @@ -360,15 +279,12 @@ fn execute(ctx: EmbedCtx, set: &MoveSet) -> Result { .as_ref() .and_then(ingest::unix_mtime); if !db::repoint_resource(conn, &r.from, &r.to, r.class.as_str(), mtime)? { - // Not inventoried after all (an out-of-band change the index hasn't seen): - // inventory the file where it now is, as a reindex would. + // Not inventoried (an out-of-band change): inventory it where it now is. ingest::project_resource_file(conn, root, &r.to, r.class, true)?; } } - // 3. Re-project from the now-current Markdown: every moved note at its new path - // (refreshing its filename-derived title, mtime and outbound edges — an - // unchanged body reuses its vectors), then every inbound source that stayed put. + // 3. Re-project moved notes at their new paths, then inbound sources that stayed. for (_, new) in &set.notes { ingest::ingest_file(ctx, new)?; } @@ -388,17 +304,13 @@ fn execute(ctx: EmbedCtx, set: &MoveSet) -> Result { }) } -/// Each inbound file's authored-target → replacement map, keyed by the file's -/// vault-relative path. `BTreeMap` throughout, so the rewrite order — and the -/// `rewrote` list every report carries — is sorted and deterministic. +/// Each inbound file's target → replacement map. `BTreeMap` keeps the order deterministic. type ByFile = BTreeMap; /// One file's authored-target → replacement map for one link syntax. type Targets = BTreeMap; -/// Refuse a destination equal to the source. `subject` names the thing being -/// moved, so the message reads in the user's own nouns ("… is the note's current -/// path"). +/// Refuse a destination equal to the source. `subject` names the thing moved, for the message. fn refuse_same_path(new_rel: &str, old_rel: &str, subject: &str) -> Result<()> { if new_rel == old_rel { return Err(Error::MoveDestination(format!( @@ -408,10 +320,8 @@ fn refuse_same_path(new_rel: &str, old_rel: &str, subject: &str) -> Result<()> { Ok(()) } -/// Refuse an occupied destination rather than clobber it (the vault never -/// overwrites, data-model.md §1) — with the case-only-rename carve-out: on a -/// case-insensitive filesystem the destination "exists" because it *is* the -/// source ([`is_same_dirent`]). +/// Refuse an occupied destination rather than clobber it (data-model.md §1), except a +/// case-only rename on a case-insensitive filesystem ([`is_same_dirent`]). fn refuse_occupied(old_abs: &Path, new_abs: &Path, new_rel: &str) -> Result<()> { if new_abs.exists() && !is_same_dirent(old_abs, new_abs) { return Err(Error::MoveTargetExists(new_rel.to_string())); @@ -419,11 +329,8 @@ fn refuse_occupied(old_abs: &Path, new_abs: &Path, new_rel: &str) -> Result<()> Ok(()) } -/// Refuse a destination beneath a regular file (`blocked/a.md` when `blocked` is a -/// file): no rename can land there, and finding out *after* the inbound rewrites is -/// exactly the broken-links failure GH #230 reported. Walks the destination's -/// ancestors from the vault root down and stops at the first missing one — the -/// move creates everything below it. +/// Refuse a destination beneath a regular file, before any rewrite (GH #230). Stops at the +/// first missing ancestor; the move creates everything below it. fn refuse_file_ancestor(vault_root: &Path, new_rel: &str) -> Result<()> { for (i, _) in new_rel.match_indices('/') { let dir = &new_rel[..i]; @@ -440,8 +347,7 @@ fn refuse_file_ancestor(vault_root: &Path, new_rel: &str) -> Result<()> { Ok(()) } -/// One inbound file's planned rewrite: the bytes it holds now and the bytes it will -/// hold. `original` is kept so [`commit`] can put the file back. +/// One inbound file's planned rewrite. `original` lets [`commit`] put the file back. #[derive(Debug)] struct Rewrite { rel: String, @@ -458,10 +364,8 @@ struct Plan { links_rewritten: usize, } -/// Markdown first, for every move: read each inbound file and compute its rewritten -/// link text, writing nothing. `wiki` holds each file's `[[…]]` replacements and `md` -/// its `[…](…)` ones; a file whose rewrite changes nothing is left out of the plan. A -/// file that can't be read fails the move here, while the vault is still untouched. +/// Read each inbound file and compute its rewrite, writing nothing. An unreadable file +/// fails the move here, while the vault is untouched. fn plan_inbound(vault_root: &Path, wiki: &ByFile, md: &ByFile) -> Result { let none = Targets::new(); let mut rewrites = Vec::new(); @@ -491,14 +395,9 @@ fn plan_inbound(vault_root: &Path, wiki: &ByFile, md: &ByFile) -> Result { }) } -/// Rewrite every link in `raw` whose trimmed target is a key of its syntax's map — -/// `wiki` for `[[…]]`/`![[…]]`, `md` for `[…](…)`/`![…](…)` — to that key's replacement. -/// Only the target token changes: every other byte (the brackets, the `|alias`, the -/// link text, whitespace around the target) is preserved, and a target merely sharing a -/// prefix with a key is never touched. The links are the ones [`link::link_spans`] -/// finds — the scanner ingest projects edges from — matched by their written text (see -/// the module doc's known gap). Returns the rewritten text and the count of targets -/// replaced. +/// Replace each link target in `raw` found in its syntax's map (`wiki` for `[[…]]`, `md` +/// for `[…](…)`). Only the target token changes. Uses [`link::link_spans`], the scanner +/// ingest uses. Returns the text and the count replaced. fn rewrite_targets(raw: &str, wiki: &Targets, md: &Targets) -> (String, usize) { let mut out = String::with_capacity(raw.len()); let (mut copied, mut count) = (0usize, 0usize); @@ -519,7 +418,7 @@ fn rewrite_targets(raw: &str, wiki: &Targets, md: &Targets) -> (String, usize) { (out, count) } -/// One vault change [`commit`] made, and so must undo if a later step fails. +/// One vault change [`commit`] must undo if a later step fails. #[derive(Debug)] enum Done { /// `rewrites[i]` was opened for writing (and so truncated). @@ -528,13 +427,9 @@ enum Done { CreatedDir(PathBuf), } -/// Apply a move's vault writes as one unit: write every planned rewrite, create any -/// missing destination folders, then rename `old_abs` → `new_abs` (the one step that -/// moves the note, resource or folder). If any step fails, undo the earlier ones in -/// reverse and return the failure; the rename is last, so once it succeeds there is -/// nothing left to undo. A filesystem has no transaction, so an undo step can fail -/// too — then [`Error::MoveIncomplete`] names the files still holding a rewrite, and -/// carries the failure that started the undo, rather than pretending the vault is whole. +/// Apply a move's vault writes as one unit: rewrites, missing folders, then the rename +/// (last, so success leaves nothing to undo). On failure, undo in reverse. If the undo +/// fails too, [`Error::MoveIncomplete`] names the files still holding a rewrite. fn commit(vault_root: &Path, rewrites: &[Rewrite], old_abs: &Path, new_abs: &Path) -> Result<()> { let mut done = Vec::new(); let Err(err) = apply(rewrites, old_abs, new_abs, &mut done) else { @@ -561,8 +456,7 @@ fn apply(rewrites: &[Rewrite], old_abs: &Path, new_abs: &Path, done: &mut Vec = new_abs .ancestors() .skip(1) @@ -577,10 +471,8 @@ fn apply(rewrites: &[Rewrite], old_abs: &Path, new_abs: &Path, done: &mut Vec Vec { let mut unrestored = Vec::new(); for step in done.iter().rev() { @@ -608,8 +500,7 @@ fn undo(vault_root: &Path, rewrites: &[Rewrite], done: &[Done]) -> Vec { unrestored } -/// Split an authored link target at its first `#` into the path and the fragment — -/// which addresses a place *inside* the target, so a move carries it through verbatim. +/// Split a link target at its first `#`. A move carries the fragment through verbatim. fn split_fragment(authored: &str) -> (&str, Option<&str>) { match authored.split_once('#') { Some((base, fragment)) => (base, Some(fragment)), @@ -617,7 +508,6 @@ fn split_fragment(authored: &str) -> (&str, Option<&str>) { } } -/// `base` with the authored `#fragment` (if any) put back on. fn with_fragment(base: &str, fragment: Option<&str>) -> String { match fragment { Some(f) => format!("{base}#{f}"), @@ -625,10 +515,9 @@ fn with_fragment(base: &str, fragment: Option<&str>) -> String { } } -/// The replacement for a wikilink at a note now living at `new_path`, preserving the -/// link's own `.md`-or-not convention (Obsidian omits the extension; an authored `.md` -/// is kept) and any `#heading` fragment. Only a lowercase `.md` is dropped: that is the -/// suffix the resolver's `+ ".md"` ladder adds back, so any other spelling stays whole. +/// The new wikilink target for a note at `new_path`, keeping the link's `.md`-or-not +/// convention and fragment. Only a lowercase `.md` is dropped, the suffix the resolver +/// adds back. fn wiki_replacement(new_path: &str, authored: &str) -> String { let (base, fragment) = split_fragment(authored); let new_base = if base.ends_with(".md") { @@ -639,28 +528,20 @@ fn wiki_replacement(new_path: &str, authored: &str) -> String { with_fragment(new_base, fragment) } -/// The replacement for a link at a resource moving `old_path` → `new_path`, as -/// authored from a note in `src_dir` (its directory **after** the move, so a link -/// between two co-moved files computes to itself and is skipped). Two things -/// survive: the authored convention — a vault-root target stays vault-root, -/// anything else is re-relativized against `src_dir` — and a `#fragment` suffix, -/// carried through untouched. +/// The new target for a link at a moved resource, from a note in `src_dir` (post-move). +/// A vault-root target stays vault-root; anything else is re-relativized. Keeps the fragment. fn resource_replacement(authored: &str, old_path: &str, new_path: &str, src_dir: &str) -> String { let (base, fragment) = split_fragment(authored); let new_base = if base.trim() == old_path { - new_path.to_string() // authored vault-root — keep it vault-root + new_path.to_string() } else { - pathspec::relativize(src_dir, new_path) // authored note-relative — keep it relative + pathspec::relativize(src_dir, new_path) }; with_fragment(&new_base, fragment) } -/// Whether `a` and `b` name the **same directory entry** on disk — true only on -/// a case-insensitive filesystem (APFS default) for a case-only rename, where -/// `Path::exists` on the destination false-positives against the source itself. -/// `fs::canonicalize` returns the on-disk-case path, so the two canonicalize -/// equal iff they are one entry; any error (e.g. the path doesn't exist) means -/// "not the same entry" and the ordinary target-exists refusal stands. +/// Whether `a` and `b` are the same directory entry: a case-only rename on a +/// case-insensitive filesystem (APFS default). Any canonicalize error means "not the same". fn is_same_dirent(a: &Path, b: &Path) -> bool { match (fs::canonicalize(a), fs::canonicalize(b)) { (Ok(ca), Ok(cb)) => ca == cb, @@ -741,8 +622,6 @@ mod tests { assert_eq!(n, 2); } - /// Each syntax is rewritten from its own map, and a Markdown target keeps the - /// whitespace and link text around it. #[test] fn each_syntax_is_rewritten_from_its_own_map() { let wiki = targets(&[("img.png", "media/img.png")]); @@ -753,14 +632,11 @@ mod tests { "![[media/img.png|cap]] and ![alt]( ../media/img.png )\n" ); assert_eq!(n, 2); - // A map for one syntax never touches the other's links. let (out, n) = rewrite_targets("![alt](img.png)", &wiki, &Targets::new()); assert_eq!((out.as_str(), n), ("![alt](img.png)", 0)); } - /// The divergence one grammar closes: a stray `[[` on one line used to pair with - /// the next line's `]]` in the move's whole-file scan, hiding a link ingest had - /// projected — so the backlink dangled after the move. + /// The move uses ingest's scanner, so a stray `[[` can't hide the next line's link. #[test] fn a_stray_open_bracket_does_not_hide_the_next_lines_link() { let t = targets(&[("old", "new")]); @@ -770,14 +646,10 @@ mod tests { } // --- the replacement rules, direct (GH #134) ------------------------------ - // - // Pure functions, so they get pinned here rather than only through a whole-vault - // move. #[test] fn a_wikilink_keeps_its_own_md_convention() { - // Obsidian's bare form stays bare; an authored `.md` keeps its `.md`. Both - // land on the same note — the convention is the *link's*, not the vault's. + // The convention is the link's, not the vault's. assert_eq!( wiki_replacement("archive/memory.md", "concepts/memory"), "archive/memory" @@ -788,8 +660,6 @@ mod tests { ); } - /// A `[[note#heading]]` link resolves to the note (the fragment is stripped for - /// the lookup only), so its move keeps the heading it addresses. #[test] fn a_wikilink_keeps_its_heading_fragment() { assert_eq!( @@ -804,8 +674,7 @@ mod tests { #[test] fn a_resource_link_keeps_its_convention_and_its_fragment() { - // Authored vault-root (the target equals the resource's vault path) → stays - // vault-root, whoever links it. + // Authored vault-root stays vault-root. assert_eq!( resource_replacement( "assets/plan.pdf", @@ -815,7 +684,7 @@ mod tests { ), "docs/plan.pdf" ); - // Authored note-relative → re-relativized against the linking note's dir. + // Authored note-relative is re-relativized. assert_eq!( resource_replacement( "../assets/plan.pdf", @@ -825,8 +694,7 @@ mod tests { ), "../docs/plan.pdf" ); - // A `#fragment` survives verbatim on both routes — it addresses a place - // *inside* the resource, which a move never touches. + // A `#fragment` survives on both routes. assert_eq!( resource_replacement( "assets/plan.pdf#page=3", @@ -849,9 +717,7 @@ mod tests { #[test] fn a_relative_link_between_co_moved_files_computes_to_itself() { - // The folder move passes the source's **post-move** directory, so a link from - // `dir/note.md` to `dir/img.png` is unchanged by moving `dir/` — and an - // unchanged replacement is what the pipeline skips, leaving the file untouched. + // `src_dir` is the post-move directory, so the link is unchanged and skipped. assert_eq!( resource_replacement("img.png", "dir/img.png", "moved/img.png", "moved"), "img.png" @@ -874,11 +740,8 @@ mod tests { // --- the all-or-nothing commit (GH #230) ------------------------------------ // - // The façade suite (tests/mv.rs) drives a failed rename end to end. These pin the - // two failures it can't reach deterministically: a write that fails after an - // earlier one landed, and an undo that itself fails. + // Failures tests/mv.rs can't reach deterministically. - /// A planned rewrite of `rel` under `root`, from whatever is on disk now. fn planned(root: &Path, rel: &str, rewritten: &str) -> Rewrite { let abs = root.join(rel); Rewrite { diff --git a/crates/b2-core/src/note.rs b/crates/b2-core/src/note.rs index c0e9239..7978ae5 100644 --- a/crates/b2-core/src/note.rs +++ b/crates/b2-core/src/note.rs @@ -1,44 +1,31 @@ //! Lossless note parsing and the surgical frontmatter/body splices. //! -//! A note is optional YAML frontmatter followed by a Markdown body. To make -//! `parse -> serialize -> parse` byte-identical, a [`ParsedNote`] keeps the **raw text -//! verbatim** and records only the byte spans of the frontmatter; serialization returns -//! the raw bytes. Every mutation here is a surgical splice performed on the human's -//! explicit command (ADR-0004) — appending a `b2_relations:` entry, replacing the body, -//! replacing the frontmatter block. The queryable fields come from a read-only YAML parse -//! and are never used to re-serialize. A `b2id:` key an older B2 left behind is now an -//! ordinary unknown key: never parsed, never rewritten, round-tripped verbatim. +//! A [`ParsedNote`] keeps the raw text verbatim plus the frontmatter's byte spans, so +//! serializing is byte-identical. Every mutation is a splice on an explicit command +//! (ADR-0004). Parsed fields are read-only and never re-serialized. use crate::error::{Error, Result}; use yaml_rust2::{Yaml, YamlLoader}; -/// The frontmatter fields B2 reads: the ones a reader displays (`type`, `created`, -/// `updated`, `tags`) and B2's own `b2_relations:`. Extraction is best-effort: -/// unparseable frontmatter still round-trips (raw is preserved); the fields just come -/// back empty. Every other key — `title`, `description`, `aliases`, your own — is -/// round-tripped verbatim and read by nothing (data-model.md §1). +/// The frontmatter fields B2 reads, best-effort: unparseable YAML just yields empty +/// fields. Every other key round-trips verbatim, read by nothing (data-model.md §1). #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct NoteFields { pub r#type: Option, pub created: Option, pub updated: Option, pub tags: Vec, - /// Raw `b2_relations:` entries (typed-link strings, §2) — B2's namespaced - /// frontmatter home for typed edges, the only place a verb/explanation lives. - /// Parsed into `origin=frontmatter` edges at ingest. (A generic `relations:` - /// key is NOT read — it is another tool's, preserved verbatim like any - /// unknown key; data-model §1.) + /// Raw `b2_relations:` entries (§2), the only home of typed edges. A generic + /// `relations:` key is another tool's and is not read (data-model §1). pub relations: Vec, } /// Byte spans of a frontmatter block within the raw text (fences excluded). #[derive(Debug, Clone, Copy)] struct Frontmatter { - /// First byte of the YAML content (right after the opening `---` line). content_start: usize, - /// First byte of the closing `---` line (YAML content ends here). + /// First byte of the closing `---` line. content_end: usize, - /// First byte of the body (right after the closing `---` line's newline). body_start: usize, } @@ -51,8 +38,7 @@ pub struct ParsedNote { fm_readable: bool, } -/// Parse `raw` into a [`ParsedNote`]. Never fails: text with no/!invalid -/// frontmatter is still represented (and still round-trips). +/// Parse `raw` into a [`ParsedNote`]. Never fails: invalid frontmatter still round-trips. pub fn parse(raw: &str) -> ParsedNote { let fm = detect_frontmatter(raw); let (fields, fm_readable) = match &fm { @@ -68,19 +54,16 @@ pub fn parse(raw: &str) -> ParsedNote { } impl ParsedNote { - /// The note serialized — byte-identical to what was parsed, plus whatever - /// splice the caller asked for. + /// The note serialized: the parsed bytes plus any splices. pub fn as_str(&self) -> &str { &self.raw } - /// The extracted, queryable frontmatter fields. pub fn fields(&self) -> &NoteFields { &self.fields } - /// The body (everything after the frontmatter; the whole text if there is - /// none). This is what gets hashed and, later, chunked. + /// Everything after the frontmatter: what gets hashed and chunked. pub fn body(&self) -> &str { match &self.fm { Some(f) => &self.raw[f.body_start..], @@ -88,32 +71,20 @@ impl ParsedNote { } } - /// The raw frontmatter YAML **verbatim** — the text between the `---` fences - /// (fences excluded), exactly as on disk — or `None` when the note has no - /// frontmatter block. Byte-honest like [`body`](Self::body): the actual bytes, - /// not a re-serialization of the parsed [`fields`](Self::fields), so keys B2 - /// doesn't model (`b2_relations:`, `aliases:`, custom keys) show as written. Powers - /// the Desktop UI's frontmatter drawer (crates/b2-desktop/CLAUDE.md). + /// The frontmatter YAML verbatim, fences excluded, or `None` with no block. Feeds the + /// desktop's frontmatter drawer. pub fn frontmatter(&self) -> Option<&str> { self.fm.map(|f| &self.raw[f.content_start..f.content_end]) } - /// Whether the frontmatter block reads as YAML metadata: `true` for no block, an - /// empty one, or one that parses to a key/value mapping; `false` when it is malformed - /// or isn't a mapping — the raw bytes still round-trip verbatim, but - /// [`fields`](Self::fields) came back empty-handed. Surfaced so an adapter can warn - /// rather than silently show blank metadata (GH #79); never a block, since the bytes - /// stay the human's to fix. + /// Whether the frontmatter reads as a YAML mapping (or is absent/empty). `false` means + /// [`fields`](Self::fields) came back empty, so an adapter can warn (GH #79). pub fn frontmatter_readable(&self) -> bool { self.fm_readable } - /// Replace the note's **body** with `new_body`, verbatim — the byte-honest - /// splice behind `Vault::write`. Everything up to - /// `body_start` (the frontmatter block and its fences — every byte, including - /// keys B2 doesn't model) is preserved *by construction*; a note with no - /// frontmatter is replaced wholesale (its body **is** the file). No newline - /// normalization or trimming: the editor buffer is the user's text. + /// Replace the body with `new_body` verbatim (`Vault::write`). The frontmatter block is + /// preserved byte for byte. No newline normalization: the buffer is the user's text. pub fn replace_body(&mut self, new_body: &str) { match self.fm { Some(f) => { @@ -125,21 +96,13 @@ impl ParsedNote { self.raw.push_str(new_body); } } - // Re-derive spans + fields from the mutated text so state stays exact - // (the same discipline as add_relation/replace_frontmatter; a body could even - // introduce a frontmatter block if it starts with `---`). + // A body starting with `---` could even introduce a frontmatter block. self.reparse(); } - /// Replace the note's **frontmatter YAML** with `new_yaml`, verbatim — the byte-honest - /// splice behind `Vault::write_frontmatter` (GH #79). Only the bytes between the - /// fences change; the fences and every body byte are preserved by construction. A note - /// with no frontmatter gains a block; an empty `new_yaml` removes the block entirely, - /// exactly as a hand-edit deleting it would. The one mechanical touch: a non-empty - /// `new_yaml` missing its final newline gains one. - /// - /// A `new_yaml` containing a top-level `---` line would end the block early and shift - /// the following bytes into the body; the façade op refuses that input before splicing. + /// Replace the frontmatter YAML with `new_yaml` verbatim (`Vault::write_frontmatter`, + /// GH #79). No block gains one; empty `new_yaml` removes it. A missing final newline is + /// added. The façade refuses a top-level `---` line, which would end the block early. pub fn replace_frontmatter(&mut self, new_yaml: &str) { let mut block = new_yaml.to_string(); if !block.is_empty() && !block.ends_with('\n') { @@ -156,12 +119,9 @@ impl ParsedNote { self.reparse(); } - /// Append a typed-link `spec` (e.g. `contradicts [[path|title]] — why`) to the - /// frontmatter `b2_relations:` list, creating the block (or the whole frontmatter) - /// if absent. **Frontmatter only — never the body** (data-model §0). The single - /// surgical insertion preserves every other byte; the new item is YAML-quoted so - /// `[[`, `|`, `:` are safe. Errors on a pre-existing flow-style `b2_relations:` - /// rather than risk corrupting it. + /// Append a typed-link `spec` to `b2_relations:`, creating the list or block if absent. + /// Frontmatter only, never the body (data-model §0). Errors on a flow-style list rather + /// than risk corrupting it. pub fn add_relation(&mut self, spec: &str) -> Result<()> { let quoted = yaml_quote(spec); match self.fm { @@ -180,8 +140,7 @@ impl ParsedNote { Ok(()) } - /// Re-derive spans + fields + readability from the mutated `raw` so state - /// stays exact — every mutator's closing step. + /// Re-derive state from the mutated `raw`; every mutator's last step. fn reparse(&mut self) { let reparsed = parse(&self.raw); self.fm = reparsed.fm; @@ -190,9 +149,7 @@ impl ParsedNote { } } -/// Locate a frontmatter block: an opening `---` line at the very top and the next -/// `---` line. Returns `None` if the first line isn't `---` or no closing fence is -/// found (in which case the whole text is body). +/// Locate a frontmatter block: `---` as the first line, up to the next `---` line. fn detect_frontmatter(raw: &str) -> Option { let first_nl = raw.find('\n')?; if raw[..first_nl].trim_end_matches('\r') != "---" { @@ -229,12 +186,8 @@ fn detect_frontmatter(raw: &str) -> Option { } } -/// Extract the projected fields from a frontmatter region, plus whether the -/// region *read* as YAML metadata (the [`ParsedNote::frontmatter_readable`] -/// flag): `true` for an empty/comments-only block (nothing to read, nothing -/// wrong) or a key/value mapping; `false` when the YAML fails to parse or parses -/// to a non-mapping (a bare scalar, a list) — the fields then come back empty -/// while the raw bytes stay the human's to fix. +/// Extract the fields from a frontmatter region, plus +/// [`ParsedNote::frontmatter_readable`]. fn extract_fields(yaml: &str) -> (NoteFields, bool) { let mut f = NoteFields::default(); let mut readable = false; @@ -251,17 +204,10 @@ fn extract_fields(yaml: &str) -> (NoteFields, bool) { } } } - // GH #75's raw `b2id:` line scan lived here: stamping was gated on the id being - // *definitively* absent, and a parse failure reading as "absent" made ingest - // stamp a fresh line on every pass — the file grew forever and the note's - // identity churned. With nothing stamped (GH #170) there is no write to gate, so - // malformed frontmatter now costs exactly what it should: empty projected fields, - // raw bytes untouched, and a warning through `frontmatter_readable`. (f, readable) } -/// Read a scalar as text, tolerating YAML that typed it as a number/bool (e.g. a -/// date-looking `created:` or a numeric value). +/// A scalar as text, even when YAML typed it as a number or bool. fn scalar_to_string(y: &Yaml) -> Option { match y { Yaml::String(s) => Some(s.clone()), @@ -272,7 +218,6 @@ fn scalar_to_string(y: &Yaml) -> Option { } } -/// Read a YAML sequence of strings; empty for a missing key or a non-sequence. fn string_list(y: &Yaml) -> Vec { match y.as_vec() { Some(items) => items @@ -283,11 +228,8 @@ fn string_list(y: &Yaml) -> Vec { } } -/// Locate where to insert a new `b2_relations:` block item, scanning the frontmatter -/// region. Returns `Some((byte_offset, indent))` to insert `"{indent}- …\n"` (the -/// indent matches existing items, or 2 spaces for a fresh/empty block), or `None` -/// if there is no `b2_relations:` key (the caller then creates one). Errors on a -/// flow-style `b2_relations:` (e.g. `b2_relations: [a, b]`) — not safely appendable. +/// Where to insert a new `b2_relations:` item: `Some((offset, indent))`, or `None` with no +/// key. Errors on a flow-style value, which isn't safely appendable. fn relations_insertion(raw: &str, fm: &Frontmatter) -> Result> { let region = &raw[fm.content_start..fm.content_end]; let mut pos = fm.content_start; @@ -303,7 +245,7 @@ fn relations_insertion(raw: &str, fm: &Frontmatter) -> Result Result Result String { let name = crate::pathspec::file_name(path); match name.get(..name.len().saturating_sub(".md".len())) { @@ -334,10 +272,7 @@ pub fn display_title(path: &str) -> String { } } -/// YAML double-quote a string (escaping `\` and `"`), so a value with `[[`, `|`, -/// `:` (a typed-link spec, or a new note's `title`) is always a safe scalar. -/// `pub(crate)` so [`crate::add`] renders a new note's frontmatter with the same -/// quoting the relation-append path uses. +/// YAML double-quote a string, so `[[`, `|` and `:` are safe. Shared with [`crate::add`]. pub(crate) fn yaml_quote(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } @@ -350,9 +285,6 @@ mod tests { fn frontmatter_returns_raw_yaml_verbatim_between_the_fences() { let raw = "---\nb2id: 01ABC\ntitle: Foo\nb2_relations:\n - references [[x]]\n---\nbody\n"; let note = parse(raw); - // The exact bytes on disk, not a re-serialization — `b2_relations:` (a key - // the projected fields flatten) survives verbatim, and so does the legacy - // `b2id:` line, which since GH #170 is an unknown key like any other. assert_eq!( note.frontmatter(), Some("b2id: 01ABC\ntitle: Foo\nb2_relations:\n - references [[x]]\n") @@ -360,16 +292,13 @@ mod tests { assert_eq!(note.body(), "body\n"); } - /// A vault indexed by an older B2 still has `b2id:` lines in it, and W5 is the - /// whole migration story: the key is not read, not rewritten, not removed — - /// which is what makes "delete `.b2/` and reindex" a complete upgrade. + /// Legacy `b2id:` lines are never read or rewritten (W5, GH #170). #[test] fn a_legacy_b2id_line_is_an_ordinary_unknown_key() { let raw = "---\nb2id: 01JMEM0000000000000000000A\ntype: concept\n---\nThe body.\n"; let mut note = parse(raw); assert_eq!(note.as_str(), raw, "round-trips byte-identically"); assert_eq!(note.fields().r#type.as_deref(), Some("concept")); - // A body save splices only the body, so the line survives that too. note.replace_body("Rewritten.\n"); assert_eq!( note.as_str(), @@ -385,20 +314,15 @@ mod tests { #[test] fn frontmatter_is_some_empty_for_an_empty_block() { - // Fences with nothing between them: the block exists (Some) but is empty — - // distinct from no frontmatter at all (None). let note = parse("---\n---\nbody\n"); assert_eq!(note.frontmatter(), Some("")); } #[test] fn frontmatter_readable_reflects_whether_the_yaml_reads_as_metadata() { - // No block, an empty block, and a mapping all read fine. assert!(parse("just a body\n").frontmatter_readable()); assert!(parse("---\n---\nbody\n").frontmatter_readable()); assert!(parse("---\ntitle: X\ntags: [x]\n---\nbody\n").frontmatter_readable()); - // Malformed YAML and a non-mapping block don't — the fields come back - // empty while the bytes round-trip, so the human should be told. let broken = parse("---\ntitle: \"unclosed\ntags: [a\n---\nbody\n"); assert!(!broken.frontmatter_readable()); assert_eq!(broken.frontmatter(), Some("title: \"unclosed\ntags: [a\n")); @@ -419,8 +343,7 @@ mod tests { #[test] fn replace_frontmatter_adds_the_missing_final_newline() { - // The one mechanical touch: without it the closing fence would join the - // last YAML line and the block would never close. + // Otherwise the closing fence would join the last YAML line. let mut note = parse("---\nb2id: 01A\n---\nbody\n"); note.replace_frontmatter("b2id: 01A\ntitle: X"); assert_eq!(note.as_str(), "---\nb2id: 01A\ntitle: X\n---\nbody\n"); @@ -428,11 +351,9 @@ mod tests { #[test] fn replace_frontmatter_creates_and_removes_the_block() { - // No block + non-empty YAML → fences created around it. let mut note = parse("only a body\n"); note.replace_frontmatter("b2id: 01A\n"); assert_eq!(note.as_str(), "---\nb2id: 01A\n---\nonly a body\n"); - // Non-empty → empty removes the block entirely, fences included. note.replace_frontmatter(""); assert_eq!(note.as_str(), "only a body\n"); assert_eq!(note.frontmatter(), None); @@ -445,12 +366,9 @@ mod tests { "spaced-repetition" ); assert_eq!(display_title("memory.md"), "memory"); - // Case-insensitive extension; nested folders drop away. assert_eq!(display_title("a/b/Read Me.MD"), "Read Me"); - // A dotted stem keeps every dot but the extension. assert_eq!(display_title("notes/2026.07.14-log.md"), "2026.07.14-log"); - // Non-`.md` or extension-only names are returned whole (never used for a - // real note, but the helper must not mangle them). + // Returned whole. assert_eq!(display_title("data.csv"), "data.csv"); assert_eq!(display_title(".md"), ".md"); } diff --git a/crates/b2-core/src/pathspec.rs b/crates/b2-core/src/pathspec.rs index 5d0edcf..024626f 100644 --- a/crates/b2-core/src/pathspec.rs +++ b/crates/b2-core/src/pathspec.rs @@ -1,38 +1,17 @@ -//! Vault-relative path algebra — the one place B2 spells out what a vault path is and how -//! paths relate. A vault path is `/`-separated and relative to the vault root; it is the -//! identity of the note or resource at it (L1, L3), so every op that validates, compares -//! or rewrites one goes through here rather than re-deriving the rules. -//! -//! Three concerns: validating a user-supplied destination ([`normalize_rel`] and its -//! folder/note variants — error-type-free, returning `Err(reason)` so each op maps it onto -//! its own [`crate::Error`] variant); the hidden-path predicate ([`is_hidden`]); and the -//! pure string algebra over paths that are already vault-relative ([`file_name`], -//! [`parent_dir`], [`extension`], [`is_under`], [`rebase`], [`relativize`]). +//! Vault-relative path algebra. A vault path is `/`-separated, relative to the root, and +//! the identity of what it names (L1, L3), so every op validates and compares paths here. +//! Validators return `Err(reason)` for each op to map onto its own [`crate::Error`]. -/// Whether this entry's *name* is dot-prefixed — the hidden-path predicate shared by the -/// folder walk, the ingest walk, and [`normalize_rel`]. **Hidden means hidden** (GH #136): -/// a dot-prefixed name is not vault material whatever its extension, so a `.scratch.md` -/// is skipped exactly as `.DS_Store` is. -/// -/// Asked of the name's **bytes**, not a decoded `&str`: `to_str` answers `None` for a name -/// UTF-8 rejects, which would make `.draft-\xFF.md` *not hidden* and route it to the note -/// collector, where the lossy path fails to reopen and surfaces as a bogus "file no longer -/// exists" skip. A leading `.` is ASCII and `OsStr`'s encoding is ASCII-compatible, so the -/// byte test is exact on every platform. +/// Whether the entry's name is dot-prefixed, whatever its extension (GH #136). Tested on +/// bytes, not `to_str`, so a non-UTF-8 name like `.draft-\xFF.md` is still hidden. pub(crate) fn is_hidden(path: &std::path::Path) -> bool { path.file_name() .is_some_and(|n| n.as_encoded_bytes().starts_with(b".")) } -/// Normalize + validate `input` into a vault-relative path of any file kind: trim, switch -/// backslashes to `/` (the index keeps one separator convention), and reject an empty, -/// absolute, vault-escaping or **hidden** path, returning the reason as `Err(String)`. The -/// extension is left exactly as given. -/// -/// The hidden check is [`is_hidden`]'s rule over every segment, and it lives on the base -/// validator rather than the directory variant alone (GH #136): the walk indexes no -/// dot-prefixed member of any kind, so a destination b2 would create and then never see is -/// a silent fs/index desync. +/// Normalize and validate a vault-relative path: trim, `\` → `/`, and reject empty, +/// absolute, escaping or hidden paths. Hidden is refused for every kind, since the walk +/// would never see what b2 created (GH #136). pub(crate) fn normalize_rel(input: &str) -> Result { let s = input.trim().replace('\\', "/"); if s.is_empty() { @@ -52,34 +31,27 @@ pub(crate) fn normalize_rel(input: &str) -> Result { Ok(s) } -/// Normalize + validate `input` into a vault-relative *directory* path — the -/// folder variant of [`normalize_rel`]: same checks, plus a trailing `/` trimmed -/// so `notes/` and `notes` name the same folder. +/// [`normalize_rel`] for a folder: a trailing `/` is trimmed. pub(crate) fn normalize_rel_dir(input: &str) -> Result { normalize_rel(input.trim().trim_end_matches('/')) } -/// Normalize + validate `input` into a vault-relative `.md` path — the note -/// variant of [`normalize_rel`]: same checks, plus `.md` appended if omitted. +/// [`normalize_rel`] for a note: `.md` appended if omitted. pub(crate) fn normalize_rel_md(input: &str) -> Result { let s = normalize_rel(input)?; Ok(if is_md(&s) { s } else { format!("{s}.md") }) } -/// A vault path's last segment — the file (or folder) name. pub(crate) fn file_name(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } -/// A vault path's folder: everything before the last `/`, and `""` at the vault root — -/// the base a note-relative Markdown target is resolved and re-relativized against. +/// A vault path's folder; `""` at the root. pub(crate) fn parent_dir(path: &str) -> &str { path.rsplit_once('/').map_or("", |(dir, _)| dir) } -/// The extension of a vault path's file name, as written (case kept): the text after -/// the name's last `.`. `None` when there is none — no `.`, a trailing `.`, or only a -/// leading one (a dotfile's name is all stem). +/// The file name's extension, case kept. A dotfile's name is all stem. pub(crate) fn extension(path: &str) -> Option<&str> { match file_name(path).rsplit_once('.') { Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => Some(ext), @@ -87,29 +59,25 @@ pub(crate) fn extension(path: &str) -> Option<&str> { } } -/// Whether a vault path names a note by its shape: its file name's extension is `md`, -/// in any case (`Foo.MD` is a note, as the walk classifies it). +/// Whether the extension is `md`, in any case. pub(crate) fn is_md(path: &str) -> bool { extension(path).is_some_and(|e| e.eq_ignore_ascii_case("md")) } -/// Whether `path` lies strictly inside the folder `dir` (at any depth). A folder is not -/// under itself, and a prefix-sharing sibling (`docs2` for `docs`) is not under it. +/// Whether `path` lies strictly inside the folder `dir`, at any depth. pub(crate) fn is_under(path: &str, dir: &str) -> bool { path.strip_prefix(dir) .is_some_and(|rest| rest.starts_with('/')) } -/// `path` carried by moving the folder `from` to `to`: the same place under `to`, or -/// `None` when `path` is not under `from` ([`is_under`]). +/// `path` after moving the folder `from` to `to`; `None` when not under `from`. pub(crate) fn rebase(path: &str, from: &str, to: &str) -> Option { let rest = path.strip_prefix(from)?.strip_prefix('/')?; Some(format!("{to}/{rest}")) } -/// Join a relative `target` onto `base_dir` (both vault-relative, `/`-separated), -/// normalizing `.` and `..` segments. `None` when the target escapes the vault -/// root — such a path can never resolve. The inverse of [`relativize`]. +/// Join a relative `target` onto `base_dir`, normalizing `.` and `..`. `None` when it +/// escapes the vault. The inverse of [`relativize`]. pub(crate) fn join_relative(base_dir: &str, target: &str) -> Option { let mut segments: Vec<&str> = if base_dir.is_empty() { Vec::new() @@ -128,9 +96,7 @@ pub(crate) fn join_relative(base_dir: &str, target: &str) -> Option { (!segments.is_empty()).then(|| segments.join("/")) } -/// The relative path from the folder `base_dir` (`""` = the vault root) to the vault -/// path `to_path`: the shared leading folders dropped, one `..` per remaining `base_dir` -/// segment — the inverse of resolving a note-relative Markdown target. +/// The relative path from folder `base_dir` (`""` = root) to `to_path`. pub(crate) fn relativize(base_dir: &str, to_path: &str) -> String { let base: Vec<&str> = if base_dir.is_empty() { Vec::new() @@ -155,8 +121,6 @@ mod tests { assert_eq!(normalize_rel_md("notes/foo.md").unwrap(), "notes/foo.md"); } - /// An authored `.MD` is already a note path: the check is on the file name's - /// extension in any case, so no second `.md` is stacked on. #[test] fn an_uppercase_md_extension_is_kept_not_doubled() { assert_eq!(normalize_rel_md("Foo.MD").unwrap(), "Foo.MD"); @@ -253,14 +217,7 @@ mod tests { assert!(normalize_rel_dir("../up").is_err()); } - /// A filename is bytes, not text. `to_str` answers `None` for a name UTF-8 rejects, - /// which made `.draft-\xFF.md` read as *not* hidden and routed it into the note - /// collector — where the lossy path names no file, so every pass reported a bogus - /// "file no longer exists" skip. - /// - /// Asserted on the predicate rather than through a real file on purpose: `read_dir` - /// can hand us any bytes the filesystem holds, but APFS refuses to *create* such a - /// name, so a fixture that writes one cannot run on the platform B2 ships on. + /// Tested on the predicate, not a real file: APFS refuses to create such a name. #[cfg(unix)] #[test] fn a_non_utf8_dot_prefixed_name_is_hidden() { @@ -271,14 +228,11 @@ mod tests { let undecodable = |bytes: &[u8]| is_hidden(Path::new(OsStr::from_bytes(bytes))); assert!(undecodable(b"/vault/.draft-\xFF.md")); assert!(undecodable(b"/vault/.\xFF")); - // …and the same bytes without the leading dot stay ordinary vault material, - // so the fix widens what counts as hidden by exactly the leading dot. + // Without the leading dot, not hidden. assert!(!undecodable(b"/vault/draft-\xFF.md")); } - /// GH #136: b2 indexes no dot-prefixed member, so no authoring destination may - /// name one — folder, resource, or note alike. An interior dot (`a.b.md`) is - /// not hidden; only a *leading* one is. + /// GH #136. Only a leading dot hides; an interior one (`a.b.md`) does not. #[test] fn every_destination_form_refuses_a_hidden_segment() { assert!(normalize_rel_dir(".b2").is_err()); diff --git a/crates/b2-core/src/relation.rs b/crates/b2-core/src/relation.rs index 44afab8..2256fdd 100644 --- a/crates/b2-core/src/relation.rs +++ b/crates/b2-core/src/relation.rs @@ -1,14 +1,12 @@ -//! The relation vocabulary (ADR-0010): a closed three-verb stance core — `references` -//! (neutral), `supports` (for), `contradicts` (against) — with display-only inverse -//! labels, plus a tolerated tail kept verbatim. The core encodes the one thing embedding -//! similarity cannot infer: stance. Adding a verb here is the whole change. +//! The relation vocabulary (ADR-0010): a closed stance core (`references`, `supports`, +//! `contradicts`) with display-only inverse labels, plus a tail kept verbatim. Stance is +//! what similarity cannot infer. Adding a verb here is the whole change. /// A core relation verb and its display metadata. pub struct CoreVerb { pub verb: &'static str, - /// The label shown for an *inbound* edge of this type (display only — the - /// edge is stored once, directed). The symmetric verb, `contradicts`, is its own - /// inverse (data-model.md §2). + /// The display label for an inbound edge; edges are stored once, directed + /// (data-model.md §2). pub inverse: &'static str, } @@ -48,24 +46,19 @@ pub fn inverse_label(verb: &str) -> &str { mod tests { use super::*; - /// The core is *closed* and its membership is what `b2 link` validates against - /// (`Vault::link` refuses a non-core verb). A verb silently joining or leaving - /// it would change the typing palette, so pin the set itself. + /// `b2 link` validates against the core, so pin its membership. #[test] fn the_core_is_exactly_the_three_stance_verbs() { let verbs: Vec<&str> = CORE.iter().map(|c| c.verb).collect(); assert_eq!(verbs, vec!["references", "supports", "contradicts"]); assert!(verbs.iter().all(|v| is_core(v))); - // The tolerated tail is stored verbatim but is never *core*. for tail in ["inspired-by", "supersedes", "Supports", ""] { assert!(!is_core(tail), "{tail:?} must not be a core verb"); assert!(core(tail).is_none()); } } - /// Inverse labels are display-only (the edge is stored once, directed): each - /// directed core verb has its own, and a tail verb falls back to itself rather - /// than gaining an invented "-by" form. + /// A tail verb falls back to itself rather than an invented "-by" form. #[test] fn inverse_labels_cover_the_directed_core_and_the_tail() { assert_eq!(inverse_label("references"), "referenced-by"); @@ -73,9 +66,6 @@ mod tests { assert_eq!(inverse_label("inspired-by"), "inspired-by"); } - /// Stance is the one thing embedding similarity cannot infer, and `contradicts` - /// is the only core verb that reads the same from both ends: its inverse is - /// itself, and no other core verb's is. #[test] fn only_contradicts_is_its_own_inverse() { assert_eq!(inverse_label("contradicts"), "contradicts"); diff --git a/crates/b2-core/src/resource.rs b/crates/b2-core/src/resource.rs index 8480264..02d24bd 100644 --- a/crates/b2-core/src/resource.rs +++ b/crates/b2-core/src/resource.rs @@ -1,11 +1,9 @@ //! Resource classification and document-kind dispatch. //! -//! Class is decided by **extension only** — deterministic, no content sniffing, so a -//! mislabeled file degrades gracefully. The table is closed with -//! [`ResourceClass::Binary`] as the total fallback, so *every* file classifies. +//! Class is decided by extension only, no content sniffing. [`ResourceClass::Binary`] is +//! the fallback, so every file classifies. -/// The closed class table (data-model.md §10). Everything that is not a note maps to -/// exactly one of these; `Binary` catches all the rest. +/// The closed class table (data-model.md §10) for everything that is not a note. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResourceClass { Text, @@ -29,10 +27,8 @@ impl ResourceClass { } } - /// Classify a vault-relative path. `None` means the file is a **note** - /// (`.md`) and belongs to the note pipeline, not the resource inventory. - /// Extensions are case-insensitive and read off the file name (a dotted folder - /// lends none); no extension → `Binary`. + /// Classify a vault-relative path; `None` means a note (`.md`). Case-insensitive, + /// read off the file name; no extension is `Binary`. pub fn of_path(path: &str) -> Option { let ext = crate::pathspec::extension(path) .map(str::to_ascii_lowercase) @@ -53,9 +49,8 @@ impl ResourceClass { } } -/// Which arm of the vault an argument names — the pure dispatch rule locked in -/// data-model.md §10, kept in core so the CLI and the desktop -/// can never drift on it. +/// Which arm of the vault an argument names (data-model.md §10). In core so the adapters +/// can't drift. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DocKind { /// A note ref: a path, with or without the `.md`. @@ -64,12 +59,9 @@ pub enum DocKind { Resource, } -/// Dispatch a document reference — an adapter argument or a link target — to the note or -/// resource arm by the reference's own shape, never by DB state: **an extension other than -/// `md` means resource; `.md` or no extension means note.** Extensionless covers the -/// wikilink habit (`concepts/memory`), the only extra form a note ref takes (ADR-0003). -/// Known limit, accepted: an extensionless *file* (`Makefile`) dispatches as a note ref — -/// it is still walked, inventoried, and reachable through surfaces that know its kind. +/// Dispatch a reference by its shape, never DB state: an extension other than `md` is a +/// resource; `.md` or none is a note (wikilinks omit it, ADR-0003). Accepted limit: an +/// extensionless file (`Makefile`) dispatches as a note ref. pub fn doc_kind(arg: &str) -> DocKind { match crate::pathspec::extension(arg) { Some(ext) if !ext.eq_ignore_ascii_case("md") => DocKind::Resource, @@ -83,7 +75,7 @@ mod tests { #[test] fn classification_is_total_and_extension_only() { - // (path, expected) — parameterized over the class table; None = note. + // None = note. let cases: &[(&str, Option)] = &[ ("notes/a.md", None), ("NOTES/A.MD", None), diff --git a/crates/b2-core/src/rm.rs b/crates/b2-core/src/rm.rs index 03c64ea..34963b3 100644 --- a/crates/b2-core/src/rm.rs +++ b/crates/b2-core/src/rm.rs @@ -1,16 +1,9 @@ -//! Delete a note / resource / folder — the destructive complement of [`crate::mv`]. +//! Delete a note, resource or folder: the files leave the disk, the rows leave the +//! index, and inbound links dangle, never rewritten. The result must equal an external +//! `rm` plus a full reindex (S3). //! -//! A delete is "a move minus the destination": the files leave the disk, the projection -//! rows leave the index, and inbound links **dangle** — never rewritten, since there is -//! nothing to rewrite them to — exactly the state an external `rm` plus a full reindex -//! produces. That equivalence is the correctness bar (S3). -//! -//! The single-note projection paths never prune, so every delete runs one pipeline -//! ([`delete_set`]): read the inbound linkers off the graph, remove from disk, drop the -//! rows directly, then re-project the **surviving** linkers — their edges re-derive -//! against the pruned tables and re-key to the dangling edge id a rebuild would derive. -//! Bodies are untouched, so re-projection re-chunks nothing and the ops are -//! **model-free**. +//! Every delete runs [`delete_set`], which re-projects the surviving linkers. Bodies are +//! untouched, so the ops are model-free. use crate::db; use crate::error::{Error, Result}; @@ -22,21 +15,16 @@ use std::fs; use std::io::ErrorKind; use std::path::Path; -/// What [`delete_note`] did: the deleted note's path — its identity (L1) — and the -/// surviving files whose links at it now dangle (sorted, deduped; empty when nothing -/// linked here). +/// What [`delete_note`] did: the deleted path (L1) and the files whose links now dangle. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct DeleteReport { pub path: String, - /// Vault-relative paths of the files whose links at the deleted note now - /// dangle. They are re-projected, never rewritten — the link text stays as - /// authored and surfaces as an unresolved link (GH #12). + /// Files whose links at the deleted note now dangle, sorted. Re-projected, never + /// rewritten (GH #12). pub dangled: Vec, } -/// What [`delete_resource`] did — the resource sibling of [`DeleteReport`]; the two -/// now carry the same fields, since notes and resources share one identity model -/// (L3, data-model.md §10). +/// What [`delete_resource`] did (L3, data-model.md §10). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResourceDeleteReport { pub path: String, @@ -44,24 +32,19 @@ pub struct ResourceDeleteReport { pub dangled: Vec, } -/// What [`delete_dir`] did: the deleted folder, how many **indexed** -/// notes/resources died with it (unindexed files inside are removed too — the -/// whole directory goes), and the surviving files whose links now dangle. +/// What [`delete_dir`] did: the folder, how many indexed notes and resources went with +/// it, and the files whose links now dangle. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct DirDeleteReport { pub dir: String, pub deleted_notes: usize, pub deleted_resources: usize, - /// See [`DeleteReport::dangled`] — only files *outside* the deleted folder - /// (a linker inside it died with the folder). + /// See [`DeleteReport::dangled`]; only files outside the folder. pub dangled: Vec, } -/// Delete the note at `rel`: file off disk, projection rows off the index -/// (chunks/FTS/centroid/outbound edges cascade with the `notes` row), then -/// re-project the inbound linkers so their edges re-dangle. The façade resolved the ref. -/// The note's chunk **vectors** deliberately do not cascade — content-addressed and -/// possibly shared (ADR-0006), they are collected by the whole-vault pass. +/// Delete the note at `rel` (already resolved). Vectors stay for the whole-vault pass +/// to collect, as they may be shared (ADR-0006). pub fn delete_note(ctx: ProjectionCtx, rel: &str) -> Result { let dangled = delete_set(ctx, &[rel], &[], || { remove_file_if_present(&ctx.root.join(rel)) @@ -72,11 +55,7 @@ pub fn delete_note(ctx: ProjectionCtx, rel: &str) -> Result { }) } -/// Delete the resource at `rel` — the note delete minus the identity step: file -/// off disk, inventory row off the index (inbound edges' `dst_resource_path` is -/// `ON DELETE SET NULL`), then re-project the inbound linkers so their edges -/// re-key to the raw-path (dangling) ids a rebuild derives. The façade checked the -/// inventory, as it does for every resource op. +/// Delete the resource at `rel` (already inventory-checked). pub fn delete_resource(ctx: ProjectionCtx, rel: &str) -> Result { let dangled = delete_set(ctx, &[], &[rel], || { remove_file_if_present(&ctx.root.join(rel)) @@ -87,15 +66,10 @@ pub fn delete_resource(ctx: ProjectionCtx, rel: &str) -> Result Result { - // The UI only sends tree-derived paths, so an invalid input (empty, absolute, - // escaping, a dotfolder) is refused as "no such folder" rather than growing a - // delete-specific destination error. + // The UI only sends tree-derived paths, so invalid input reads as "no such folder". let dir = pathspec::normalize_rel_dir(dir_input) .map_err(|_| Error::DirNotFound(dir_input.trim().to_string()))?; let abs = ctx.root.join(&dir); @@ -120,11 +94,8 @@ pub fn delete_dir(ctx: ProjectionCtx, dir_input: &str) -> Result Result<()> { match fs::remove_file(abs) { Ok(()) => Ok(()), diff --git a/crates/b2-core/src/search.rs b/crates/b2-core/src/search.rs index badfec4..4b1035b 100644 --- a/crates/b2-core/src/search.rs +++ b/crates/b2-core/src/search.rs @@ -19,35 +19,24 @@ pub struct Hit { pub note_path: String, /// Higher is better (RRF score for hybrid; negated distance for vector-only). pub score: f64, - /// Which signals ranked this chunk, and how far its vector really sits from - /// the query — the absolute readings RRF discards (invariants.md D2). + /// The absolute readings RRF discards (invariants.md D2). pub provenance: HitProvenance, } -/// Per-hit provenance carried **beside** the fused order, never folded into it -/// (ADR-0015, GH #201). -/// -/// RRF reduces each list to integer ranks and adds reciprocals, which is exactly what -/// makes a fused score read as confidence: rank 1 of a list that should have been empty -/// scores identically to rank 1 of a list full of real matches. These are the absolute -/// signals that reduction throws away. Recording them changes no ordering. +/// Per-hit provenance carried beside the fused order, never folded into it (ADR-0015, +/// GH #201). RRF's ranks can't tell a real rank 1 from one in a list that should have been +/// empty; these absolute signals can. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct HitProvenance { - /// 0-based rank in the BM25 list; `None` = the lexical half never ranked - /// this chunk — the "dense-only hit" a nonsense query serves `limit` of. + /// 0-based rank in the BM25 list; `None` = a dense-only hit. pub bm25_rank: Option, - /// 0-based rank in the dense list; `None` = the vector half never ranked it, - /// or never ran (a projected-but-unembedded vault). + /// 0-based rank in the dense list; `None` = not ranked, or no vectors yet. pub vector_rank: Option, - /// L2 distance from the query vector to this chunk's, in - /// [`db::vector_search`]'s units — `cosine = 1 - d²/2`, the model's vectors - /// being unit-length. `None` whenever `vector_rank` is. + /// L2 distance to the query vector (`cosine = 1 - d²/2` for unit vectors). pub distance: Option, } -/// Build the per-chunk provenance map for a fusion over `(bm25, vector)`, the -/// dense list carrying its distances. Chunks absent from a list simply carry -/// `None` for it — absence *is* the reading. +/// The per-chunk provenance map for a fusion over `(bm25, vector)`. fn provenance_of(bm25: &[i64], vector: &[(i64, f32)]) -> HashMap { let mut map: HashMap = HashMap::new(); for (rank, &id) in bm25.iter().enumerate() { @@ -61,17 +50,11 @@ fn provenance_of(bm25: &[i64], vector: &[(i64, f32)]) -> HashMap], k: usize) -> Vec<(i64, f64)> { let mut scores: HashMap = HashMap::new(); for list in ranked_lists { @@ -94,10 +77,8 @@ pub fn rrf_fuse(ranked_lists: &[Vec], k: usize) -> Vec<(i64, f64)> { out } -/// BM25 keyword search over chunk text -> chunk ids, best first. The raw query is -/// sanitized into a safe FTS5 expression first ([`fts5_query`]): natural-language -/// punctuation is FTS5 *syntax* and would otherwise raise a parse error. A query with -/// no usable terms yields no hits; the vector half still runs. +/// BM25 keyword search over chunk text -> chunk ids, best first. The query is sanitized +/// first ([`fts5_query`]), since punctuation is FTS5 syntax. pub fn keyword_search(conn: &rusqlite::Connection, query: &str, limit: usize) -> Result> { let match_expr = fts5_query(query); if match_expr.is_empty() { @@ -111,20 +92,15 @@ pub fn keyword_search(conn: &rusqlite::Connection, query: &str, limit: usize) -> Ok(rows.collect::>>()?) } -/// Turn arbitrary user text into a safe FTS5 `MATCH` expression: extract -/// alphanumeric terms, wrap each as a double-quoted string literal (so nothing in -/// the input is interpreted as FTS5 operators), and OR them for keyword recall — -/// the vector half supplies semantics, so the keyword half should be forgiving. -/// Returns an empty string when the query has no usable terms. +/// Arbitrary text as a safe FTS5 `MATCH` expression: alphanumeric terms, each quoted, +/// OR-ed for recall. Empty when there are no usable terms. pub fn fts5_query(raw: &str) -> String { let terms: Vec = query_terms(raw).iter().map(|t| quoted(t)).collect(); terms.join(" OR ") } -/// The terms [`fts5_query`] ORs, in query order, before quoting: every maximal -/// alphanumeric run. Factored out because the evidence reading must judge **exactly** -/// the terms the `MATCH` expression searches for — a second tokenizer here would let -/// the two disagree about what the query even said. +/// The terms [`fts5_query`] ORs, unquoted. Shared so the evidence reading judges exactly +/// the terms searched for. pub fn query_terms(raw: &str) -> Vec { raw.split(|c: char| !c.is_alphanumeric()) .filter(|t| !t.is_empty()) @@ -132,78 +108,47 @@ pub fn query_terms(raw: &str) -> Vec { .collect() } -/// One term as a double-quoted FTS5 string literal, so nothing in it is read as -/// an operator. Internal quotes cannot occur (the split drops them) but are -/// doubled defensively regardless. +/// One term as a quoted FTS5 string literal. Quotes can't occur but are doubled anyway. fn quoted(term: &str) -> String { format!("\"{}\"", term.replace('"', "\"\"")) } -/// One query term's lexical reading: the term as written, and its **document -/// frequency** — how many chunks match it alone. +/// One query term and its document frequency. #[derive(Debug, Clone, PartialEq)] pub struct TermEvidence { - /// The term the sanitizer extracted (unstemmed; FTS5 stems it on the way in, - /// so `df` is the stemmed reading of this surface form). + /// The term as written; FTS5 stems it, so `df` is the stemmed reading. pub term: String, - /// Chunks matching this term. `0` = the vault has never seen the word, which - /// is the strongest lexical statement available: not "ranked low", *absent*. + /// Chunks matching this term. `0` = the vault has never seen the word. pub df: usize, } -/// The lexical half's **absolute** reading for one query (ADR-0015) — what the OR-ed -/// `MATCH` knows and then loses. -/// -/// Matching at all is not evidence: [`keyword_search`] ORs every term for recall, so a -/// phrase-shaped query matches on its function words — on the eval corpus the labelled -/// negatives match 68 of 70 chunks through `a` / `to` / `my` alone (GH #201). So neither -/// a raw hit count nor a raw best score is a lexical-anchor test. -/// -/// Document frequency is, and it is kept as a **weight**, not a bin: a term is worth its -/// IDF, so a word in most chunks counts for almost nothing and a word in none counts for -/// the most there is. That keeps the rule working on a vault whose jargon is another -/// vault's rare word (see [`LexicalEvidence::idf`]). +/// The lexical half's absolute reading for one query (ADR-0015). Matching at all is not +/// evidence: OR-ed terms match on function words (GH #201). Each term is weighted by IDF +/// instead, so common words count for almost nothing. #[derive(Debug, Clone, PartialEq)] pub struct LexicalEvidence { - /// Chunks in the index — the scale every term's weight is read against. + /// Chunks in the index. pub chunk_total: usize, /// Every distinct term the query contributed, in first-appearance order. pub terms: Vec, } impl LexicalEvidence { - /// A term's inverse document frequency in this vault: `ln((chunks + 1) / (df + 1))`. - /// - /// This is the whole stopword policy, and it is **continuous** — no ceiling anywhere - /// in it. The `+1`s keep both ends finite: a `df` of zero is the reading this rule - /// most depends on, and an empty vault must not divide by zero. - /// - /// A ceiling was tried first and **failed its transfer check** (GH #201): a "term in - /// <= 10% of chunks is content" rule is scale-free in the vault's *size* but not its - /// *topical concentration*, so on the 15-chunk single-domain fixture `drone` (df 3) - /// and `comb` (df 7) were classed stopwords in a vault about beekeeping and the bar - /// cut 3 of 15 answerable queries. Weighting has no bin to put them on the wrong - /// side of. + /// A term's IDF in this vault: `ln((chunks + 1) / (df + 1))`. The whole stopword policy, + /// continuous by design: a df ceiling failed on topically concentrated vaults (GH #201). + /// The `+1`s keep `df = 0` and an empty vault finite. pub fn idf(&self, df: usize) -> f64 { ((self.chunk_total as f64 + 1.0) / (df as f64 + 1.0)).ln() } - /// How much of the query's **content** the vault actually holds: the share of its - /// total term IDF carried by terms with `df >= 1`. - /// - /// Weighted, not counted, and that is what makes it a content test. Function words - /// weigh nothing, so they neither grant coverage nor dilute it; words the vault has - /// never seen weigh the *most*, so a query is judged mostly on the words that would - /// have mattered. `None` when no term carries any weight at all — that is *no - /// reading*, not a coverage of zero, and the cosine half then decides alone. + /// The share of the query's total IDF carried by terms the vault holds (`df >= 1`). + /// `None` when no term has any weight: no reading, not zero coverage. pub fn term_coverage(&self) -> Option { let total: f64 = self.terms.iter().map(|t| self.idf(t.df)).sum(); if total <= f64::EPSILON { return None; } - // Folded from an explicit `0.0` rather than `.sum()`, whose additive identity - // for `f64` is `-0.0`: an empty present-set is this function's most common - // reading, and a coverage of `-0.0` prints as `-0.00`. + // Not `.sum()`: its `f64` identity is `-0.0`, which prints as `-0.00`. let present: f64 = self .terms .iter() @@ -213,24 +158,14 @@ impl LexicalEvidence { Some(present / total) } - /// Whether the query has a **lexical anchor** — ADR-0015's lexical half. Coverage, - /// not presence: an off-topic query shares one word with almost any vault, so what - /// separates a query the vault has material for is how much of the query's own - /// weight the vault carries. + /// Whether the query has a lexical anchor (ADR-0015): coverage, not mere presence. pub fn anchored(&self, min_term_coverage: f64) -> bool { self.term_coverage().is_some_and(|c| c >= min_term_coverage) } } -/// Read the lexical evidence for `query`: every term's document frequency, over the -/// same sanitized terms [`keyword_search`] searches for. One `count(*)` per distinct -/// term — the probe FTS5 has no cheaper form of without an `fts5vocab` shadow table. -/// -/// **Distinct means distinct to FTS5, not to `str::eq`** (PR #205 review). `chunks_fts` -/// is tokenized, so `Memory`, `memory` and `memories` are one token and match the same -/// chunks; counting them separately would let a query weigh one piece of evidence twice, -/// which changes verdicts rather than numbers (a measured pair straddled the shipped -/// 0.20 bar). So the fold is onto [`fts_tokens`]' reading, which is the index's own. +/// Read the lexical evidence for `query`: one `count(*)` per distinct term. Distinct to +/// FTS5 ([`fts_tokens`]), not `str::eq`, so `Memory`/`memories` count once (PR #205). pub fn lexical_evidence(conn: &rusqlite::Connection, query: &str) -> Result { let chunk_total: usize = conn .query_row("SELECT count(*) FROM chunks", [], |r| r.get::<_, i64>(0))? @@ -241,8 +176,7 @@ pub fn lexical_evidence(conn: &rusqlite::Connection, query: &str) -> Result = Vec::new(); let mut seen: Vec = Vec::new(); for (term, token) in raw.into_iter().zip(tokens) { - // A repeated term is one piece of evidence, not two — and the first spelling is - // shown, since the token is an index-internal form no reader typed. + // Count a repeated token once, showing its first spelling. if seen.contains(&token) { continue; } @@ -260,24 +194,15 @@ pub fn lexical_evidence(conn: &rusqlite::Connection, query: &str) -> Result Result> { if terms.is_empty() { return Ok(Vec::new()); } - // Named for the tokenizer it was built with, so a mid-session `rebuild_fts` - // swap lands on a different table instead of reusing a stale one. + // Named for its tokenizer, so a mid-session swap doesn't reuse a stale table. let tokenizer = db::index_tokenizer(conn)?; let table = format!("b2_qtok_{}", tokenizer.sql().replace(' ', "_")); conn.execute_batch(&format!( @@ -298,8 +223,7 @@ fn fts_tokens(conn: &rusqlite::Connection, terms: &[String]) -> Result = terms.to_vec(); let mut stmt = conn.prepare(&format!( "SELECT doc, term FROM temp.{table}_v ORDER BY doc, offset" @@ -323,36 +247,20 @@ fn fts_tokens(conn: &rusqlite::Connection, terms: &[String]) -> Result Option { let base = model_id.split_once('@').map_or(model_id, |(id, _)| id); match base { @@ -362,69 +270,43 @@ impl EvidenceBar { } } -/// The evidence bar for the shipped model, `BAAI/bge-base-en-v1.5` (ADR-0015). -/// -/// Read from the search evidence bake-off and re-derived on every `make eval` run rather -/// than quoted here — deliberately no numbers in this comment, which is the mistake -/// GH #187 named, where a floor's docstring froze the day's readings. -/// -/// Where the margin lives is worth knowing, though. The lexical half does nearly all the -/// work and the cosine half is a **thin backstop** for what it drops: ask the lexical -/// half for a stricter coverage and the cosine window collapses, because the queries it -/// then has to rescue are exactly the ones with the weakest semantic evidence too. So -/// the safe band is a joint one and the two are placed inside it together, never tuned -/// one at a time. Both sit toward the **serving** side of their windows, because the -/// errors are not symmetric: serving a weak result costs a little trust, and cutting a -/// real one is the tripwire ADR-0015 asserts at zero. +/// The evidence bar for `BAAI/bge-base-en-v1.5` (ADR-0015), re-derived on every `make eval` +/// (no numbers here, GH #187). The two values form a joint band: tune them together, never +/// one at a time. Both lean toward serving, since cutting a real result is the worse error. pub const BGE_BASE_EVIDENCE_BAR: EvidenceBar = EvidenceBar { min_term_coverage: 0.20, min_cos: 0.54, }; -/// The query-level evidence reading behind a search (ADR-0015): the two absolute signals -/// a surface needs to say whether it vouches for anything at all. Assembled by a caller -/// that wants a verdict, from a [`Retrieval`]'s `best_cos` and a [`lexical_evidence`] read. +/// The query-level evidence behind a search (ADR-0015), from a [`Retrieval`]'s `best_cos` +/// and a [`lexical_evidence`] read. #[derive(Debug, Clone, PartialEq)] pub struct QueryEvidence { - /// The lexical half's reading — see [`LexicalEvidence`]. pub lexical: LexicalEvidence, - /// The dense half's — see [`Retrieval::best_cos`]. + /// See [`Retrieval::best_cos`]. pub best_cos: Option, } impl QueryEvidence { - /// ADR-0015's query-level verdict: does the vault hold **positive evidence** for - /// this query — a lexical anchor, *or* semantic proximity clearing `bar`? - /// - /// Two independent signals, which is what keeps this out of the single-population - /// trap GH #196 measured: a topic query in a vault full of that topic carries lexical - /// evidence, semantic evidence, or both, while nonsense carries neither. A - /// one-signal test could not tell *nothing matches* from *everything matches*. + /// Whether the vault holds positive evidence: a lexical anchor or semantic proximity + /// clearing `bar` (ADR-0015). Two independent signals, since one can't tell "nothing + /// matches" from "everything matches" (GH #196). pub fn vouched(&self, bar: EvidenceBar) -> bool { self.lexical.anchored(bar.min_term_coverage) || self.best_cos.is_some_and(|c| c >= bar.min_cos) } } -/// A retrieval's fused order plus the one absolute signal that comes free with it: the -/// dense half's best cosine, the head of a list the call has already produced. -/// -/// The **lexical** half of the evidence is deliberately not here. It costs a `count(*)` -/// per distinct term and is read by [`lexical_evidence`] only where a caller wants a -/// verdict — every search would otherwise pay for a reading nothing consumes, worst on -/// the keyword-only fallback, which has no vector scan to hide the cost behind. +/// A retrieval's fused order plus the dense half's best cosine, which comes free. The +/// lexical evidence costs a query per term, so [`lexical_evidence`] reads it on demand. #[derive(Debug, Clone, PartialEq)] pub struct Retrieval { pub hits: Vec, - /// Best cosine between the query vector and any scanned chunk vector: the - /// dense half's strongest absolute claim about *this* query, as opposed to - /// the rank it always has. `None` on a projected-but-unembedded vault, where - /// there is no dense half and BM25's own emptiness is already honest. + /// Best cosine between the query and any chunk vector. `None` on an unembedded vault. pub best_cos: Option, } impl Retrieval { - /// No hits and no dense reading: what a zero `limit` asks for. fn empty() -> Self { Self { hits: Vec::new(), @@ -433,28 +315,15 @@ impl Retrieval { } } -/// How wide a pool to pull from each signal before fusing (qmd keeps ~30). -/// -/// `pub(crate)` because a *measurement* needs it: the façade's two candidate-pool -/// helpers compose it with each view's headroom to state the total per-signal depth -/// that view reaches (GH #141). It is this 5x multiplication that makes one unit of -/// façade headroom cost five candidates, and so makes headroom a **ranking** choice -/// rather than a plumbing one (GH #142). -/// -/// Saturating, like the façade's own widenings: `limit` is user input and the two -/// widenings compose, so a caller can reach a product that overflows `usize` — which -/// release builds *wrap*, turning an absurd ask into a silently tiny pool. Saturation -/// degrades honestly: an unreachable pool is just every candidate there is. +/// How wide a pool to pull from each signal before fusing (qmd keeps ~30). `pub(crate)` +/// for the façade's candidate-pool measurements (GH #141, #142). Saturating: `limit` is +/// user input, and a wrapped product would silently shrink the pool. pub(crate) fn pool_size(limit: usize) -> usize { limit.saturating_mul(5).max(30) } -/// Keyword-only search: BM25 over `chunks_fts`, resolved to notes — the fallback that -/// makes a **projected-but-unembedded** vault searchable with no query embedding and no -/// model. Scores are the RRF of the single BM25 list, so they live on the same scale as -/// [`hybrid_search`]'s. This path was always honest about zero, and the [`Retrieval`] -/// says so with `best_cos: None`: there is no dense half here to overrule the lexical -/// half's silence (ADR-0015). +/// Keyword-only search, the fallback for an unembedded vault. Scores are single-list RRF, +/// on [`hybrid_search`]'s scale; `best_cos` is `None` (ADR-0015). pub fn keyword_only_search( conn: &rusqlite::Connection, query: &str, @@ -475,9 +344,7 @@ pub fn keyword_only_search( } /// Hybrid search: BM25 ⊕ vector(query) -> RRF -> top `limit`, resolved to notes -/// (ADR-0008). A `limit` of 0 returns before the query is embedded — with the real model -/// that call is the expensive part, and asking for no results should cost none. The same -/// guard opens [`keyword_only_search`]. +/// (ADR-0008). A `limit` of 0 returns before the costly query embedding. pub fn hybrid_search( conn: &rusqlite::Connection, embedder: &dyn Embedder, @@ -500,10 +367,8 @@ pub fn hybrid_search( fuse(conn, bm25, &dense, limit) } -/// The shared tail of both retrievals: fuse the BM25 list with the dense one (if any) -/// by RRF, keep each chunk's provenance, resolve the first `limit` to notes, and carry -/// the dense half's absolute reading. With no dense list this is the single-list RRF -/// the keyword-only fallback has always served, and `best_cos` is `None`. +/// The shared tail of both retrievals: RRF-fuse, keep provenance, resolve the first +/// `limit` to notes. Dense goes last, so it breaks ties. fn fuse( conn: &rusqlite::Connection, bm25: Vec, @@ -511,8 +376,7 @@ fn fuse( limit: usize, ) -> Result { let provenance = provenance_of(&bm25, dense); - // The dense list is nearest-first, so its head *is* the best cosine — the absolute - // reading RRF is about to reduce to "rank 0". + // The dense list is nearest-first, so its head is the best cosine. let best_cos = dense.first().map(|&(_, d)| cosine_of_distance(d)); let mut lists = vec![bm25]; if !dense.is_empty() { @@ -524,23 +388,16 @@ fn fuse( }) } -/// Cosine similarity from an L2 distance between **unit** vectors: `cos = 1 - d²/2`. -/// bge normalizes its output, so this is an identity rather than an approximation — and -/// it is what puts the dense half's absolute reading on the one scale a bar can be -/// written in. +/// Cosine from an L2 distance between unit vectors: `cos = 1 - d²/2` (exact, as bge +/// normalizes its output). pub fn cosine_of_distance(distance: f32) -> f64 { let d = distance as f64; 1.0 - (d * d) / 2.0 } -/// Vector-only search: the dense half of [`hybrid_search`] alone. -/// -/// **An ablation instrument, not a product surface** (ADR-0013, GH #158): the eval scores -/// it beside bm25-only and hybrid on every run, so fusion has a measured single-signal -/// baseline to answer to. Scores are negated L2 distance (closer = higher), the same -/// convention as discovery's, and **not** commensurable with RRF scores. A -/// vault with no embedding space yields no hits — there are no vectors to scan, and -/// pretending otherwise would rank on nothing. +/// Vector-only search: the dense half of [`hybrid_search`] alone. An eval ablation +/// instrument, not a product surface (ADR-0013, GH #158). Scores are negated L2 distance, +/// not comparable with RRF scores. pub fn vector_only_search( conn: &rusqlite::Connection, embedder: &dyn Embedder, @@ -566,16 +423,9 @@ pub fn vector_only_search( resolve_hits(conn, ranked, &provenance, limit) } -/// The shared tail of the retrieval entry points: resolve the ranked -/// `(chunk_id, score)` list to [`Hit`]s, best first, keeping the first `limit` **that -/// still resolve** to a note. -/// -/// One pass over the whole ranking, stopping at `limit` — an unresolved chunk is skipped -/// over, never charged against the budget (GH #137). A chunk can only fail to resolve -/// when a concurrent reindex replaced its note's chunks mid-query, the posture C1 -/// promises. Taking the top `limit` *first* would let a dead chunk hold a slot a live -/// candidate could fill, so search would quietly under-fill during that window; steady -/// state is unchanged. Per-hit resolution is fine here because the loop stops at `limit`. +/// Resolve a ranked `(chunk_id, score)` list to the first `limit` [`Hit`]s that still +/// resolve to a note. An unresolved chunk (a concurrent reindex, C1) is skipped, not +/// charged against `limit` (GH #137). fn resolve_hits( conn: &rusqlite::Connection, fused: Vec<(i64, f64)>, @@ -584,9 +434,7 @@ fn resolve_hits( ) -> Result> { let mut hits = Vec::new(); for (chunk_id, score) in fused { - // Tested *before* the push: an after-the-push test can never fire at a limit - // the loop starts below (`limit == 0`), which would turn "give me nothing" into - // "give me the whole ranking". + // Before the push, so `limit == 0` returns nothing. if hits.len() == limit { break; } diff --git a/crates/b2-core/src/snippet.rs b/crates/b2-core/src/snippet.rs index 1f599f3..c2261e2 100644 --- a/crates/b2-core/src/snippet.rs +++ b/crates/b2-core/src/snippet.rs @@ -19,24 +19,20 @@ fn head_snippet(flat: &str) -> String { } } -/// Collapse a chunk's text to a single-line, length-bounded snippet (its head). Used -/// where there is no query to center on (e.g. `similar`'s evidence passage). +/// A chunk's head as a single-line, length-bounded snippet, where there is no query. pub(crate) fn snippet(text: &str) -> String { head_snippet(&flatten(text)) } -/// Like [`snippet`] but windows the excerpt around the first query-term match, so a -/// section-sized chunk still surfaces the matched text instead of only its head. -/// Falls back to the head when no term matches or the match is already in view — a -/// pure vector hit keeps the head. +/// Like [`snippet`] but windowed around the first query-term match. Falls back to the +/// head when nothing matches or the match is already in view. pub(crate) fn query_snippet(text: &str, query: &str) -> String { let flat = flatten(text); if flat.chars().count() <= SNIPPET_CHARS { return flat; } let lower = flat.to_lowercase(); - // The same tokenizer the lexical half matched with, so the window lands on a term - // that actually ranked the hit. + // The lexical half's tokenizer, so the window lands on a term that ranked the hit. let match_pos = crate::search::query_terms(query) .iter() .filter(|t| t.chars().count() >= 2) @@ -51,8 +47,7 @@ pub(crate) fn query_snippet(text: &str, query: &str) -> String { return head_snippet(&flat); }; let chars: Vec = flat.chars().collect(); - // `pos` indexes the lowercased text, whose length can differ from `flat` for - // exotic Unicode; clamp so the slice below can never go out of range. + // Lowercasing can change length for exotic Unicode; clamp so the slice stays in range. let start = (pos - LEAD).min(chars.len()); let end = (start + SNIPPET_CHARS).min(chars.len()); let mut out = String::from("…"); diff --git a/crates/b2-core/src/vault.rs b/crates/b2-core/src/vault.rs index 0955449..7e59086 100644 --- a/crates/b2-core/src/vault.rs +++ b/crates/b2-core/src/vault.rs @@ -1,13 +1,10 @@ -//! The `Vault` façade — B2's one typed core API and the only surface the two dumb -//! adapters call (ADR-0012). It owns the connection and the injected embedder, and -//! exposes only what the shipped commands need; add an operation when a command -//! needs it, never speculatively. +//! The `Vault` façade: B2's one typed core API and the only surface the adapters call +//! (ADR-0012). It owns the connection and the injected embedder. Add an operation when a +//! command needs it, never speculatively. //! -//! A vault is one portable folder: the index lives under `/.b2/` (ADR-0002). -//! [`open`](Vault::open) defaults to the deterministic [`FakeEmbedder`]; -//! [`open_with_embedder`](Vault::open_with_embedder) wires the real model -//! (ADR-0005). Under the fake, `search`'s BM25 half is still real but the vector -//! half is not semantic — callers must not overstate it. +//! The index lives under `/.b2/` (ADR-0002). [`open`](Vault::open) uses the +//! deterministic [`FakeEmbedder`], whose vector half is not semantic; +//! [`open_with_embedder`](Vault::open_with_embedder) wires the real model (ADR-0005). use crate::add; use crate::chunk::ChunkConfig; @@ -34,8 +31,7 @@ mod views; pub use views::*; -// Report types the façade returns — re-exported so adapters name them through the -// one typed contract. +// Report types the façade returns, re-exported for the adapters. pub use crate::add::AddReport; pub use crate::dirs::DirCreateReport; pub use crate::import::ImportReport; @@ -48,51 +44,33 @@ pub use crate::rm::{DeleteReport, DirDeleteReport, ResourceDeleteReport}; /// brings its own (768); a model or dim swap re-embeds on `reindex` (ADR-0007). const EMBED_DIM: usize = 64; -/// Headroom [`Vault::search_chunks`] keeps over `limit`. A small constant, because -/// façade headroom is multiplied: each unit buys [`search::pool_size`] more -/// candidates from each signal, and candidate width changes answers (GH #142). +/// Headroom [`Vault::search_chunks`] keeps over `limit`. Small, because each unit buys +/// [`search::pool_size`] more candidates per signal, and width changes answers (GH #142). const TORN_READ_HEADROOM: usize = 2; -/// [`Vault::search`]'s hit pool: **3×**, and load-bearing. Its results are -/// note-level, so chunks sharing a note collapse onto that note's best one; without -/// the headroom an ordinary query under-fills `limit` distinct notes. The same -/// headroom absorbs a chunk whose note row vanished mid-query (the -/// concurrent-reindex window C1 allows). Dedup scales with `limit`, so this does too. +/// [`Vault::search`]'s hit pool: 3× `limit`. Chunks of one note collapse to its best, +/// so without headroom a query under-fills `limit` notes. Also absorbs a note row +/// vanishing mid-query (C1). fn note_hit_pool(limit: usize) -> usize { limit.saturating_mul(3) } -/// [`Vault::search_chunks`]'s hit pool: `limit` + [`TORN_READ_HEADROOM`]. No dedup -/// here — this is the un-deduped passage view — so the only hit it drops is one -/// whose `chunk_detail` lookup missed on a torn read, a bounded window a constant -/// covers (GH #137). -/// -/// Deliberately *not* `search`'s 3× (GH #142): RRF scores `Σ 1/(k + rank + 1)`, so a -/// wider pool returns different answers, not merely more of them — 10 of 10 probes -/// changed their top-4 passages across exactly that step (`make stability`). Wider -/// may well be better; the labelled corpus is too small to price it (GH #141), so -/// width stays at the conservative setting until an eval can (ADR-0013). +/// [`Vault::search_chunks`]'s hit pool: `limit` + [`TORN_READ_HEADROOM`], covering only +/// torn reads (GH #137). Not 3×: under RRF a wider pool changes answers, and the eval +/// cannot yet price that (GH #141, #142, ADR-0013). fn chunk_hit_pool(limit: usize) -> usize { limit.saturating_add(TORN_READ_HEADROOM) } -/// Candidates **each retrieval signal** pulls for a `limit`-sized -/// [`Vault::search`]: the façade asks retrieval for [`note_hit_pool`] hits, and each -/// signal widens that again before the two lists are fused (ADR-0008). -/// -/// Public because a *measurement* needs it. A corpus with no more chunks than this -/// truncates neither candidate list, so its scores are invariant under candidate -/// width — a pool change reads as "no change" there while moving real-vault results. -/// The eval harness prints that blindness rather than let a reader trust a number -/// that could not have moved (GH #141). ([`search::RRF_K`] re-weights the *same* -/// lists, so it reorders even a tiny corpus.) +/// Candidates each retrieval signal pulls for a `limit`-sized [`Vault::search`] +/// (ADR-0008). Public for the eval: a corpus smaller than this is blind to pool-width +/// changes, and the harness says so (GH #141). pub fn note_candidate_pool(limit: usize) -> usize { search::pool_size(note_hit_pool(limit)) } -/// [`note_candidate_pool`] for the passage view, over [`chunk_hit_pool`]. Always -/// the narrower of the two, which makes it the threshold a *blindness* claim must -/// clear: under it, no number a run prints can move with candidate width (GH #141). +/// [`note_candidate_pool`] for the passage view. The narrower of the two, so the +/// threshold for the blindness claim (GH #141). pub fn chunk_candidate_pool(limit: usize) -> usize { search::pool_size(chunk_hit_pool(limit)) } @@ -102,36 +80,27 @@ pub fn chunk_candidate_pool(limit: usize) -> usize { pub struct Vault { root: PathBuf, conn: Connection, - /// Injected through the seam (ADR-0005): the adapters wire the real model, - /// `open` defaults to `FakeEmbedder`. + /// Injected through the seam (ADR-0005). embedder: Box, - /// The vault's one chunking policy, held here rather than re-defaulted per - /// call, so every path that chunks cuts identically and `incremental ≡ full - /// rebuild` holds by construction. Across a `set_chunk_config` change that - /// guarantee is doc-enforced instead: the change must pair with - /// `project(force)`. The retrieval eval is the only client that overrides it. + /// The one chunking policy, held here so every path cuts identically and + /// incremental ≡ full rebuild. chunk_config: ChunkConfig, } impl Vault { - /// Open the vault rooted at `vault_root` with the deterministic [`FakeEmbedder`] - /// — the default for tests/dev. Creating `/.b2/` if absent; idempotent. + /// Open the vault at `vault_root` with the deterministic [`FakeEmbedder`], creating + /// `/.b2/` if absent. pub fn open(vault_root: &Path) -> Result { Self::open_with_embedder(vault_root, Box::new(FakeEmbedder::new(EMBED_DIM))) } - /// Open the vault with a caller-supplied embedder — the seam the adapters use to - /// inject the real candle model while tests keep the fake. + /// Open the vault with a caller-supplied embedder. /// - /// `open` **never mutates the embedding space** (ADR-0007): shaping the vector - /// tables and any re-embed happen only on `reindex`, so changing the configured - /// model can never silently wipe vectors on the next command. `search` fails - /// fast on a mismatch instead. + /// Never mutates the embedding space (ADR-0007): only `reindex` does, so a model + /// change can't silently wipe vectors. `search` fails fast on a mismatch instead. pub fn open_with_embedder(vault_root: &Path, embedder: Box) -> Result { - // Every façade op opens a `tracing` span (target `b2::vault`), so each op - // reports its own duration and the per-query `b2::sqlite` events carry which - // op they ran under. Inert until an adapter installs a subscriber, so the - // core still reads no clock of its own. + // Every façade op opens a `b2::vault` span, so `b2::sqlite` events carry their + // op. Inert until an adapter installs a subscriber, so the core reads no clock. let _op = tracing::debug_span!(target: "b2::vault", "open").entered(); // `Connection::open` creates the DB file but not its parent. fs::create_dir_all(vault_root.join(".b2"))?; @@ -144,63 +113,40 @@ impl Vault { }) } - /// The vault's **projection context** — the `(conn, root, cfg)` bundle every - /// write-side op threads, built once here rather than per call site (GH #134). - /// Handing an op this rather than [`embed_ctx`](Self::embed_ctx) is what *makes* - /// it model-free: it carries no embedder, so `rm`/`create_note`/`write` cannot - /// embed even by mistake. + /// The projection context every write-side op threads (GH #134). It carries no + /// embedder, so an op given this cannot embed. fn ctx(&self) -> ingest::ProjectionCtx<'_> { ingest::ProjectionCtx::new(&self.conn, &self.root, &self.chunk_config) } - /// [`ctx`](Self::ctx) plus the injected embedder — for the ops that re-embed what - /// they touch (`reindex`, `add`, `link`, `mv`). + /// [`ctx`](Self::ctx) plus the embedder, for ops that re-embed what they touch. fn embed_ctx(&self) -> ingest::EmbedCtx<'_> { ingest::EmbedCtx::new(self.ctx(), self.embedder.as_ref()) } - /// Override the vault's chunking policy. Every subsequent op that chunks cuts - /// with it, so the index stays self-consistent. The client is the out-of-CI - /// retrieval eval (ADR-0013), which sweeps chunker levers in one process; the - /// shipped adapters never call it. It does not re-chunk by itself — pair it with - /// `project(force)`. + /// Override the chunking policy, for the retrieval eval (ADR-0013). Does not + /// re-chunk: pair it with `project(force)`. pub fn set_chunk_config(&mut self, cfg: ChunkConfig) { self.chunk_config = cfg; } - /// Rebuild the FTS index over the **same stored chunk text** with a different - /// tokenizer — the eval harness's lexical-half lever (ADR-0013), not an adapter - /// surface, so the GH #157 stemmer A/B can flip it without re-chunking or - /// re-embedding. The choice is recorded nowhere durable: the index is disposable, - /// and a `reindex` into a fresh `.b2/` restores the shipped default. + /// Rebuild the FTS index with a different tokenizer: the eval's lexical lever + /// (ADR-0013, GH #157). Not recorded; a fresh `.b2/` restores the default. pub fn rebuild_fts(&self, tokenizer: db::FtsTokenizer) -> Result<()> { db::rebuild_fts(&self.conn, tokenizer) } - /// Re-project every `.md` note under the vault root into the index (Flow ①): - /// notes, chunks (+embeddings), and the typed graph. Writes nothing to the vault. - /// **Incremental** — a note whose body is unchanged reuses its vectors rather - /// than re-embedding (see [`reindex_with_progress`](Self::reindex_with_progress) - /// to force a full re-embed or observe progress). + /// Re-project every note into the index and embed it (Flow ①). Incremental; writes + /// nothing to the vault. pub fn reindex(&self) -> Result { self.reindex_with_progress(false, &mut |_| ControlFlow::Continue(())) } - /// [`reindex`](Self::reindex) with the three knobs its adapters need: `force` - /// re-chunks every note even if unchanged; `on_progress` fires after each embed - /// batch so a slow reindex shows a live line instead of looking frozen; and - /// returning [`ControlFlow::Break`] from it **cooperatively cancels** the embed - /// phase at that batch boundary while projection still completes, leaving a - /// consistent, resumable index ([`ReindexReport::cancelled`]). The desktop maps - /// its cancel flag to `Break`; the CLI always returns `Continue`. + /// [`reindex`](Self::reindex) with knobs: `force` re-chunks every note; `on_progress` + /// fires after each embed batch, and returning [`ControlFlow::Break`] cancels the + /// embed phase there, leaving a consistent, resumable index. /// - /// **`force` re-chunks; whether it re-*embeds* is content's to decide.** Vectors - /// are keyed by chunk text (ADR-0006), so forcing a rebuild over unchanged notes - /// finds every vector already stored and reports `embedded: 0` truthfully. Where - /// `force` is actually reached for — a chunker-policy change — the chunk text - /// moves, the hashes miss, and the model runs on exactly what changed. It no - /// longer repairs a *damaged* stored vector; the index is disposable, so deleting - /// `.b2/` is the answer there. + /// `force` re-embeds only chunk text that changed (ADR-0006). pub fn reindex_with_progress( &self, force: bool, @@ -228,13 +174,9 @@ impl Vault { }) } - /// The **projection pass** alone: re-project every `.md` note into - /// `notes`/`chunks`(+FTS)/`edges` with **no model and no vector work**, and no - /// write to the vault. After it returns the file tree lists, notes open, keyword - /// search answers and the graph resolves; only vectors — and thus `similar` and - /// semantic ranking — wait for [`embed`](Self::embed). `force` re-chunks every - /// note, so `project(force)` + `embed` is a full rebuild, costing model calls - /// only where chunk text genuinely moved (ADR-0006). + /// The projection pass alone: model-free, no vault write. Everything but vectors + /// works afterwards; those wait for [`embed`](Self::embed). `force` re-chunks every + /// note. pub fn project(&self, force: bool) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "project", force).entered(); let outcome = ingest::project_vault(self.ctx(), force)?; @@ -247,13 +189,9 @@ impl Vault { }) } - /// The **embed pass** alone: fill a vector for every chunk that lacks one. The - /// pending set is derived from the index itself, so this needs no prior - /// [`project`](Self::project) call in the same process and heals any interruption - /// — a cancelled embed, a crash between passes — by embedding exactly what is - /// still missing. Progress and cooperative cancel behave as in - /// [`reindex_with_progress`](Self::reindex_with_progress), and progress is - /// determinate from the first batch. + /// The embed pass alone: fill a vector for every chunk that lacks one. The pending + /// set comes from the index, so any interruption heals here. Progress and cancel as + /// in [`reindex_with_progress`](Self::reindex_with_progress). pub fn embed( &self, on_progress: &mut dyn FnMut(ingest::ReindexProgress) -> ControlFlow<()>, @@ -266,10 +204,7 @@ impl Vault { }) } - /// Preview a reindex (`reindex --dry-run`): what [`reindex`](Self::reindex) - /// **would** index and (re)embed, with no write of any kind — not to the - /// Markdown, the index, or the vectors. `force` previews a re-chunk of every - /// note. A pure read, so it needs no model. + /// Preview a reindex (`reindex --dry-run`) with no write of any kind. Model-free. pub fn plan_reindex(&self, force: bool) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "plan_reindex", force).entered(); let plan = ingest::plan_reindex(&self.conn, &self.root, force)?; @@ -279,19 +214,15 @@ impl Vault { }) } - /// Active neighbors of the note referenced by `note_ref` (a path, with or - /// without the `.md`), each resolved to the other note's path + title. Errors - /// with [`Error::NoteNotFound`] for an unknown ref — distinct from a found note - /// with no neighbors, which is an empty list. + /// Active neighbors of `note_ref` (a path, `.md` optional). + /// [`Error::NoteNotFound`] for an unknown ref. pub fn neighbors(&self, note_ref: &str) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "neighbors", note = note_ref).entered(); let path = self.resolve_ref(note_ref)?; self.neighbors_of(&path) } - /// The active neighbors of an already-resolved path. Shared by - /// [`neighbors`](Self::neighbors) and [`explain`](Self::explain), so the two - /// present the same edge shape. + /// The active neighbors of an already-resolved path. fn neighbors_of(&self, note_path: &str) -> Result> { let mut out = Vec::new(); for n in graph::neighbors(&self.conn, note_path)? { @@ -314,8 +245,7 @@ impl Vault { Ok(out) } - /// The resource links of an already-resolved path — every outbound edge at a - /// non-`.md` file, resolved with its inventory class for display (GH #22). + /// The outbound resource links of an already-resolved path (GH #22). fn resource_links_of(&self, note_path: &str) -> Result> { Ok(db::outbound_resource_edges(&self.conn, note_path)? .into_iter() @@ -331,8 +261,7 @@ impl Vault { .collect()) } - /// The unresolved (dangling) outbound links of an already-resolved path, shared - /// by [`explain`](Self::explain) and [`unresolved_links`](Self::unresolved_links). + /// The dangling outbound links of an already-resolved path. fn unresolved_of(&self, note_path: &str) -> Result> { Ok(graph::unresolved_outbound(&self.conn, note_path)? .into_iter() @@ -345,10 +274,8 @@ impl Vault { .collect()) } - /// The unresolved (dangling) outbound links of `note_ref`: links it authored - /// that resolve to no note and no resource — a `[[Hermes]]` naming a *folder*, or - /// a typo (GH #12). A pure graph read; [`Error::NoteNotFound`] for an unknown - /// ref, an empty list for a note whose every link resolves. + /// The dangling outbound links of `note_ref`: links resolving to no note or + /// resource, such as a folder name or a typo (GH #12). pub fn unresolved_links(&self, note_ref: &str) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "unresolved_links", note = note_ref) .entered(); @@ -356,11 +283,8 @@ impl Vault { self.unresolved_of(&path) } - /// Explain a note's connections (`b2 explain`): the note resolved to its - /// identity + title, every active typed edge and its "why", its outbound - /// **resource** links (GH #22), and any **unresolved** outbound links — surfaced, - /// not dropped (GH #12). A pure graph read; [`Error::NoteNotFound`] for an - /// unknown ref, empty vectors for a note with no edges. + /// Explain a note's connections (`b2 explain`): typed edges with their "why", + /// resource links (GH #22) and unresolved links (GH #12). pub fn explain(&self, note_ref: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "explain", note = note_ref).entered(); let path = self.resolve_ref(note_ref)?; @@ -377,11 +301,8 @@ impl Vault { }) } - /// Read a note for display: resolve `note_ref` to its file and return the note's - /// **raw Markdown body from disk** (the source of truth, not the index - /// projection) plus the frontmatter metadata worth showing a reader. A pure, - /// model-free read, and ref resolution lives here so the adapter never touches - /// the filesystem itself. [`Error::NoteNotFound`] for an unknown ref. + /// Read a note for display: the raw Markdown from disk (not the index) plus its + /// frontmatter metadata. [`Error::NoteNotFound`] for an unknown ref. pub fn read(&self, note_ref: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "read", note = note_ref).entered(); let path = self.resolve_ref(note_ref)?; @@ -389,8 +310,8 @@ impl Vault { let revision = revision_of(&raw); let parsed = note::parse(&raw); let fields = parsed.fields(); - // The display title is the filename (the frontmatter `title:` is inert), - // derived from the path here so even a not-yet-reindexed note shows one. + // The title is the filename (frontmatter `title:` is inert), so even an + // unindexed note has one. let title = Some(note::display_title(&path)); Ok(NoteView { path, @@ -406,40 +327,25 @@ impl Vault { }) } - /// Save a note's **body** — the editing surface's body write op - /// ([`write_frontmatter`](Self::write_frontmatter) is its frontmatter sibling). - /// Markdown-first and **model-free**: validate that the file on disk still hashes - /// to `base_revision` (else [`Error::WriteConflict`] and nothing is written), - /// splice `body` in verbatim after the untouched frontmatter, write, and - /// re-project the note. A changed body's stale vectors are cleared and join the - /// pending set for any later [`embed`](Self::embed), so saving works with no - /// model provisioned. + /// Save a note's body, model-free. Refuses with [`Error::WriteConflict`] unless the + /// file still hashes to `base_revision`; frontmatter bytes are untouched. /// - /// Returns the **new revision**, hashing the *final* on-disk bytes — which the - /// editor chains its next save on: sequential saves never self-conflict, and only - /// an external write trips the guard. + /// Returns the new revision of the on-disk bytes, which the editor chains its next + /// save on, so only an external write trips the guard. pub fn write(&self, note_ref: &str, body: &str, base_revision: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "write", note = note_ref).entered(); - // The byte-honest splice: frontmatter bytes untouched. self.save_guarded(note_ref, base_revision, |parsed| { parsed.replace_body(body); Ok(()) }) } - /// Save a note's **frontmatter** — [`write`](Self::write)'s sibling (GH #79), - /// with the same shape: validate the content-hash `base_revision`, splice - /// `frontmatter` in verbatim between the fences (every body byte preserved by - /// construction), write, re-project **model-free**. An unchanged body keeps its - /// chunks and vectors, so a frontmatter save never re-embeds. + /// Save a note's frontmatter verbatim: [`write`](Self::write)'s sibling (GH #79). + /// Body bytes are untouched, so it never re-embeds. /// - /// **One** refusal, before any byte reaches disk: a top-level `---` line - /// ([`Error::Frontmatter`]) would close the block early and shift the rest into - /// the body, and the body is not this op's to change. Everything else in the - /// block is the human's: malformed YAML saves fine — it round-trips verbatim, - /// projects best-effort, and surfaces through - /// [`NoteView::frontmatter_readable`], exactly as the same edit made in an - /// external editor would. + /// The one refusal is a `---` line ([`Error::Frontmatter`]), which would end the + /// block early. Malformed YAML saves fine and surfaces through + /// [`NoteView::frontmatter_readable`]. pub fn write_frontmatter( &self, note_ref: &str, @@ -462,13 +368,9 @@ impl Vault { }) } - /// The shared shape of the two editor saves: resolve, check that the file on disk - /// still hashes to `base_revision` (else [`Error::WriteConflict`], nothing written), - /// apply `splice` (which may refuse, also before any byte reaches disk), write, and - /// re-project **model-free** through the ordinary path. - /// - /// The returned revision is read back from disk rather than assumed: the token the - /// editor chains its next save on must describe the file, not our intent. + /// The shared editor save: check `base_revision`, apply `splice` (which may refuse), + /// write, re-project model-free. The returned revision is read back from disk, so it + /// describes the file rather than our intent. fn save_guarded( &self, note_ref: &str, @@ -492,10 +394,7 @@ impl Vault { }) } - /// Every indexed note as a lightweight [`NoteSummary`], ordered by `path` — what - /// the file tree is built from. A pure model-free read: the tree shows exactly - /// the notes the index knows, and every one is [`read`](Self::read)-resolvable, - /// so a click always opens. A never-reindexed vault lists nothing, no error. + /// Every indexed note as a [`NoteSummary`], path-ordered, for the file tree. pub fn list_notes(&self) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "list_notes").entered(); Ok(db::all_notes(&self.conn)? @@ -504,10 +403,7 @@ impl Vault { .collect()) } - /// Every inventoried resource as a lightweight [`ResourceSummary`], ordered by - /// `path` — the file tree's resource half, a sibling of - /// [`list_notes`](Self::list_notes) rather than a widened union; the adapters - /// compose the tree. + /// Every inventoried resource as a [`ResourceSummary`], path-ordered. pub fn list_resources(&self) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "list_resources").entered(); Ok(db::list_resources(&self.conn)? @@ -521,50 +417,34 @@ impl Vault { .collect()) } - /// Every folder in the vault (vault-relative, sorted, empty ones included) — the - /// file tree's structure half. Read **live off the filesystem, never the index**: - /// folders are user-authored structure with no derived data, so the walk itself - /// is the projection and the tree stays one-to-one with disk. Dot-folders are - /// skipped, as in the ingest walk. Works on a never-reindexed vault. + /// Every folder in the vault (sorted, empty ones included, dot-folders skipped). + /// Read live off the filesystem, never the index, so the tree matches disk. pub fn list_dirs(&self) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "list_dirs").entered(); dirs::list_dirs(&self.root) } - /// Read a resource's **bytes** — [`read`](Self::read)'s non-note sibling, for the - /// viewers an adapter shows in place of the fallback card (an image, today). - /// - /// Inventory-checked *before* the filesystem is touched, the same posture the - /// desktop's *Open in system default* takes: the path must name a row the walk put - /// in `resources`, so this can never be talked into "read any file this process can - /// reach" by a link a note authored. Errors with [`Error::ResourceNotFound`] - /// otherwise — including for a note, which is not resource inventory. + /// Read a resource's bytes, for an adapter's viewer. /// - /// Reads whole, because every caller wants the whole file; a resource too large for - /// a viewer is the *adapter's* judgement (it knows the size from - /// [`explain_resource`](Self::explain_resource) before asking), not a rule the - /// engine imposes. + /// Inventory-checked before touching the filesystem, so a note's link can never + /// read an arbitrary file; [`Error::ResourceNotFound`] otherwise. Size limits are + /// the adapter's call. pub fn read_resource_bytes(&self, path: &str) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "read_resource_bytes", path).entered(); self.require_resource(path)?; Ok(fs::read(self.root.join(path))?) } - /// The absolute path of an inventoried resource, for an adapter that hands the file - /// to the OS (*Open in system default*). Inventory-checked like - /// [`read_resource_bytes`](Self::read_resource_bytes), so a path a note authored can - /// never name a file outside the vault's resources. [`Error::ResourceNotFound`] - /// otherwise. + /// The absolute path of an inventoried resource, for handing to the OS. + /// Inventory-checked like [`read_resource_bytes`](Self::read_resource_bytes). pub fn resource_path(&self, path: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "resource_path", path).entered(); self.require_resource(path)?; Ok(self.root.join(path)) } - /// The fallback card's data for one resource: inventory metadata plus the - /// backlinks panel, straight off the materialized graph. `path` is vault-relative - /// (the adapters dispatch here via [`crate::resource::doc_kind`]); errors with - /// [`Error::ResourceNotFound`] when it is not inventoried. + /// The fallback card's data for one resource: inventory metadata plus backlinks. + /// [`Error::ResourceNotFound`] when not inventoried. pub fn explain_resource(&self, path: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "explain_resource", path).entered(); let detail = self.require_resource(path)?; @@ -588,10 +468,7 @@ impl Vault { }) } - /// Move/rename a resource — the note move minus the identity step: rewrite every - /// inbound link's authored text (each syntax keeping its own relative-vs-root - /// convention), move the file, update the inventory, re-project the touched - /// notes. Errors with [`Error::ResourceNotFound`]; destination errors mirror + /// Move or rename a resource, rewriting inbound links. Errors mirror /// [`move_note`](Self::move_note). pub fn move_resource(&self, path: &str, to: &str) -> Result { let _op = @@ -600,19 +477,12 @@ impl Vault { mv::move_resource(self.embed_ctx(), path, to) } - /// Hybrid search (BM25 ⊕ vector → RRF) resolved to notes, best first, capped at - /// `limit` *notes*: chunk hits dedup to the highest-scoring chunk per note, so - /// one note never appears twice (ADR-0008). + /// Hybrid search (BM25 ⊕ vector → RRF) resolved to at most `limit` distinct notes, + /// best first (ADR-0008). /// - /// **Keyword-first fallback:** when the vector space does not exist yet — a - /// projected-but-unembedded vault — this runs BM25-only rather than returning - /// nothing, so a vault is searchable the moment [`project`](Self::project) - /// finishes. A never-indexed vault yields no hits and no error; callers read - /// [`embed_status`](Self::embed_status) to present keyword-only results honestly. - /// - /// A `limit` of 0 short-circuits ahead of [`retrieve`](Self::retrieve), so it - /// costs no query embedding and no [`Error::ModelMismatch`] either: that guard - /// exists to stop *wrong results*, and there are none to be wrong about. + /// With no vector space yet it runs BM25-only; callers read + /// [`embed_status`](Self::embed_status) to say so. A `limit` of 0 returns early, + /// without [`Error::ModelMismatch`]: there are no results to be wrong. pub fn search(&self, query: &str, limit: usize) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "search", query, limit).entered(); if limit == 0 { @@ -622,13 +492,9 @@ impl Vault { self.note_results(hits, query, limit) } - /// [`search`](Self::search)'s dense half alone — vector KNN resolved to notes, - /// deduped, best first. **The eval harness's ablation instrument** (ADR-0013, - /// GH #158), not an adapter surface: scoring it beside bm25-only and hybrid is - /// what gives fusion a measured single-signal baseline. Same model-mismatch - /// fail-fast as `search`, but a projected-but-unembedded vault returns no hits — - /// where `search` honestly *falls back* to keywords, an ablation that quietly did - /// the same would be measuring the wrong signal. + /// [`search`](Self::search)'s dense half alone: the eval's ablation arm (ADR-0013, + /// GH #158). An unembedded vault returns no hits rather than falling back, or the + /// ablation would measure the wrong signal. pub fn search_vector_only(&self, query: &str, limit: usize) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "search_vector_only", query, limit).entered(); @@ -648,46 +514,22 @@ impl Vault { self.note_results(hits, query, limit) } - /// [`search`](Self::search) with the **evidence behind it** (ADR-0015): the same - /// served results, plus the two absolute signals RRF discards and the verdict - /// they produce. - /// - /// Flow ② could not answer *zero* as first shipped — the dense half always has k - /// nearest, *nearest* is a fact about the vault rather than evidence about the - /// query, and RRF keeps only ranks — so a nonsense query served `limit` - /// confident-looking results. This read is what a surface consults to say **"no - /// matches"** honestly. It judges nothing itself: the results come back whole and - /// in order, and what a surface *vouches for* is the surface's decision to make. + /// [`search`](Self::search) plus the evidence RRF discards and its verdict + /// (ADR-0015), so a surface can say "no matches" honestly. Results come back whole; + /// what to vouch for is the surface's call. /// - /// `limit` caps the rows and nothing else — the evidence is a fact about the - /// query and the vault, not about how many results were asked for. `vouched` is - /// `None` when the active embedder has no calibrated bar (a fake space has no - /// geometry to hold a cosine bar over, and an uncalibrated model's bar would be - /// another model's constant); a caller that gets `None` never guesses one. - /// - /// [`search_evidence_excluding`](Self::search_evidence_excluding) is this same - /// read minus a caller-named set of notes — the follow-up-search form. + /// `limit` caps rows only. `vouched` is `None` when the embedder has no calibrated + /// bar; a caller never guesses one. pub fn search_evidence(&self, query: &str, limit: usize) -> Result { self.search_evidence_excluding(query, limit, &[]) } - /// [`search_evidence`](Self::search_evidence) minus a **caller-named** set of notes: - /// hits on `exclude` paths are dropped before note resolution, so the served rows - /// backfill from the same ranking with the next fresh notes. This is the - /// follow-up-search form an agent loop passes its already-inspected paths to — a - /// re-query that hands back the same head reads as progress and is none. - /// - /// The subtraction is the caller's, never the verdict's: `vouched`, the terms and - /// `best_cos` still read the whole vault, because the evidence is a fact about the - /// query and the vault (ADR-0015), not about what the caller has already read. The - /// retrieval pool is also unchanged — width is a ranking choice priced by the eval, - /// not plumbing (GH #141/#142) — so exclusion spends the same headroom note-dedup - /// does, and a heavily-excluded query may serve fewer than `limit` rows: the - /// ranking's head is spent, and the honest next move is a refined query, not a - /// deeper page. + /// [`search_evidence`](Self::search_evidence) minus the `exclude` paths, for an agent's + /// follow-up search. Rows backfill from the same ranking. /// - /// Paths match exactly as a result served them — a note's identity is its - /// vault-relative path (L1) — and an unknown path excludes nothing. + /// The verdict, terms and `best_cos` still read the whole vault (ADR-0015). The pool + /// is unchanged (GH #141/#142), so a heavily-excluded query may serve fewer than + /// `limit` rows. Paths match exactly (L1). pub fn search_evidence_excluding( &self, query: &str, @@ -702,19 +544,12 @@ impl Vault { excluded = exclude.len() ) .entered(); - // `limit.max(1)` where `search` would short-circuit: a zero limit caps the - // *rows*, and the question this read answers is about the evidence. Going - // through `retrieve(0)` would skip the dense scan and hand back - // `best_cos: None` — which reads as "no embedding space" — so a verdict taken - // from it would be the lexical half wearing both halves' name. + // `limit.max(1)`: `retrieve(0)` would skip the dense scan and report + // `best_cos: None`, which reads as "no embedding space". let mut retrieval = self.retrieve(query, note_hit_pool(limit.max(1)))?; - // The subtraction drops hits only. `best_cos` is a scalar the retrieval - // already carries, so the vault's nearest chunk still reports even when the - // caller has already read the note it belongs to. + // Drops hits only; `best_cos` still reports the vault's nearest chunk. retrieval.hits.retain(|h| !exclude.contains(&h.note_path)); - // The lexical half is read here rather than inside retrieval: it costs a - // `count(*)` per distinct query term, and only a caller that wants a verdict - // should pay for it. + // Read here, not in retrieval: a `count(*)` per term only a verdict needs. let evidence = search::QueryEvidence { lexical: search::lexical_evidence(&self.conn, query)?, best_cos: retrieval.best_cos, @@ -747,9 +582,7 @@ impl Vault { }) } - /// [`resolve_note_hits`](Self::resolve_note_hits) without the provenance — the - /// shared tail of [`search`](Self::search) and - /// [`search_vector_only`](Self::search_vector_only). + /// [`resolve_note_hits`](Self::resolve_note_hits) without the provenance. fn note_results( &self, hits: Vec, @@ -763,9 +596,7 @@ impl Vault { .collect()) } - /// The note-resolution tail of every note-level search: dedup chunk hits to their - /// best-scoring note, resolve path + title + query-windowed snippet, stop at - /// `limit`. + /// Dedup chunk hits to their best note, resolve each with a snippet, stop at `limit`. fn resolve_note_hits( &self, hits: Vec, @@ -780,9 +611,7 @@ impl Vault { if out.iter().any(|(r, _)| r.path == hit.note_path) { continue; // note already represented by a higher-scoring chunk } - // The chunk or its note can only be missing on a torn read; drop the hit - // rather than name a note that is gone — the pool has headroom to backfill - // it (GH #137). + // Missing only on a torn read; the pool's headroom backfills (GH #137). let Some(found) = db::chunk_hit(&self.conn, hit.chunk_id)? else { continue; }; @@ -799,11 +628,7 @@ impl Vault { Ok(out) } - /// [`search`](Self::search) at **chunk** granularity: the top `limit` ranked - /// chunks resolved to their note + heading breadcrumb + full text, with **no note - /// dedup** — one note may appear several times when several of its passages rank. - /// Same retrieval, same fallback, same fail-fast (see [`ChunkSearchResult`] for - /// who consumes this). Retrieves a narrower pool than [`search`](Self::search) — + /// [`search`](Self::search) at chunk granularity, with no note dedup. Narrower pool; /// see [`chunk_hit_pool`]. pub fn search_chunks(&self, query: &str, limit: usize) -> Result> { let _op = @@ -816,9 +641,7 @@ impl Vault { if out.len() == limit { break; } - // The lookup misses only on a torn read; drop such a hit rather than emit a - // half-resolved one — a rank slot with an empty path would read as a real - // result. The pool backfills it (GH #137). + // Missing only on a torn read; the pool backfills (GH #137). let Some(found) = db::chunk_hit(&self.conn, hit.chunk_id)? else { continue; }; @@ -832,11 +655,8 @@ impl Vault { Ok(out) } - /// The shared retrieval core of [`search`](Self::search), - /// [`search_chunks`](Self::search_chunks) and - /// [`search_evidence_excluding`](Self::search_evidence_excluding): hybrid when the embedding space - /// exists (failing fast on a model mismatch), BM25-only on a - /// projected-but-unembedded vault. + /// Shared retrieval: hybrid when the embedding space exists (failing fast on a model + /// mismatch), else BM25-only. fn retrieve(&self, query: &str, pool: usize) -> Result { if db::embedding_space_exists(&self.conn)? { self.ensure_query_space_matches()?; @@ -846,10 +666,8 @@ impl Vault { } } - /// The model-identity guard every query-embedding read shares: the stored vectors - /// must have been produced by the active embedder, or the query vector is - /// incomparable with them and results would be silently wrong (ADR-0007). The fix - /// is a `reindex`. + /// Refuse a query embedding when the stored vectors came from another embedder: the + /// results would be silently wrong (ADR-0007). fn ensure_query_space_matches(&self) -> Result<()> { if let Some((indexed_model, indexed_dim)) = db::recorded_embedder(&self.conn)? { if indexed_model != self.embedder.model_id() || indexed_dim != self.embedder.dim() { @@ -862,31 +680,23 @@ impl Vault { Ok(()) } - /// The vault's embedding coverage as an [`EmbedStatus`] — the honest "N/M - /// embedded" read (#26). A **pure model-free count**, so an adapter can flag - /// results "keyword-only for now" rather than silently under-rank while a vault - /// embeds behind the first tree paint. + /// The vault's embedding coverage (#26), so an adapter can flag results as + /// keyword-only while a vault embeds. Model-free. pub fn embed_status(&self) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "embed_status").entered(); let (embedded, total) = db::embed_progress(&self.conn)?; Ok(EmbedStatus { embedded, total }) } - /// The notes most semantically similar to `note_ref` that are **not already - /// connected** to it — [`discover::candidates`] surfaced directly: vector KNN over - /// the stored embeddings, minus the anchor's 1-hop neighbours, ranked - /// nearest-best-passage first. **The ranked list is what is served** (ADR-0014): - /// no statistic gates it, `limit` is a cap that under-fills only for want of - /// scorable notes, and an empty result means the candidate set is genuinely empty, - /// never a verdict that nothing relates. Each row carries path + title, the - /// passage that made it similar, and — on a real-embedded space with a large - /// enough population — the `z` the strength band derives from. A **pure read over - /// stored vectors, no model call**. [`Error::NoteNotFound`] for an unknown ref. + /// The notes most similar to `note_ref` that are not already linked to it, with the + /// passage that made each similar. No model call. + /// + /// The ranked list is served as is (ADR-0014): no statistic gates it, and empty means + /// no candidates, not that nothing relates. `z` is set only when it means something. pub fn similar(&self, note_ref: &str, limit: usize) -> Result> { let _op = tracing::debug_span!(target: "b2::vault", "similar", note = note_ref, limit).entered(); - // Never claim a statistic over a fake-embedded space (`grades`). Grading changes - // what the rows carry, never which rows exist (ADR-0014). + // Grading changes what rows carry, never which rows exist (ADR-0014). let grade = self.grades()?; self.refuse_resource_anchor(note_ref)?; let anchor = self.resolve_ref(note_ref)?; @@ -907,14 +717,9 @@ impl Vault { Ok(out) } - /// **Explain** one note against an anchor's *Similar & unlinked* list (GH #236): - /// where it stands (ranked, past the list, linked, not embedded, not shortlisted), - /// its z and whole-note rank, the field's z population, every passage pair graded on - /// the card's yardstick, and the neighbors both notes share. `limit` is the list - /// length the surface showed, so the rank and z are the card's. Any note can be - /// explained, not only a served one. A pure read over stored vectors, no model call. - /// [`Error::NoteNotFound`] for an unknown ref on either side; - /// [`Error::ResourceUnsupported`] for a resource anchor, as [`similar`](Self::similar). + /// Explain one note against an anchor's Similar & unlinked list (GH #236): where it + /// stands, its z and rank, graded passage pairs, and shared neighbors. `limit` is the + /// list length shown, so rank and z match the card. Any note works. No model call. pub fn explain_similar( &self, anchor_ref: &str, @@ -952,8 +757,7 @@ impl Vault { for g in ex.pairs { let anchor_side = self.passage(g.pair.anchor_chunk_id)?; let candidate_side = self.passage(g.pair.candidate_chunk_id)?; - // A chunk row that vanished mid-read (a concurrent reindex, C1) is skipped - // rather than shown half-empty. + // A chunk that vanished mid-read (C1) is skipped, not shown half-empty. let (Some(a), Some(c)) = (anchor_side, candidate_side) else { continue; }; @@ -983,9 +787,8 @@ impl Vault { }) } - /// Whether this vault's discovery is graded: never over a fake-embedded space, whose - /// hash vectors have no semantic geometry for a z to describe. Judged by the RECORDED - /// identity (the space being searched), not the injected embedder. + /// Whether discovery is graded: never over a fake-embedded space. Judged by the + /// recorded embedder (the space searched), not the injected one. fn grades(&self) -> Result { Ok(!matches!( db::recorded_embedder(&self.conn)?, @@ -1022,16 +825,11 @@ impl Vault { .collect()) } - /// Commit a typed connection `src --type--> dst` (`b2 link`, flow ③): append a - /// typed-link string to the **source note's frontmatter `b2_relations:`** — never - /// the body (ADR-0010) — and re-project it as an `origin='frontmatter'` active - /// edge. Both ends resolve by path. `edge_type` must be a **core** verb; a - /// non-core one errors with [`Error::InvalidRelation`] rather than silently store - /// a typo. **Idempotent:** an existing `(src, dst, type)` edge writes nothing - /// (`created: false`). + /// Commit a typed connection `src --type--> dst` (`b2 link`, flow ③) to the source's + /// frontmatter `b2_relations:`, never the body (ADR-0010). `edge_type` must be a core + /// verb ([`Error::InvalidRelation`]). Idempotent: an existing edge writes nothing. /// - /// Re-projection re-reads the source note, so the adapters open the vault with the - /// same embedder the index was built with, as for `add`/`mv`. + /// Re-projection embeds, so adapters open with the index's embedder. pub fn link( &self, src_ref: &str, @@ -1049,7 +847,7 @@ impl Vault { } let src_path = self.resolve_ref(src_ref)?; let dst_full = self.resolve_ref(dst_ref)?; - // The link path drops the `.md` Obsidian omits (matches how `[[links]]` are written). + // Drop the `.md`, as `[[links]]` are written. let dst_path = dst_full .strip_suffix(".md") .unwrap_or(&dst_full) @@ -1065,8 +863,7 @@ impl Vault { }); } - // The spec targets the dst's path. A note's title is its filename, so a bare - // `[[path]]` already reads as the title — B2 writes no alias. + // The title is the filename, so B2 writes no alias. let spec = link::render_relation(edge_type, &dst_path, explanation); // 1. Markdown first: append to frontmatter b2_relations: (never the body, §0). @@ -1075,8 +872,7 @@ impl Vault { parsed.add_relation(&spec)?; fs::write(&abs, parsed.as_str())?; - // 2. Re-project the source note so the edge re-materializes from the Markdown - // as origin='frontmatter' — a projection of the line just written. + // 2. Re-project, so the edge materializes from the line just written. ingest::ingest_file(self.embed_ctx(), &src_path)?; Ok(LinkReport { @@ -1087,76 +883,53 @@ impl Vault { }) } - /// Move/rename the whole folder `from` to `to` (both vault-relative; a trailing - /// `/` is tolerated). One `fs::rename` moves the directory — unindexed files - /// inside travel too — after every inbound link at the moved set (including - /// vault-root wikilinks *between* co-moved notes) is rewritten; the index then - /// re-projects and the graph never breaks (each moved note's rows re-key to its - /// new path, `ON UPDATE CASCADE`). Errors with [`Error::DirNotFound`], - /// [`Error::MoveDestination`] (including a destination inside the moved folder), - /// or [`Error::MoveTargetExists`] rather than merge into an existing entry. + /// Move or rename the folder `from` to `to` (trailing `/` tolerated), rewriting every + /// inbound link first, including links between co-moved notes. Refuses to merge into + /// an existing entry ([`Error::MoveTargetExists`]). /// - /// Rewriting an inbound file changes its body, so this **re-embeds** those files: - /// the adapters open with the real model for a dir move. + /// Rewritten files re-embed, so adapters open with the real model. pub fn move_dir(&self, from: &str, to: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "mv_dir", from, to).entered(); mv::move_dir(self.embed_ctx(), from, to) } - /// Move/rename the note `note_ref` to `to` (a vault-relative path, `.md` - /// optional), rewriting every inbound `[[oldpath|alias]]` link to the new path and - /// re-projecting. The graph never breaks — the note's rows re-key to the new path - /// (`ON UPDATE CASCADE`), so neighbors and backlinks show the same set before and - /// after and not one chunk is re-embedded (ADR-0003, ADR-0006); the human-readable - /// link text is repaired alongside. Errors with [`Error::NoteNotFound`], - /// [`Error::MoveDestination`] or [`Error::MoveTargetExists`]. + /// Move or rename the note `note_ref` to `to` (`.md` optional), rewriting every + /// inbound link. The note's rows re-key, so the graph never breaks and its chunks + /// are not re-embedded (ADR-0003, ADR-0006). /// - /// Rewriting an inbound file changes its body, so this **re-embeds** those files: - /// the adapters open with the real model for `mv`. + /// Rewritten inbound files re-embed, so adapters open with the real model. pub fn move_note(&self, note_ref: &str, to: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "mv", from = note_ref, to).entered(); let old_rel = self.resolve_ref(note_ref)?; mv::move_note(self.embed_ctx(), &old_rel, to) } - /// Delete the note `note_ref`: the file leaves the disk, its projection rows leave - /// the index, and every inbound link at it **dangles** — never rewritten, - /// surfacing as unresolved (GH #12) — exactly the state an external `rm` plus a - /// full reindex produces. **Model-free**: no body changes, so the inbound - /// re-projection touches no vectors. [`Error::NoteNotFound`] for an unknown ref. + /// Delete the note `note_ref` and its rows. Inbound links dangle, never rewritten + /// (GH #12), as after an external `rm` and a reindex. Model-free. pub fn delete_note(&self, note_ref: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "rm", note = note_ref).entered(); let rel = self.resolve_ref(note_ref)?; rm::delete_note(self.ctx(), &rel) } - /// [`delete_note`](Self::delete_note)'s resource sibling — same posture (file - /// off disk, inventory row off the index, inbound links dangle, model-free). - /// Errors with [`Error::ResourceNotFound`] for a path not in the inventory. + /// [`delete_note`](Self::delete_note) for a resource. pub fn delete_resource(&self, path: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "rm_resource", path).entered(); self.require_resource(path)?; rm::delete_resource(self.ctx(), path) } - /// Delete the whole folder `dir` (vault-relative) and everything inside it — - /// one `fs::remove_dir_all`, so unindexed files inside go too — then every - /// contained note's/resource's rows; surviving linkers outside the folder - /// dangle, as for [`delete_note`](Self::delete_note). Model-free. Errors with - /// [`Error::DirNotFound`] for a missing (or invalid) folder. + /// Delete the folder `dir` and everything in it, unindexed files included. Links + /// from outside dangle, as for [`delete_note`](Self::delete_note). Model-free. pub fn delete_dir(&self, dir: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "rm_dir", dir).entered(); rm::delete_dir(self.ctx(), dir) } - /// Create a new note (`b2 add`): write `path` with a minimal valid frontmatter (an - /// optional `title`, today's `created`) and `content` as its body, then project it - /// — the note is immediately searchable and in the graph. Nothing is added beyond - /// what the template wrote, so it stays fully reconstructible from Markdown. - /// Errors with [`Error::AddDestination`] or [`Error::AddTargetExists`] rather than - /// clobber an existing file. + /// Create a new note (`b2 add`) with minimal frontmatter and `content` as its body, + /// then project it. Never clobbers ([`Error::AddTargetExists`]). /// - /// Projection **embeds** the new note, so the CLI opens with the real model. + /// Projection embeds, so the CLI opens with the real model. pub fn add_note( &self, path: &str, @@ -1168,81 +941,55 @@ impl Vault { add::add_note(self.embed_ctx(), path, title, content, &created) } - /// Create a new, empty note **model-free** — the desktop's New-note action, the - /// create sibling of [`write`](Self::write). Same minimal frontmatter as - /// [`add_note`](Self::add_note) (no title — a note's display title is its - /// filename), projected with **no embedder touched**, so creation works with no - /// model provisioned and a fake-opened vault can never write foreign vectors into - /// a real embedding space (ADR-0007). The chunks join the pending set any later - /// [`embed`](Self::embed) heals; an empty body has nothing to embed anyway. Same - /// refusals as `add_note`. + /// Create a new, empty note model-free (the desktop's New note). No embedder, so a + /// fake-opened vault can't write foreign vectors into a real space (ADR-0007). pub fn create_note(&self, path: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "create", path).entered(); let created = self.today()?; add::create_note(self.ctx(), path, None, None, &created) } - /// Import a file the human already has into the vault folder `dir` (`""` for the - /// root) under `file_name`, from its **bytes** — the desktop's drag-onto-the-tree - /// gesture, where the OS hands the webview content rather than a path. A `.md` - /// lands as a note (projected); anything else as a resource (one inventory row), - /// exactly as the vault walk would have classified it. The bytes are written - /// **verbatim** — B2 authors nothing here, unlike [`add_note`](Self::add_note), - /// which mints a document. **Model-free**, like [`create_note`](Self::create_note). + /// Import a file's bytes verbatim into folder `dir` (`""` for the root) as + /// `file_name`: the desktop's drag onto the tree. Classified as the walk would. + /// Model-free. /// - /// Errors with [`Error::ImportDestination`] for an invalid name or folder/name - /// pair, and [`Error::ImportTargetExists`] rather than clobber an existing file: - /// the destination path *is* the arriving note's identity (ADR-0003), so refusing - /// an occupied one is the whole of the collision story. If projection fails the - /// placed file is removed again — an import either lands and indexes, or leaves - /// nothing behind. + /// Never clobbers ([`Error::ImportTargetExists`]): the path is the identity + /// (ADR-0003). If projection fails the file is removed again. pub fn import_file(&self, dir: &str, file_name: &str, bytes: &[u8]) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "import", dir, file_name).entered(); import::import_bytes(self.ctx(), dir, file_name, bytes) } - /// [`import_file`](Self::import_file) from a **path** instead of bytes — the same - /// op for the same gesture's keyboard half (an OS file picker, which yields - /// paths), keeping the file's own name. Same refusals, plus - /// [`Error::ImportDestination`] for a source that is a folder or has no file name. + /// [`import_file`](Self::import_file) from a path (an OS file picker), keeping the + /// file's name. pub fn import_path(&self, dir: &str, source: &Path) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "import_path", dir).entered(); import::import_path(self.ctx(), dir, source) } - /// Create the folder `dir` (missing parents included, an occupied target refused) - /// — the desktop's New-folder action, the structure sibling of - /// [`create_note`](Self::create_note). A folder is user-authored structure, so - /// this writes the filesystem and touches **nothing else**: no index rows exist - /// for a folder (see [`list_dirs`](Self::list_dirs)), and it is real to Finder, - /// the CLI and a sync the moment this returns. Errors with - /// [`Error::DirDestination`] or [`Error::DirTargetExists`]. + /// Create the folder `dir`, parents included. Touches only the filesystem: folders + /// have no index rows (see [`list_dirs`](Self::list_dirs)). pub fn create_dir(&self, dir: &str) -> Result { let _op = tracing::debug_span!(target: "b2::vault", "mkdir", dir).entered(); dirs::create_dir(&self.root, dir) } - /// Today's date (`YYYY-MM-DD`) from **SQLite** — the same clock that stamps - /// `indexed_at`, so `b2-core` needs no wall-clock crate and the façade stays the - /// determinism boundary. The vault convention for a note's `created:` field. + /// Today's date (`YYYY-MM-DD`) from SQLite, so `b2-core` reads no wall clock itself. fn today(&self) -> Result { Ok(self .conn .query_row("SELECT strftime('%Y-%m-%d','now')", [], |r| r.get(0))?) } - /// The inventory row for `path`, or [`Error::ResourceNotFound`]: the one existence - /// check every resource op makes before touching the filesystem, so a path a note - /// authored can never talk B2 into reading or moving a file outside the inventory. + /// The inventory row for `path`, or [`Error::ResourceNotFound`]. Every resource op + /// checks this first, so a note's link can't reach a file outside the inventory. fn require_resource(&self, path: &str) -> Result { db::resource_detail(&self.conn, path)? .ok_or_else(|| Error::ResourceNotFound(path.to_string())) } - /// Discovery's anchor guard. A resource anchor is honest, not silent: resources - /// become discoverable once they have chunks + centroids. Until then an inventoried - /// resource errs "not yet" ([`Error::ResourceUnsupported`]) — never an empty result — - /// and an unknown path falls through to the caller's not-found. + /// Discovery's anchor guard: an inventoried resource errs + /// [`Error::ResourceUnsupported`] rather than return an empty result. fn refuse_resource_anchor(&self, anchor_ref: &str) -> Result<()> { if crate::resource::doc_kind(anchor_ref) == crate::resource::DocKind::Resource && db::resource_detail(&self.conn, anchor_ref)?.is_some() @@ -1252,19 +999,15 @@ impl Vault { Ok(()) } - /// Resolve a note reference to the indexed note's vault-relative path — which *is* - /// its identity (ADR-0003), so this is one canonicalization rather than the - /// two-step "is it an id? is it a path?" it replaced. The ref may be written with - /// or without the `.md`, as links are. The [`Error::NoteNotFound`] carries the - /// caller's original `note_ref`, so the error reads as they typed it. + /// Resolve a note reference (`.md` optional) to its vault-relative path, its + /// identity (ADR-0003). The error carries `note_ref` as typed. fn resolve_ref(&self, note_ref: &str) -> Result { db::resolve_link_target(&self.conn, note_ref)? .ok_or_else(|| Error::NoteNotFound(note_ref.to_string())) } } -/// A file's save-guard revision: blake3 of its raw bytes. One fn, so `read` -/// (capture) and `write` (validate + return) can never drift. +/// A file's save-guard revision: blake3 of its raw bytes. fn revision_of(raw: &str) -> String { blake3::hash(raw.as_bytes()).to_hex().to_string() } diff --git a/crates/b2-core/src/vault/chat.rs b/crates/b2-core/src/vault/chat.rs index 689ad88..b9cbc73 100644 --- a/crates/b2-core/src/vault/chat.rs +++ b/crates/b2-core/src/vault/chat.rs @@ -1,7 +1,6 @@ -//! Flow ④ on the façade: grounded chat ([`Vault::ask`]) and the one tool-using turn, -//! **Why?** on a *Similar & unlinked* card ([`Vault::why_similar`], ADR-0022). The prompt -//! text and tool schemas live in [`crate::chat`]; this is the orchestration over the -//! façade's own reads. Chat is a reader: nothing model-derived is stored. +//! Flow ④ on the façade: grounded chat ([`Vault::ask`]) and the tool-using Why? turn +//! ([`Vault::why_similar`], ADR-0022). Prompts and tool schemas live in [`crate::chat`]. +//! Chat is a reader: nothing model-derived is stored. use super::{AnswerView, Citation, ToolUseView, Vault}; use crate::chat; @@ -13,26 +12,12 @@ use crate::snippet::snippet; use std::ops::ControlFlow; impl Vault { - /// Flow ④ — grounded chat over the vault: condense → retrieve → assemble → - /// stream → cite, orchestrated here over the core logic in [`crate::chat`]. The - /// provider is injected **per call**: chat is its sole consumer and, unlike the - /// embedder, it carries no index identity (contrast ADR-0007), so nothing about - /// it belongs on the open vault. + /// Flow ④, grounded chat: condense → retrieve → stream → cite. The provider is + /// injected per call: unlike the embedder it carries no index identity (ADR-0007). /// - /// - **Condense** (multi-turn only): one provider call rewrites the follow-up - /// into a standalone retrieval query; on failure it degrades to the raw - /// question, so that step can never break chat. - /// - **Retrieve**: [`search_chunks`](Self::search_chunks) at - /// [`chat::ASK_PASSAGES`], holding `search`'s posture — chat is a reader. - /// - **Stream**: tokens flow up through `on_token` as they arrive; returning - /// `ControlFlow::Break(())` cancels at token granularity and the result reports - /// it ([`AnswerView::cancelled`]). - /// - **Cite**: distinct `[n]` markers resolve to `(path, excerpt)`; a - /// hallucinated marker resolves to nothing, and the text is never rewritten. - /// - /// Nothing model-derived is stored anywhere, and history is the caller's, - /// session-only. Errors: retrieval as `search` raises it; a failed *answer* call - /// as [`Error::Llm`](crate::Error::Llm). + /// A failed condense falls back to the raw question. `on_token` returning `Break` + /// cancels. A hallucinated `[n]` resolves to nothing; the text is never rewritten. + /// A failed answer call is [`Error::Llm`](crate::Error::Llm). pub fn ask( &self, llm: &dyn LlmProvider, @@ -70,39 +55,19 @@ impl Vault { self.stream_answer(llm, &req, on_token) } - /// **Why was this suggested?** — the chat answer behind one *Similar & unlinked* - /// row: explain, grounded and cited, why `candidate_ref` surfaced for `anchor_ref`. - /// - /// A **tool-using** turn (ADR-0022). The model is offered B2's read-only tools - /// ([`chat::why_tools`]: `b2_passage_pairs`, `b2_similar`, `b2_neighbors`, `b2_read`) - /// and makes the lookups itself; each call is one read on this façade, run here and - /// replayed to the model with its result. Every argument defaults to this turn's - /// pair, so `{}` is always a valid call. Three things bound that: - /// - /// - **Round 1 is the lookup round.** Its text is never streamed: a model that - /// answers there answered from nothing. If it calls no tool — or the call fails, - /// most often a model with no tool support — the turn degrades to - /// [`chat::build_why_request`]: B2 makes the pair lookup and hands the evidence over - /// in one plain grounded request. If it calls tools but skips the pair lookup, B2 - /// appends that call itself (`seeded`), because the matched pairs are what the row - /// was ranked on and no explanation is written without them. - /// - **The loop is bounded** at [`chat::MAX_TOOL_ROUNDS`] model calls and - /// [`chat::MAX_CALLS_PER_ROUND`] tool calls each, and the last round offers no - /// tools, so it can only answer. - /// - **The row's position rides in the prompt** — rank and strength from - /// [`similar`](Self::similar) at the `limit` the surface showed, so the explanation - /// describes the card that was clicked. + /// Why? on a Similar & unlinked row: a grounded, cited explanation of why + /// `candidate_ref` surfaced for `anchor_ref`. A tool-using turn (ADR-0022) over + /// [`chat::why_tools`]; every argument defaults to this pair, so `{}` is valid. /// - /// One consequence of the hidden first round: a cancel lands when the first visible - /// token arrives, not during the lookups. + /// - Round 1 is the lookup round and is never streamed. No tool call, or a failed + /// one, degrades to [`chat::build_why_request`]. If the pair lookup was skipped, + /// B2 makes it itself (`seeded`): the row was ranked on those pairs. + /// - At most [`chat::MAX_TOOL_ROUNDS`] rounds of [`chat::MAX_CALLS_PER_ROUND`] calls; + /// the last round offers no tools. + /// - The row's rank and strength at `limit` ride in the prompt. /// - /// A tool call is model output, so it is untrusted: an unknown tool, malformed - /// arguments or an unknown note is answered with an `error:` *result* the model can - /// read, never a failed turn. Every passage a tool hands over is numbered once on one - /// ledger, and the answer's `[n]` markers resolve against it. Streaming, cancellation - /// and the [`Error::Llm`] normalization are [`ask`](Self::ask)'s — chat is a reader - /// here too, and nothing is stored. [`Error::NoteNotFound`] for an unknown ref on - /// either side. + /// Tool calls are untrusted: a bad one gets an `error:` result, never a failed turn. + /// Passages are numbered once on one ledger, which `[n]` resolves against. pub fn why_similar( &self, llm: &dyn LlmProvider, @@ -122,7 +87,7 @@ impl Vault { let anchor = self.resolve_ref(anchor_ref)?; let candidate = self.resolve_ref(candidate_ref)?; - // The row as the surface showed it: same call, same limit, so the same rank + z. + // Same call and limit as the surface, so the same rank and z. let served = self.similar(&anchor, limit)?; let row = served.iter().position(|s| s.path == candidate); let mut desk = ToolDesk { @@ -156,9 +121,7 @@ impl Vault { if round == chat::MAX_TOOL_ROUNDS { req.tools.clear(); // nothing left to do but answer } - // Round 1 is the lookup round, and its text is never shown: a model that - // answers there has answered from nothing, and one that calls tools says at - // most "let me check". From round 2 on, tokens stream as they arrive. + // Round 1's text is never shown: an answer there is from nothing. let completion = if round == 1 { llm.complete(&req, &mut |_| ControlFlow::Continue(())) } else { @@ -173,11 +136,10 @@ impl Vault { return self.why_handoff(llm, &mut facts, desk, tools_used, on_token); } Ok(c) => c, - // Never degraded: a reply past the tool-call cap is a broken or hostile - // server, and a quiet handoff would hide it. + // Never degraded: past the cap is a broken or hostile server. Err(e @ Error::ToolCallLimit { .. }) => return Err(e), - // The lookup round failed: most often a model with no tool support. - // If the server is simply down, the handoff fails the same way, honestly. + // Most often a model with no tool support; a down server fails the + // handoff too. Err(e) if round == 1 => { tracing::debug!( target: "b2::chat", @@ -217,9 +179,7 @@ impl Vault { }); req.exchanges.push(ToolExchange { call, result }); } - // Whatever the model chose to look up, the matched pairs for *this* pair are - // what the row was ranked on. If the lookup round skipped them, B2 makes that - // call itself, so no explanation is written without them on the desk. + // The row was ranked on this pair's matches, so B2 seeds them if skipped. if round == 1 && desk.passages.is_empty() { let seeded = self.seed_pairs(&mut desk, &facts)?; tools_used.push(ToolUseView { @@ -262,9 +222,8 @@ impl Vault { }) } - /// The degrade of a tool-using why-turn: B2 makes the pair lookup itself and hands - /// the evidence over in one plain grounded request — for a model with no tool - /// support, and for one that was offered tools and used none. + /// The why-turn's degrade: B2 makes the pair lookup and sends one plain grounded + /// request. fn why_handoff( &self, llm: &dyn LlmProvider, @@ -287,10 +246,8 @@ impl Vault { Ok(view) } - /// Run one tool call for the model and render its result as text. Infallible by - /// design: the call is model output, so every failure — an unknown tool, arguments - /// that aren't a JSON object, a note that doesn't exist, an index error — becomes an - /// `error:` result the model reads, and the turn carries on. + /// Run one tool call and render its result as text. Infallible: the call is model + /// output, so every failure becomes an `error:` result the model reads. fn run_tool(&self, desk: &mut ToolDesk, facts: &chat::WhyFacts, call: &ToolCall) -> String { let args: serde_json::Value = if call.arguments.trim().is_empty() { serde_json::json!({}) @@ -321,8 +278,7 @@ impl Vault { self.tool_neighbors(¬e) } chat::TOOL_READ => { - // The open note is already on the human's screen; the one worth reading - // by default is the suggestion. + // The anchor is already on screen; default to the suggestion. let note = note.unwrap_or_else(|| facts.candidate_path.clone()); let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0); self.tool_read(desk, ¬e, offset as usize) @@ -336,9 +292,8 @@ impl Vault { }) } - /// `b2_passage_pairs`: the matched pairs between two notes, then each passage they - /// name that the ledger had not handed over yet. Also returns the pairs over the - /// ledger's numbering, which the seeded call's facts need. + /// `b2_passage_pairs`: the matched pairs between two notes and any passages new to the + /// ledger. Also returns the pairs in ledger numbering. fn tool_passage_pairs( &self, desk: &mut ToolDesk, @@ -350,8 +305,7 @@ impl Vault { for pair in discover::passage_pairs(&self.conn, note, candidate, chat::WHY_PAIRS)? { let a = self.ledger_marker(desk, pair.anchor_chunk_id, note, &mut fresh)?; let c = self.ledger_marker(desk, pair.candidate_chunk_id, candidate, &mut fresh)?; - // A miss means a torn read against a concurrent reindex; skip the pair rather - // than name a passage with no text. + // A miss is a torn read (C1); skip the pair. if let (Some(a), Some(c)) = (a, c) { pairs.push((a, c, pair.score)); } @@ -472,8 +426,7 @@ impl Vault { Ok(Some(desk.passages.len())) } - /// The text of the passages a tool call handed over for the first time. A passage - /// already on the ledger is named by its marker only — the model has its text. + /// The text of passages handed over for the first time. fn fresh_blocks(&self, desk: &ToolDesk, fresh: &[usize]) -> Vec { fresh .iter() @@ -486,9 +439,7 @@ impl Vault { .collect() } - /// The shared tail of [`ask`](Self::ask) and the handoff half of - /// [`why_similar`](Self::why_similar): stream the completion, then resolve the - /// answer's `[n]` markers against the request's own passages. + /// Stream the completion, then resolve `[n]` against the request's passages. fn stream_answer( &self, llm: &dyn LlmProvider, @@ -505,10 +456,8 @@ impl Vault { } } -/// The state one tool-using turn carries between calls: the list length the surface -/// showed, and the **passage ledger** — every passage a tool has handed the model, -/// numbered once in first-seen order, which is what `[n]` resolves against. Whose turn it -/// is (what `{}` arguments default to) rides on the turn's [`chat::WhyFacts`]. +/// A tool-using turn's state: the surface's list length and the passage ledger, every +/// passage handed to the model numbered once in first-seen order. struct ToolDesk { limit: usize, /// Parallel to `passages`: the chunk behind each, so a passage is never numbered twice. @@ -516,17 +465,15 @@ struct ToolDesk { passages: Vec, } -/// B2's own pair lookup: the call as the model would have made it, its result text, and -/// the pairs over the ledger's numbering. +/// B2's own pair lookup, as the model would have made it. struct SeededLookup { call: ToolCall, result: String, pairs: Vec, } -/// Normalize a provider failure: the trait returns the crate-wide `Result`, but every -/// failure of a model call is a failed model call, and adapters match [`Error::Llm`] for -/// the "can't reach the model server" message. Enforced here, not hoped for. +/// Normalize a provider failure to [`Error::Llm`], which adapters match for the +/// "can't reach the model server" message. fn llm_error(e: Error) -> Error { match e { Error::Llm(_) | Error::ToolCallLimit { .. } => e, @@ -539,8 +486,7 @@ fn cite(answer: &str, passages: &[ContextPassage]) -> Vec { chat::cited_markers(answer, passages.len()) .into_iter() .filter_map(|marker| { - // 1-based marker to 0-based passage; skip rather than index, so even a - // broken invariant degrades to a missing citation. + // 1-based marker; a broken invariant degrades to a missing citation. let p = passages.get(marker.checked_sub(1)?)?; Some(Citation { marker, diff --git a/crates/b2-core/src/vault/views.rs b/crates/b2-core/src/vault/views.rs index adfab12..460ec99 100644 --- a/crates/b2-core/src/vault/views.rs +++ b/crates/b2-core/src/vault/views.rs @@ -10,34 +10,27 @@ use super::Vault; #[cfg(doc)] use crate::{graph, note}; -/// What `reindex` did: notes projected, and how many were actually (re)embedded -/// (the rest reused their vectors). It reports no vault writes because there are -/// none — a reindex reads (ADR-0004). -/// -/// `cancelled` marks a cooperative cancel of the embed phase; the counts then -/// describe the partial work truthfully and the index is still consistent (keyword -/// and graph complete, a prefix embedded). Always `false` for -/// [`reindex`](Vault::reindex). +/// What `reindex` did: notes projected and how many were (re)embedded. No vault writes +/// to report: a reindex reads (ADR-0004). After `cancelled`, the counts describe the +/// partial work. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ReindexReport { pub indexed: usize, pub embedded: usize, pub cancelled: bool, - /// Files skipped as unreadable this run — one bad file never fails the pass. + /// Files skipped as unreadable; one bad file never fails the pass. pub skipped: Vec, - /// Ghost rows pruned: notes whose files were deleted outside b2 (#31), so - /// incremental equals a from-scratch rebuild. + /// Ghost rows pruned: notes whose files were deleted outside b2 (#31). pub notes_pruned: usize, pub resources_indexed: usize, pub resources_pruned: usize, } -/// What [`project`](Vault::project) did — the model-free half of a reindex. No -/// embed counts: projection never touches vectors. +/// What [`project`](Vault::project) did: the model-free half of a reindex. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ProjectReport { pub indexed: usize, - /// Files skipped as unreadable this pass, so an adapter can say which and why. + /// Files skipped as unreadable. pub skipped: Vec, /// Ghost rows pruned: notes whose files were deleted outside b2 (#31). pub notes_pruned: usize, @@ -45,46 +38,35 @@ pub struct ProjectReport { pub resources_pruned: usize, } -/// What [`embed`](Vault::embed) did — the model-bound half of a reindex: notes -/// whose missing vectors were filled, and whether a cooperative cancel cut the pass -/// short (the counts stay truthful, and a re-run embeds exactly the remainder). +/// What [`embed`](Vault::embed) did: notes whose missing vectors were filled, and whether +/// a cancel cut the pass short. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub struct EmbedReport { pub embedded: usize, pub cancelled: bool, } -/// The vault's embedding coverage — the honest "N/M embedded" signal (#26). -/// Model-free: a pure count over the projection, so an adapter can say -/// "keyword-only for now" precisely without loading a model. `embedded < total` -/// means [`search`](Vault::search) is running keyword-first over the remainder; -/// `embedded == 0` is a fully keyword-only vault. +/// The vault's embedding coverage, "N/M embedded" (#26). `embedded < total` means +/// [`search`](Vault::search) is keyword-only over the remainder. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub struct EmbedStatus { - /// Notes with no chunk awaiting a vector. A note with **no chunks at all** — an empty - /// body — counts here: there is no vector it could be waiting for, so it must be able - /// to reach the numerator (see [`crate::db::embed_progress`]). + /// Notes with no chunk awaiting a vector, empty notes included + /// (see [`crate::db::embed_progress`]). pub embedded: usize, /// Every projected note (the denominator). pub total: usize, } -/// What a reindex **would** do — the `reindex --dry-run` preview, computed -/// read-only. The `would_*` keys are the honesty signal: this is a forecast. -/// -/// It forecasts work and nothing else. The dry-run's old columns (which notes would -/// be stamped, which files collide) existed because a real run wrote to the vault; -/// it no longer does (ADR-0004), so only the embedding is left to size. +/// What a reindex would do: the `reindex --dry-run` forecast, computed read-only. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ReindexPlan { - /// Notes a real reindex would project (every `.md` file the walk collects). + /// Notes a real reindex would project. pub would_index: usize, /// …of which this many would be (re)embedded. pub would_embed: usize, } -/// One neighbor of a note, resolved for display: the note at the other end of an -/// edge, with its path + title, so the adapter stays a dumb printer. +/// One neighbor of a note, resolved for display. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct NeighborView { /// The other note's vault-relative path — its identity (ADR-0003). @@ -103,11 +85,8 @@ pub struct NeighborView { pub created: Option, } -/// One outbound link a note authors at a **resource** (an image, a PDF — any -/// non-`.md` vault file), resolved for display. Surfaced on [`ExplainView`] so a -/// note's file links are visible from the note's side, not only as the resource's -/// backlinks (GH #22). Distinct from [`NeighborView`]: a resource has no title and -/// authors no edges, so these are always outbound. +/// One outbound link from a note to a resource (any non-`.md` file), resolved for +/// display (GH #22). Always outbound: resources author no edges. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResourceLinkView { pub path: String, @@ -122,11 +101,8 @@ pub struct ResourceLinkView { pub explanation: Option, } -/// One outbound link a note authored that resolves to **nothing** — no note and no -/// resource at its target (a `[[Hermes]]` naming a *folder*, or a typo). A note is -/// one `.md` file, so a folder is never a valid target; rather than drop such a link -/// B2 surfaces it as unresolved, so it reads as broken rather than missing (GH #12). -/// It has no `path` — that is the whole point. +/// One outbound link that resolves to no note or resource (a folder name, a typo), +/// surfaced so it reads as broken rather than missing (GH #12). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UnresolvedLink { /// The target exactly as written in the Markdown (`[[target]]`). @@ -136,26 +112,20 @@ pub struct UnresolvedLink { pub explanation: Option, } -/// A note's full connection picture for `b2 explain`: the note itself, every active -/// connection with its "why", its outbound resource links, and any unresolved -/// outbound links. A thin header over [`NeighborView`] — it reuses the per-edge -/// shape `neighbors` returns rather than a parallel one. +/// A note's full connection picture for `b2 explain`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ExplainView { pub path: String, pub title: Option, /// Outbound edges first, then inbound (as [`graph::neighbors`] orders them). pub connections: Vec, - /// Outbound links at resources — the third target kind (GH #22). + /// Outbound links at resources (GH #22). pub resources: Vec, /// Outbound links that resolved to nothing (GH #12). pub unresolved: Vec, } -/// A note's content + display metadata for a reader. Carries the note's identity, -/// the frontmatter fields worth showing a human, and the **raw Markdown body read -/// from disk** (the source of truth, not the projection) so an adapter renders -/// Markdown itself. A pure read — no embedding. +/// A note's raw Markdown from disk (not the projection) plus display metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct NoteView { pub path: String, @@ -166,33 +136,25 @@ pub struct NoteView { pub tags: Vec, /// The note's Markdown body (frontmatter stripped), verbatim from disk. pub body: String, - /// The raw frontmatter YAML **verbatim** (the text between the `---` fences, - /// fences excluded), not a re-serialization of the projected fields above — so - /// `b2_relations:` and any keys B2 doesn't model show as written. + /// The frontmatter YAML verbatim, fences excluded, so unmodelled keys show as + /// written. pub frontmatter: Option, - /// Whether that block *reads* as YAML metadata - /// ([`note::ParsedNote::frontmatter_readable`]): `false` means the raw bytes - /// above are shown verbatim but the projected fields came back empty. Every - /// read passes through here, so an external hand-edit surfaces the same warning - /// as an in-app save (GH #79). + /// Whether that block parses as YAML ([`note::ParsedNote::frontmatter_readable`]); + /// `false` means the projected fields are empty (GH #79). pub frontmatter_readable: bool, - /// blake3 of the **raw file bytes** at read time — the save-guard token - /// [`write`](Vault::write) validates, so a save can never silently clobber an - /// external edit. Whole-file, so *any* out-of-band change conflicts honestly. + /// blake3 of the whole file at read time: the save-guard token + /// [`write`](Vault::write) validates, so a save never clobbers an external edit. pub revision: String, } -/// One note's identity for a listing — `path` + `title`, with **no body**: enough -/// to show and open a note, cheap enough to fetch the whole vault at once. The body -/// is a separate [`read`](Vault::read) when a note is opened. +/// One note's identity for a listing, with no body. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct NoteSummary { pub path: String, pub title: Option, } -/// One resource's identity for the file tree — the per-kind sibling of -/// [`NoteSummary`], never a union type. +/// One resource's identity for the file tree. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResourceSummary { pub path: String, @@ -233,36 +195,25 @@ pub struct SearchResult { pub snippet: String, } -/// A search's evidence reading — [`Vault::search_evidence`]'s return, and what a -/// surface needs to decide what it vouches for (ADR-0015). +/// A search's evidence reading, for a surface to decide what it vouches for (ADR-0015). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct SearchEvidenceView { - /// The served results, whole and in fused order — evidence never reorders or - /// removes a row. + /// The served results, whole and in fused order. pub results: Vec, - /// Does the vault hold positive evidence for this query at the active model's - /// bar? `None` = no calibrated bar for this embedder, so no verdict is offered - /// rather than one guessed. + /// Whether the vault holds evidence for this query at the model's bar. `None`: no + /// calibrated bar for this embedder, so no verdict. pub vouched: Option, - /// Chunks in the index — the scale every term's weight is read against. + /// Chunks in the index. pub chunk_total: usize, /// Every query term with its document frequency, in query order. pub terms: Vec, - /// Best cosine between the query and any chunk vector — the dense half's - /// absolute claim. `None` on a projected-but-unembedded vault. + /// Best cosine between the query and any chunk. `None` on an unembedded vault. pub best_cos: Option, } -/// One served result with the provenance RRF discarded — which lists ranked its -/// chunk, and how near its vector actually was. -/// -/// The query-level verdict on [`SearchEvidenceView`] is what ADR-0015's "no matches" -/// rests on; **this** is what the per-hit tail bake-off is argued from (GH #206). -/// That bake-off has run — the labels carry the per-hit depth (`tail_relevant`) — -/// and **no tail fold shipped**: the fused order is not an evidence order, so every -/// admissible prefix cut proved vacuous, and the tail complaint is ordering work -/// (the reranker seam), not disclosure work. This stays an instrument reading, -/// re-priced on every `make eval` run. +/// One served result with the provenance RRF discarded: which lists ranked its chunk, +/// and how near its vector was. An instrument reading for `make eval`; no per-hit cut +/// shipped (GH #206). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct EvidencedResult { #[serde(flatten)] @@ -282,22 +233,16 @@ pub struct QueryTermView { pub term: String, /// Chunks matching this term alone; `0` = the vault has never seen the word. pub df: usize, - /// This term's weight in the coverage reading — `ln((chunks+1)/(df+1))`; near - /// zero for a ubiquitous word, largest for one the vault has never seen. + /// This term's weight, `ln((chunks+1)/(df+1))`. pub idf: f64, } -/// One **chunk-level** search hit — the sub-note view of [`search`](Vault::search). -/// Same retrieval, but ranked chunks are returned as-is instead of deduped up to -/// notes, so a caller can see *which passage* matched and at what rank. The client -/// is the out-of-CI retrieval eval (ADR-0013): note-rank scoring is blind to -/// sub-note quality, which is exactly what chunking levers move. Carries the chunk's -/// **full text**, which the eval's containment scoring anchors on. +/// One chunk-level search hit, not deduped to notes, with the chunk's full text. For +/// the retrieval eval, whose chunking levers note-rank scoring can't see (ADR-0013). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct ChunkSearchResult { pub path: String, - /// The chunk's heading breadcrumb (`"Fermentation > Vegetables"`), when the - /// chunker recorded one. + /// The chunk's heading breadcrumb (`"Fermentation > Vegetables"`), if any. pub heading_path: Option, /// Fused relevance score; higher is better. pub score: f64, @@ -305,106 +250,79 @@ pub struct ChunkSearchResult { pub text: String, } -/// The answer to one grounded-chat ask — flow ④'s display view: the model's -/// streamed text with its `[n]` citation markers resolved back to the vault. +/// One grounded-chat answer (flow ④), with its `[n]` markers resolved to the vault. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AnswerView { - /// The answer, verbatim as streamed — including any marker that did *not* - /// resolve. Model output is untrusted content, but it is never rewritten here; - /// an unmatched marker is simply absent from `citations`. + /// The answer verbatim as streamed, never rewritten; an unresolved marker is just + /// absent from `citations`. pub answer: String, - /// The resolved citations, ascending by marker; one entry per **distinct** - /// marker that names a real passage. + /// One citation per distinct marker naming a real passage, ascending. pub citations: Vec, - /// `true` when the caller's callback broke the stream mid-answer: `answer` then - /// holds the partial text honestly, and citations resolve over what arrived. + /// The caller broke the stream; `answer` holds the partial text. pub cancelled: bool, - /// The B2 tools that ran to produce this answer, in order — empty for a plain - /// [`ask`](Vault::ask), which retrieves once and offers the model none. Serialized - /// only when present, so existing JSON consumers see no change. + /// The B2 tools that ran, in order; empty for a plain [`ask`](Vault::ask). #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, } -/// One tool run during a tool-using chat turn — shown beside the answer so a human can -/// see what the explanation was built from. +/// One tool run during a chat turn, shown beside the answer. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ToolUseView { /// The tool's name (`b2_passage_pairs`, `b2_read`, …). pub name: String, - /// The call's arguments, as JSON text — the model's own, verbatim, for a call it made. + /// The call's arguments as JSON text, verbatim. pub arguments: String, - /// `true` for the lookup B2 made itself before asking the model anything; `false` - /// for a call the model chose. + /// `true` for B2's own lookup before asking the model; `false` for the model's call. pub seeded: bool, } -/// One resolved `[n]` citation: the passage's note, plus a one-line excerpt of the -/// cited passage as display evidence. +/// One resolved `[n]` citation: the note and an excerpt of the passage. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Citation { /// The marker as it appears in the answer text (1-based passage number). pub marker: usize, - /// Vault-relative path of the cited note — its identity (ADR-0003), and what an - /// adapter opens on click. A note renamed between answer and click goes stale, - /// exactly as any path handle does. + /// The cited note's path (ADR-0003). pub path: String, /// A one-line excerpt of the cited passage (its head, length-bounded). pub excerpt: String, } -/// One semantically-similar candidate for `b2 similar`: a note near the anchor in -/// embedding space that is **not** already connected to it, resolved for display -/// with the passage that made it similar. The machine finds the candidate, the human -/// decides whether to `link` it (ADR-0009). +/// One `b2 similar` candidate: a note near the anchor, not linked to it, with the +/// passage that made it similar. The human decides whether to link it (ADR-0009). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct SimilarView { pub path: String, pub title: Option, /// Best chunk-pair similarity to the anchor; higher is nearer. pub score: f64, - /// A one-line excerpt of the candidate chunk that achieved `score` — the - /// evidence for *why* it surfaced. + /// An excerpt of the candidate chunk that achieved `score`. pub evidence: String, - /// How far this candidate stands above the anchor's own candidate population — - /// its best-passage z (GH #192), and the one honest input for a displayed - /// *strength* band (GH #150). Non-increasing down the row order, so the band - /// never contradicts the ranking. It **gates nothing** (ADR-0014): the band is a - /// within-list grading, never a verdict on existence. The unit is load-bearing — - /// a band calibrated in the retired centroid unit grades every card down - /// (GH #182) — so a surface reads its landmarks off `make eval`'s calibration - /// block. `None` when no statistic was computed (a fake-embedded space, a pool - /// under the statistics minimum, or zero variance), which is the adapters' cue - /// to say the list is *ungraded* rather than let bare cards read as uniformly - /// weak; serialized only when present, so older JSON consumers see no change. + /// The best-passage z over the anchor's candidate population (GH #192), the input + /// for the strength band (GH #150). Non-increasing down the list; gates nothing + /// (ADR-0014). Band landmarks come from `make eval` (GH #182). `None` when + /// ungraded, which adapters must say. #[serde(skip_serializing_if = "Option::is_none")] pub z: Option, } -/// **Explain** for a *Similar & unlinked* card (GH #236): where one note stands in an -/// anchor's discovery field, and the passage pairs behind it. Model-free and read from -/// the same computation as [`Vault::similar`], so a served row's rank, z and best pair are -/// exactly the card's. Raw distances are never the point: every grade is a z on the same -/// yardstick as the strength band. +/// Explain for a Similar & unlinked card (GH #236): where a note stands in the anchor's +/// field and the passage pairs behind it. Same computation as [`Vault::similar`], so a +/// served row matches its card. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct SimilarExplainView { pub anchor: NoteSummary, pub candidate: NoteSummary, - /// The list length the explanation is read at: the surface's own, so `served` - /// answers "was this a card?". + /// The surface's list length, so `served` answers "was this a card?". pub limit: usize, pub standing: SimilarStanding, /// The candidate's z when it was scored in a graded field (the card's band input). pub z: Option, - /// The candidate's rank judged by whole-note average (stage 1), when it entered - /// stage 1. Far worse than a ranked standing's `rank` means one section carries the - /// match: a buried gem. + /// The whole-note (stage 1) rank. Far worse than `rank` means one section carries the + /// match. pub centroid_rank: Option, - /// Every scored note's z, nearest first: the field the band is relative to. Empty - /// when ungraded (a fake space, a small pool, no spread). + /// Every scored note's z, nearest first. Empty when ungraded. pub population: Vec, - /// One pair per candidate passage, nearest first, each matched to the anchor passage - /// nearest to it. Empty when either side has no vectors. + /// Each candidate passage with its nearest anchor passage, nearest first. pub pairs: Vec, /// Notes both sides already link with (either direction). pub shared_neighbors: Vec, @@ -418,12 +336,11 @@ pub enum SimilarStanding { SameNote, /// The anchor has no stored vectors yet: nothing to compare from. AnchorUnembedded, - /// Directly linked (either direction), so discovery leaves it out as already known. + /// Directly linked (either direction), so already known. Linked, /// The candidate has no stored vectors yet. Unembedded, - /// Its whole-note rank fell past the first-pass shortlist of `shortlist` notes, so - /// it was never scored passage by passage. + /// Its whole-note rank fell past the `shortlist`, so passages were never scored. NotShortlisted { shortlist: usize }, /// Scored: `rank` (1-based) of `of` scored notes; `served` when `rank <= limit`. Ranked { @@ -442,8 +359,7 @@ pub struct PassagePairView { pub score: f64, /// The pair's z on the card's yardstick, `None` when ungraded. pub z: Option, - /// Both passages hold the same text (a template, a copy): a perfect match that says - /// nothing about what the notes are about. + /// Both passages hold the same text (a template, a copy): a meaningless match. pub identical: bool, } @@ -456,18 +372,15 @@ pub struct PassageView { pub text: String, } -/// What [`write`](Vault::write) did: the saved note's path and the **new revision** -/// (blake3 of the final on-disk bytes) — the token the editor chains its next save -/// on, so sequential saves never self-conflict. +/// What [`write`](Vault::write) did: the saved path and the new revision the editor +/// chains its next save on. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct WriteReport { pub path: String, pub revision: String, } -/// What `b2 link` did: the committed typed edge, resolved for display. `created` is -/// `false` when the directed `(src, dst, type)` edge already existed, so nothing was -/// written (the command is idempotent). +/// What `b2 link` did. `created` is `false` when the edge already existed. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct LinkReport { pub src_path: String, diff --git a/crates/b2-core/tests/add.rs b/crates/b2-core/tests/add.rs index cf8f3c6..3f6c4aa 100644 --- a/crates/b2-core/tests/add.rs +++ b/crates/b2-core/tests/add.rs @@ -1,7 +1,5 @@ -//! `b2 add` — create a new note and project it (note CRUD's *create*). Driven -//! through the [`Vault`] façade against a temp vault, deterministic under the -//! FakeEmbedder. The new note must land on disk with a valid, stamped frontmatter -//! and be immediately live in the index (graph + search), from the Markdown alone. +//! `b2 add` and `create_note`: the new note lands on disk and is immediately live in the +//! index (graph and search). mod common; @@ -23,16 +21,14 @@ fn add_writes_a_minimal_note_and_projects_it() { ) .unwrap(); - // The `.md` suffix was appended; the path is the note's identity (L1). + // The `.md` suffix is appended; the path is the identity (L1). assert_eq!(report.path, "notes/widgets.md"); - // The file exists with exactly the template's frontmatter + body — and nothing - // else: projecting it added no key of B2's (W1). + // No key of B2's is added (W1). let file = root.join("notes/widgets.md"); let text = fs::read_to_string(&file).unwrap(); assert!(!text.contains("b2id"), "nothing is stamped: {text}"); - // `type:` is not seeded — the template stamps only what can't be reconstructed - // later; ingest defaults an absent type to "note" (GH #80). + // Ingest defaults an absent type to "note" (GH #80). assert!(!text.contains("type:"), "{text}"); assert!(text.contains(r#"title: "All about widgets""#), "{text}"); assert!(text.contains("created:"), "{text}"); @@ -41,11 +37,10 @@ fn add_writes_a_minimal_note_and_projects_it() { "{text}" ); - // It round-trips losslessly. let parsed = b2_core::note::parse(&text); assert_eq!(parsed.as_str(), text); - // Projected: it resolves in both authored link forms, and search finds it. + // Resolves in both authored link forms. assert!(vault.explain("notes/widgets").is_ok()); assert!(vault.explain(&report.path).is_ok()); let hits = vault.search("widgets", 10).unwrap(); @@ -60,7 +55,6 @@ fn add_projects_the_edges_its_body_authors() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // A note whose body links to an existing golden note. let report = vault .add_note( "notes/linker", @@ -69,7 +63,6 @@ fn add_projects_the_edges_its_body_authors() { ) .unwrap(); - // The outbound reference edge is live from the new note… let out = vault.neighbors(&report.path).unwrap(); assert!( out.iter().any(|n| n.direction == "outbound" @@ -77,7 +70,6 @@ fn add_projects_the_edges_its_body_authors() { && n.relation == "references"), "add must project the new note's body links: {out:?}" ); - // …and shows up as an inbound backlink on the target. let inbound = vault.neighbors(MEMORY_PATH).unwrap(); assert!( inbound @@ -100,7 +92,7 @@ fn add_creates_missing_parent_directories() { #[test] fn add_works_on_a_never_reindexed_vault() { - // No prior `reindex`: `add` shapes the index and projects the note itself. + // `add` shapes the index itself. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); fs::create_dir_all(&root).unwrap(); @@ -111,7 +103,6 @@ fn add_works_on_a_never_reindexed_vault() { .unwrap(); assert_eq!(report.path, "first.md"); assert!(root.join("first.md").is_file()); - // Immediately searchable. let hits = vault.search("Body", 10).unwrap(); assert!(hits.iter().any(|h| h.path == "first.md"), "{hits:?}"); } @@ -121,13 +112,11 @@ fn add_refuses_to_clobber_an_existing_file() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // Onto an existing golden note. let err = vault .add_note("concepts/memory.md", None, None) .unwrap_err(); assert!(matches!(err, Error::AddTargetExists(p) if p == "concepts/memory.md")); - // Onto a note we just added (and its content is left intact). vault.add_note("notes/dup", None, Some("original")).unwrap(); let before = fs::read_to_string(root.join("notes/dup.md")).unwrap(); let err = vault @@ -141,8 +130,7 @@ fn add_refuses_to_clobber_an_existing_file() { ); } -/// The refusal is the create itself (one create-new open, no check before it), so it -/// covers whatever occupies the path — a folder named like the note included. +/// The refusal is the create-new open itself, so it covers a folder at the path too. #[test] fn add_refuses_a_path_a_folder_occupies() { let tmp = tempfile::TempDir::new().unwrap(); @@ -169,16 +157,14 @@ fn create_note_writes_a_minimal_note_model_free() { let report = vault.create_note("inbox/idea").unwrap(); assert_eq!(report.path, "inbox/idea.md"); - // On disk: the minimal frontmatter (no title — the display title is the - // filename, data-model.md §1), body-less, in a freshly-created dir. + // No title: the display title is the filename (data-model.md §1). let text = fs::read_to_string(root.join("inbox/idea.md")).unwrap(); assert!(!text.contains("b2id"), "nothing is stamped: {text}"); - // `type:` is not seeded — ingest defaults it to "note" (GH #80). + // GH #80. assert!(!text.contains("type:"), "{text}"); assert!(text.contains("created:"), "{text}"); assert!(!text.contains("title:"), "{text}"); - // Projected: it resolves in both authored link forms, and the tree lists it. assert!(vault.explain("inbox/idea").is_ok()); assert!(vault.explain(&report.path).is_ok()); assert!(vault @@ -187,11 +173,8 @@ fn create_note_writes_a_minimal_note_model_free() { .iter() .any(|n| n.path == "inbox/idea.md")); - // Model-free: the embedding space is untouched. Measured on the vector table itself - // rather than on the coverage fraction, which cannot see this: a body-less note has - // no chunks, so it joins the embedded count the moment it is projected — vacuously, - // having nothing to wait for — and would hide a vector this call had no business - // storing. Any later embed/reindex owns vectors, never `create_note`. + // Model-free. Measured on the vector table: a body-less note counts as embedded + // vacuously, so the coverage fraction would hide a stray vector. let conn = index_conn(&root); assert_eq!(count(&conn, "chunks WHERE note_path = 'inbox/idea.md'"), 0); assert_eq!( @@ -200,7 +183,6 @@ fn create_note_writes_a_minimal_note_model_free() { "create_note must never embed" ); - // And the note is in the projection, counted as needing nothing. let after = vault.embed_status().unwrap(); assert_eq!(after.total, before.total + 1); assert_eq!( diff --git a/crates/b2-core/tests/ask.rs b/crates/b2-core/tests/ask.rs index 2e45008..b1123c4 100644 --- a/crates/b2-core/tests/ask.rs +++ b/crates/b2-core/tests/ask.rs @@ -1,8 +1,5 @@ -//! Flow ④ — grounded chat (`Vault::ask`, GH #151/#153): condense → retrieve → -//! assemble → stream → cite, asserted end-to-end against the deterministic -//! [`FakeLlm`] (E2: no candle, no tokio, no network). Like `tests/search.rs`, -//! these prove the **plumbing** — orchestration, degrades, cancellation, -//! citation resolution — not answer quality, which is a real-model eval concern. +//! Flow ④, grounded chat (`Vault::ask`, GH #151/#153): condense, retrieve, assemble, +//! stream, cite. Plumbing only, against the deterministic [`FakeLlm`] (E2). mod common; @@ -19,8 +16,7 @@ use std::fs; use std::ops::ControlFlow; use std::path::PathBuf; -/// Wraps a provider and counts its calls — how the suite observes whether the -/// condensation step ran without reaching into the orchestration. +/// Counts a provider's calls, to observe whether condensation ran. struct Counting<'a> { inner: &'a dyn LlmProvider, calls: Cell, @@ -49,8 +45,7 @@ impl LlmProvider for Counting<'_> { } } -/// A purpose-built two-note vault whose notes share no terms, so which query -/// retrieval actually used is observable from which note ranks first. +/// Two notes sharing no terms, so the note ranked first shows which query retrieval used. fn two_topic_vault(dir: &std::path::Path) -> (Vault, PathBuf) { let root = dir.join("vault"); fs::create_dir_all(&root).unwrap(); @@ -84,15 +79,12 @@ fn ask_grounds_the_answer_and_resolves_citations_end_to_end() { ) .unwrap(); - // The answer is exactly what streamed up through the callback, uncancelled. assert_eq!(view.answer, streamed); assert!(!view.cancelled); assert!(view.answer.starts_with("Grounded in [1]")); - // FakeLlm cites every passage it was handed, so citations mirror retrieval: - // markers 1..=k ascending, each resolved to a real note path + evidence. The - // path IS the citation's handle (L1) — as durable as any path, which is the - // trade GH #170 made deliberately. + // FakeLlm cites every passage, so citations mirror retrieval. The path is the + // citation's handle (L1, GH #170). assert!(!view.citations.is_empty()); for (i, c) in view.citations.iter().enumerate() { assert_eq!(c.marker, i + 1, "markers are ascending and 1-based"); @@ -104,8 +96,7 @@ fn ask_grounds_the_answer_and_resolves_citations_end_to_end() { ); assert!(!c.excerpt.is_empty(), "the cited passage is the evidence"); } - // 'forgetting' lives only in spaced-repetition.md — the keyword match must - // be among the grounding passages, hence among the citations. + // 'forgetting' lives only in spaced-repetition.md. assert!(view .citations .iter() @@ -135,11 +126,10 @@ fn breaking_mid_stream_reports_a_truncated_answer_honestly() { view.answer, "Grounded in [1]", "the partial text includes the token the break landed on" ); - // Citations resolve over what actually arrived — exactly marker 1. + // Citations resolve over what actually arrived. assert_eq!(view.citations.len(), 1); assert_eq!(view.citations[0].marker, 1); - // Breaking before any marker arrives leaves no citations at all. let mut first = true; let early = vault .ask(&FakeLlm, "forgetting curve", &[], &mut |_tok| { @@ -161,7 +151,6 @@ fn a_single_turn_ask_skips_condensation_and_a_follow_up_pays_for_it() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // Single turn: exactly one provider call — the answer itself. let counting = Counting::new(&FakeLlm); let mut sink = String::new(); vault @@ -174,7 +163,6 @@ fn a_single_turn_ask_skips_condensation_and_a_follow_up_pays_for_it() { .unwrap(); assert_eq!(counting.calls.get(), 1, "no condensation on a single turn"); - // A follow-up condenses first: two calls, one answer. let history = [ ChatTurn::user("what is the forgetting curve?"), ChatTurn::assistant("It describes memory decay over time [1]."), @@ -192,12 +180,10 @@ fn a_single_turn_ask_skips_condensation_and_a_follow_up_pays_for_it() { assert_eq!(counting.calls.get(), 2, "condense, then answer"); } -/// A provider whose condensation call fails or comes back cancelled with -/// garbage, while chat behaves like [`FakeLlm`]. -/// Step 0 must degrade to the raw question — never break chat, never retrieve -/// on the garbage. +/// A condense call that fails or returns cancelled garbage; chat behaves like [`FakeLlm`]. +/// Condensation must degrade to the raw question. struct BrokenCondense { - /// `None` → error the condense call; `Some(text)` → return it *cancelled*. + /// `None` errors the condense call; `Some(text)` returns it cancelled. cancelled_text: Option<&'static str>, } @@ -230,8 +216,6 @@ fn condensation_failure_degrades_to_the_raw_question() { let (vault, _root) = two_topic_vault(tmp.path()); let history = [ChatTurn::user("earlier turn")]; - // The condense call errors outright: chat must still answer, and the top - // passage must be the raw question's note — proof retrieval used it. let mut sink = String::new(); let view = vault .ask( @@ -249,8 +233,7 @@ fn condensation_failure_degrades_to_the_raw_question() { "the raw question drove retrieval, so its keyword note ranks first" ); - // The condense call returns *cancelled* text naming the other note: the - // partial text must be discarded, not used as the retrieval query. + // Cancelled text naming the other note must be discarded. let mut sink = String::new(); let view = vault .ask( @@ -274,8 +257,7 @@ fn ask_answers_bm25_only_on_a_projected_but_unembedded_vault() { let (vault, _root) = opened_vault(tmp.path()); vault.project(false).unwrap(); - // No embedding space exists — retrieval is keyword-only (M4), so the only - // grounding passages are the actual term matches, and chat still works. + // No embedding space: retrieval is keyword-only (M4). let mut streamed = String::new(); let view = vault .ask(&FakeLlm, "forgetting", &[], &mut stream_into(&mut streamed)) @@ -290,9 +272,7 @@ fn ask_answers_bm25_only_on_a_projected_but_unembedded_vault() { ); } -/// Emits a fixed answer citing one real and one hallucinated marker, so the -/// resolution rules are observable end-to-end: the real one resolves, the -/// fake one contributes nothing, and the answer text is never rewritten. +/// A fixed answer citing one real and one hallucinated marker. struct Hallucinating; impl LlmProvider for Hallucinating { @@ -346,9 +326,8 @@ fn a_hallucinated_marker_resolves_to_nothing_and_stays_in_the_text() { assert_eq!(view.citations[0].marker, 1); } -/// Empty retrieval is not condensation: a grounded request whose passage list -/// is empty (nothing in the vault matched) gets the honest no-evidence answer, -/// not an echo of the question — `RequestKind` is what keeps the two apart. +/// A grounded request with no passages is not a condensation (`RequestKind` keeps them +/// apart), so it gets the no-evidence answer, not an echo. #[test] fn empty_retrieval_answers_no_evidence_rather_than_echoing() { let tmp = tempfile::TempDir::new().unwrap(); @@ -375,11 +354,8 @@ fn empty_retrieval_answers_no_evidence_rather_than_echoing() { assert!(!view.cancelled); } -/// A failed *answer* call is a real error (contrast condensation, which -/// degrades): the adapter needs to say "can't reach the model server", not -/// render an empty answer as if the vault had nothing to say. Whatever variant -/// the provider returns is normalized to [`Error::Llm`], so adapters have one -/// variant to match for that message. +/// A failed answer call is a real error, unlike condensation. Every variant is normalized +/// to [`Error::Llm`], so adapters match one. struct FailingChat { err: fn() -> Error, } @@ -414,8 +390,6 @@ fn a_failed_answer_call_surfaces_as_an_llm_error() { .unwrap_err(); assert!(matches!(err, Error::Llm(_))); - // A provider leaking a non-Llm variant is normalized at the seam boundary, - // so the documented contract holds whatever the implementation returns. let err = vault .ask( &FailingChat { @@ -432,8 +406,7 @@ fn a_failed_answer_call_surfaces_as_an_llm_error() { ); } -/// Chat is a reader with `search`'s posture: a mismatched embedding space -/// fails fast rather than grounding the answer on incomparable vectors. +/// Chat never grounds on incomparable vectors. #[test] fn ask_shares_searchs_model_mismatch_fail_fast() { use b2_core::embed::FakeEmbedder; @@ -474,7 +447,6 @@ fn the_grounded_request_numbers_passages_and_ends_on_the_question() { ]; let req = chat::build_request("second question", &history, passages); - // The conversation the provider sees: history in order, question last. assert_eq!(req.turns.len(), 3); assert_eq!(req.turns[0].content, "first question"); assert_eq!(req.turns[1].role, Role::Assistant); @@ -484,8 +456,6 @@ fn the_grounded_request_numbers_passages_and_ends_on_the_question() { (Role::User, "second question") ); - // The rendered system message: the grounded instruction, then the numbered - // block — 1-based markers, path + breadcrumb + verbatim text per passage. let msg = req.system_message(); assert!(msg.starts_with(GROUNDED_SYSTEM_PROMPT)); assert!(msg.contains("I don't find that in your notes")); @@ -494,11 +464,9 @@ fn the_grounded_request_numbers_passages_and_ends_on_the_question() { assert!(msg.contains("[2] notes/spaced-repetition.md")); assert!(msg.contains("Review at increasing intervals.")); - // The no-evidence sentence the fake obeys is the one the prompt dictates — - // the two are pinned together so neither can drift. + // Pinned together so neither can drift. assert!(GROUNDED_SYSTEM_PROMPT.contains(NO_EVIDENCE_ANSWER)); - // A condensation request renders without a passage block. let condense = ChatRequest { kind: RequestKind::Condense, system: CONDENSE_SYSTEM_PROMPT.to_string(), @@ -512,15 +480,13 @@ fn the_grounded_request_numbers_passages_and_ends_on_the_question() { #[test] fn cited_markers_keeps_real_markers_and_drops_everything_else() { - // Distinct, ascending, deduped; multi-digit parses. assert_eq!( chat::cited_markers("[2] then [1], [2] again", 5), vec![1, 2] ); assert_eq!(chat::cited_markers("deep cite [12]!", 12), vec![12]); - // Out of range (0 is out of range — numbering is 1-based), overflow-long - // digits, unclosed and empty brackets: all resolve to nothing. + // Numbering is 1-based, so 0 is out of range. assert_eq!( chat::cited_markers("[0] [6] [99999999999999999999] [", 5), Vec::::new() @@ -534,6 +500,5 @@ fn cited_markers_keeps_real_markers_and_drops_everything_else() { Vec::::new() ); - // Zero passages: nothing can resolve, whatever the model claims. assert_eq!(chat::cited_markers("[1]", 0), Vec::::new()); } diff --git a/crates/b2-core/tests/cancel.rs b/crates/b2-core/tests/cancel.rs index 32e1838..389d2eb 100644 --- a/crates/b2-core/tests/cancel.rs +++ b/crates/b2-core/tests/cancel.rs @@ -1,9 +1,6 @@ -//! Cooperative-cancel of a reindex: the embed -//! phase can be stopped at a batch boundary via `ControlFlow::Break`, and the result -//! is a **consistent, resumable** index — every note has chunks + FTS + edges (keyword -//! search + graph complete), only a *prefix* has vectors, and an incremental re-run -//! embeds exactly the remainder (`incremental ≡ eventual full`). Model-free: the fake -//! embedder makes cancel-after-N-batches deterministic. +//! Cooperative cancel of a reindex at a batch boundary leaves a consistent, resumable +//! index: keyword search and graph complete, a prefix of vectors, and a re-run embeds +//! exactly the remainder. mod common; @@ -22,16 +19,14 @@ fn cancel_after_first_batch_leaves_a_consistent_resumable_index() { let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); let embedder = FakeEmbedder::new(64); - // Break at the very first embed batch. The golden vault's notes are small (one - // batch each), so this embeds the first note only. + // The golden notes are one batch each, so this embeds the first note only. let cfg = ChunkConfig::default(); let ctx = ProjectionCtx::new(&conn, &vault, &cfg); project_vault(ctx, false).unwrap(); let outcome = embed_vault(&conn, &embedder, &mut |_| ControlFlow::Break(())).unwrap(); assert!(outcome.cancelled, "the run reports itself cancelled"); - // §5.1 — keyword + graph are COMPLETE at the cancel point: every note has chunks, - // FTS mirrors them, and every authored edge is projected (Phase 2 runs post-cancel). + // §5.1: keyword and graph are complete at the cancel point. let chunks = count(&conn, "chunks"); assert!(chunks > 0); assert_eq!( @@ -44,15 +39,13 @@ fn cancel_after_first_batch_leaves_a_consistent_resumable_index() { "typed graph complete after cancel" ); - // …only VECTORS are partial: a prefix of chunks embedded, the rest pending. let vecs_after_cancel = count(&conn, "embeddings"); assert!( vecs_after_cancel > 0 && vecs_after_cancel < chunks, "a prefix embedded, the remainder pending: {vecs_after_cancel}/{chunks}" ); - // §5.2 — resume: an ordinary (uncancelled) reindex embeds exactly the remainder and - // finishes. No corruption, no double-work. + // §5.2: resume embeds exactly the remainder. project_vault(ctx, false).unwrap(); let resumed = embed_vault(&conn, &embedder, &mut |_| ControlFlow::Continue(())).unwrap(); assert!(!resumed.cancelled); @@ -68,8 +61,7 @@ fn facade_report_is_honest_about_a_cancelled_run() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _) = opened_vault(tmp.path()); - // Cancel at the first batch: fewer notes embed than are indexed, and `cancelled` - // is set — the counts describe the partial work truthfully (§3). + // The counts describe the partial work truthfully (§3). let partial = vault .reindex_with_progress(false, &mut |_| ControlFlow::Break(())) .unwrap(); @@ -82,7 +74,6 @@ fn facade_report_is_honest_about_a_cancelled_run() { partial.indexed ); - // Re-running to completion embeds exactly the remainder and is not cancelled. let finished = vault.reindex().unwrap(); assert!(!finished.cancelled); assert_eq!( @@ -91,7 +82,6 @@ fn facade_report_is_honest_about_a_cancelled_run() { "the re-run embeds only the notes the cancel left unfinished" ); - // And a second clean reindex now re-embeds nothing (fully consistent, incremental). let noop = vault.reindex().unwrap(); assert_eq!(noop.embedded, 0, "nothing left to embed after resume"); assert!(!noop.cancelled); diff --git a/crates/b2-core/tests/chunks.rs b/crates/b2-core/tests/chunks.rs index 65d9dd2..f6fb6fb 100644 --- a/crates/b2-core/tests/chunks.rs +++ b/crates/b2-core/tests/chunks.rs @@ -1,6 +1,5 @@ -//! The qmd-heuristic chunker (index-engine.md, issue #19): size-targeted, -//! overlapping, Markdown-aware chunks carrying a `heading_path`. Two DB-level tests keep -//! the projection wiring honest; the rest exercise `chunk_body` as the pure function it is. +//! The qmd-heuristic chunker (index-engine.md, GH #19): size-targeted, overlapping, +//! Markdown-aware chunks carrying a `heading_path`. mod common; @@ -23,9 +22,7 @@ fn chunks_are_projected_for_each_note() { .unwrap(); assert!(total >= 2, "at least one chunk per golden note"); - // Under qmd sizing the whole small spaced-repetition note (prose + the Relations - // list, well under the 450-token target) coalesces into a single chunk — the - // paragraph splitter's two-chunk split is exactly the regression #19 fixes. + // A small note, well under target, coalesces into one chunk (GH #19). let srs_chunks: i64 = conn .query_row( "SELECT COUNT(*) FROM chunks WHERE note_path = ?1", @@ -35,7 +32,6 @@ fn chunks_are_projected_for_each_note() { .unwrap(); assert_eq!(srs_chunks, 1); - // char offsets must address the slice that produced the chunk text. let (start, end, text): (i64, i64, String) = conn .query_row( "SELECT char_start, char_end, text FROM chunks WHERE note_path = ?1 AND seq = 0", @@ -55,8 +51,6 @@ fn fts_index_tracks_chunks_and_matches_body_text() { let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); ingest_vault(&conn, &vault, &FakeEmbedder::default()).unwrap(); - // 'forgetting' appears only in spaced-repetition's Relations text (now folded - // into that note's single chunk); the match still resolves to that note. let note: String = conn .query_row( "SELECT c.note_path FROM chunks_fts f @@ -71,10 +65,7 @@ fn fts_index_tracks_chunks_and_matches_body_text() { #[test] fn vault_chunk_config_reaches_projection() { - // The eval's sweep seam (the eval harness, crates/b2-embed/evals/): a non-default ChunkConfig - // set on the Vault must actually shape the cut — `set_chunk_config` + - // `project(force)` on the same vault re-chunks under the new policy, so a - // much finer target yields more chunks than the default did. Model-free. + // The eval's sweep seam: a ChunkConfig set on the Vault must shape the cut. let tmp = tempfile::TempDir::new().unwrap(); let (mut vault, vault_dir) = opened_vault(tmp.path()); vault.project(false).unwrap(); @@ -113,7 +104,6 @@ fn reindexing_a_note_does_not_leave_stale_fts_rows() { }; let before = fts_count(&conn); - // Re-ingesting must replace, not accumulate (delete sentinel + reinsert). ingest_vault(&conn, &vault, &FakeEmbedder::default()).unwrap(); assert_eq!( before, @@ -126,7 +116,7 @@ fn reindexing_a_note_does_not_leave_stale_fts_rows() { // chunk_body — the pure function (spec §6 Step 1). Deterministic, model-free. // -------------------------------------------------------------------------- -/// Estimated tokens by the same `chars/4` proxy the chunker sizes with. +/// Estimated tokens by the chunker's own `chars/4` proxy. fn est_tokens(text: &str, cfg: &ChunkConfig) -> f64 { text.chars().count() as f64 / cfg.chars_per_token as f64 } @@ -140,7 +130,7 @@ fn empty_or_blank_body_yields_no_chunks() { #[test] fn char_ranges_address_the_exact_text() { - // The anchoring invariant: body[char_start..char_end] == text (prepend off). + // With prepend off. let body = "# Title\n\nA first paragraph about memory and recall.\n\n\ ## Section\n\nSome more content under a section heading here.\n"; let cfg = ChunkConfig::default(); @@ -152,22 +142,19 @@ fn char_ranges_address_the_exact_text() { #[test] fn a_heading_and_its_section_land_in_one_chunk() { - // The core regression (#19): a bare `## Threat model` must not be its own chunk — - // it coalesces with the section body it introduces. + // GH #19: a bare heading must not be its own chunk. let body = "## Threat model\n\n\ The adversary can read any file left on the disk after a theft.\n"; let chunks = chunk_body(body, &ChunkConfig::default()); assert_eq!(chunks.len(), 1, "heading + section is one chunk"); assert!(chunks[0].text.contains("## Threat model")); assert!(chunks[0].text.contains("The adversary can read")); - // No chunk is the bare heading alone. assert!(!chunks.iter().any(|c| c.text.trim() == "## Threat model")); } #[test] fn heading_path_tracks_nested_headings() { - // A small target forces each section into its own chunk; the deep chunk carries - // the full H1 › H2 › H3 breadcrumb. Overlap off so the deep chunk starts clean. + // A small target gives each section its own chunk; no overlap, so each starts clean. let cfg = ChunkConfig { target_tokens: 8, overlap_frac: 0.0, @@ -190,7 +177,6 @@ fn heading_path_tracks_nested_headings() { Some("Top > Section A > Sub A1") ); - // A chunk under the H1 intro (before any H2) carries just the H1. let top = chunks .iter() .find(|c| c.text.contains("Intro prose")) @@ -200,8 +186,7 @@ fn heading_path_tracks_nested_headings() { #[test] fn body_before_any_heading_has_no_path() { - // Prose, then a heading. The single chunk starts in the headingless prose, - // so its path is None. + // The single chunk starts in headingless prose. let body = "Spaced repetition exploits the forgetting curve.\n\n## Notes\n- more prose\n"; let chunks = chunk_body(body, &ChunkConfig::default()); assert_eq!(chunks.len(), 1); @@ -210,8 +195,7 @@ fn body_before_any_heading_has_no_path() { #[test] fn large_body_splits_near_target_and_never_far_over() { - // Many paragraphs → several chunks, each clustered near the target and never - // wildly past the proxy cap (target + one backscan window of slack). + // The cap is target plus one backscan window of slack. let para = "This is a paragraph of ordinary prose that carries a fair amount \ of content so that several of them together comfortably exceed the \ chunk size target and force the chunker to make real cuts.\n\n"; @@ -228,7 +212,6 @@ fn large_body_splits_near_target_and_never_far_over() { est_tokens(&c.text, &cfg) ); } - // The interior chunks should sit in the neighbourhood of the target, not tiny. let interior_ok = chunks[..chunks.len() - 1] .iter() .all(|c| est_tokens(&c.text, &cfg) >= cfg.target_tokens as f64 * 0.5); @@ -266,14 +249,14 @@ fn zero_overlap_partitions_without_gaps() { let chunks = chunk_body(&body, &cfg); assert!(chunks.len() > 1); for pair in chunks.windows(2) { - // Boundaries touch (allowing for trimmed whitespace between them). + // Allowing for trimmed whitespace between them. assert!(pair[1].char_start >= pair[0].char_end); } } #[test] fn a_giant_single_line_paragraph_still_splits() { - // No blank lines, no newlines — only word-boundary fallbacks can cut it. + // Only word-boundary fallbacks can cut it. let body = "word ".repeat(2000); let cfg = ChunkConfig::default(); let chunks = chunk_body(&body, &cfg); @@ -295,7 +278,6 @@ fn seq_is_contiguous_from_zero() { #[test] fn hash_inside_a_code_fence_is_not_a_heading() { - // A shell comment inside a fence must not corrupt the breadcrumb. let cfg = ChunkConfig { target_tokens: 8, overlap_frac: 0.0, @@ -308,7 +290,6 @@ fn hash_inside_a_code_fence_is_not_a_heading() { ```\n\n\ Trailing prose distinctively marked with a zebra token here.\n"; let chunks = chunk_body(body, &cfg); - // No chunk's path is ever led by the shell comment text. for c in &chunks { let path = c.heading_path.as_deref().unwrap_or(""); assert!( @@ -316,13 +297,11 @@ fn hash_inside_a_code_fence_is_not_a_heading() { "fence comment leaked into heading_path: {path:?}" ); } - // The trailing prose stays under the real heading. let zebra = chunks.iter().find(|c| c.text.contains("zebra")).unwrap(); assert_eq!(zebra.heading_path.as_deref(), Some("Real heading")); } -/// Count fence-delimiter lines (```` ``` ````/`~~~`) in a chunk — an odd count means -/// the chunk carries an unbalanced fence, i.e. a code block was bisected. +/// Fence-delimiter lines in a chunk; an odd count means a code block was bisected. fn fence_lines(text: &str) -> usize { text.lines() .filter(|l| { @@ -334,9 +313,7 @@ fn fence_lines(text: &str) -> usize { #[test] fn a_code_fence_is_never_split_across_chunks() { - // #41: a code block larger than the backscan window spans a target boundary, so a - // forced cut would land inside it. The guard must keep every chunk's fences - // balanced and pull the whole block into a single chunk. + // GH #41: a block larger than the backscan window spans a target boundary. let cfg = ChunkConfig { target_tokens: 30, overlap_frac: 0.15, @@ -365,7 +342,6 @@ fn a_code_fence_is_never_split_across_chunks() { c.text ); } - // The block is pushed past its closing fence into one chunk (first line to last). let block = chunks .iter() .find(|c| c.text.contains("fn alpha()")) @@ -379,8 +355,7 @@ fn a_code_fence_is_never_split_across_chunks() { #[test] fn a_table_keeps_its_header_and_rows_together() { - // #41: a table larger than the target spans a boundary; the guard must keep the - // header row and every data row in the same chunk (no orphaned rows). + // GH #41: a table larger than the target spans a boundary. let cfg = ChunkConfig { target_tokens: 30, overlap_frac: 0.0, @@ -399,7 +374,6 @@ fn a_table_keeps_its_header_and_rows_together() { let chunks = chunk_body(body, &cfg); assert!(chunks.len() > 1, "the body must actually split"); - // Any chunk carrying a data row must also carry the header — the table is whole. for c in &chunks { let has_row = ["| Ann", "| Bob", "| Cy", "| Dot", "| Eve"] .iter() @@ -413,7 +387,6 @@ fn a_table_keeps_its_header_and_rows_together() { ); } } - // And the header chunk holds the full table (first row through last). let table = chunks .iter() .find(|c| c.text.contains("| Name |")) @@ -423,16 +396,13 @@ fn a_table_keeps_its_header_and_rows_together() { #[test] fn a_setext_underline_is_not_mistaken_for_a_table() { - // `---` under a line is a setext heading / rule, not a table delimiter (no `|`), - // so it forms no protected region and normal boundary scoring applies. + // `---` with no `|` is a setext underline, not a table delimiter. let cfg = ChunkConfig { target_tokens: 12, overlap_frac: 0.0, ..ChunkConfig::default() }; let body = "Alpha heading\n---\n\nSome prose that follows the setext underline here.\n"; - // Just needs to chunk without panicking and address exact slices (no false region - // pushing the boundary somewhere the text no longer matches). for c in chunk_body(body, &cfg) { assert_eq!(&body[c.char_start..c.char_end], c.text); } @@ -440,8 +410,7 @@ fn a_setext_underline_is_not_mistaken_for_a_table() { #[test] fn prepend_heading_path_seeds_the_embedded_text() { - // D3's eval knob: with prepend on, the breadcrumb leads the embedded text; the - // char range keeps addressing the underlying body slice. + // D3's eval knob. let cfg = ChunkConfig { target_tokens: 8, overlap_frac: 0.0, @@ -456,18 +425,12 @@ fn prepend_heading_path_seeds_the_embedded_text() { hit.text.starts_with(hp), "embedded text leads with the path" ); - // The stored range still points at the real slice (which excludes the prefix). + // The stored range excludes the prefix. assert!(!body[hit.char_start..hit.char_end].starts_with(hp)); } -/// `weights` is a real lever, not decoration — the scorer reads the numbers from -/// the config rather than hard-coding the qmd gradient. -/// -/// Two halves: with the default gradient (H2 = 90 ≫ blank_line = 20) a cut near a -/// section break snaps to the heading, so each section keeps its own breadcrumb; -/// demote headings *below* every other boundary and the same body cuts somewhere -/// else. Only `weights.heading` differs between the two runs, so the difference is -/// attributable to it alone. +/// The scorer reads `weights` from the config, not a hard-coded gradient: zeroing only +/// `weights.heading` moves the cuts. #[test] fn heading_weight_is_a_lever_on_where_the_cut_lands() { let cfg = ChunkConfig { diff --git a/crates/b2-core/tests/common/mod.rs b/crates/b2-core/tests/common/mod.rs index 8a0115f..b2d52be 100644 --- a/crates/b2-core/tests/common/mod.rs +++ b/crates/b2-core/tests/common/mod.rs @@ -1,10 +1,8 @@ //! Shared helpers for the integration tests (golden-vault fixtures). //! -//! Every test binary that says `mod common;` gets this whole file, so it is deliberately -//! small and dependency-light. Anything only one file wants stays in that file — including, -//! on purpose, the tracing `MakeWriter` capture `tests/logging.rs` and -//! `tests/discover_query_count.rs` each define: hoisting it would make all ~28 test binaries -//! link `tracing-subscriber` to serve two that already need their own binary anyway. +//! Every test binary that says `mod common;` compiles this whole file, so it stays small +//! and dependency-light. The tracing capture in `logging.rs` and `discover_query_count.rs` +//! stays there, so the other binaries don't link `tracing-subscriber`. #![allow(dead_code)] use b2_core::embed::{Embedder, FakeEmbedder}; @@ -17,11 +15,8 @@ use std::fs; use std::ops::ControlFlow; use std::path::{Path, PathBuf}; -/// Vault-relative paths of the two golden-vault notes (data-model.md §8) — which -/// **are** their identities (L1, GH #170), so these constants are what the suite -/// asserts against. The `MEMORY_ID`/`SRS_ID` ULIDs they replaced, and the -/// `FixedId`/`SeqId` generators that made a stamped id assertable, went with the -/// stamp: the core mints nothing, so there is no injected id seam left to fake. +/// The two golden-vault notes (data-model.md §8), by path, which is their identity (L1, +/// GH #170). pub const MEMORY_PATH: &str = "concepts/memory.md"; pub const SRS_PATH: &str = "notes/spaced-repetition.md"; @@ -39,11 +34,7 @@ pub fn copy_dir(src: &Path, dst: &Path) { } } -/// Copy the committed golden vault into `dst`, so no test can mutate the repo -/// fixtures. Ingest no longer writes to a vault at all (W1), so this is now belt -/// and braces rather than the load-bearing guard it was — kept because a test that -/// edits a committed fixture is a bad idea under any write posture, and CI's -/// `git diff --exit-code` step would fail on one regardless. +/// Copy the committed golden vault into `dst`, so no test can mutate the repo fixtures. pub fn golden_vault_copy(dst: &Path) { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); copy_dir(&src, dst); @@ -51,9 +42,7 @@ pub fn golden_vault_copy(dst: &Path) { // --- façade fixtures ------------------------------------------------------------- -/// A golden-vault copy under `dir/vault`, opened but **not** reindexed — the -/// index-free starting point (structure reads, "before the first reindex" cases). -/// Returns `(vault, vault_root)`. +/// A golden-vault copy under `dir/vault`, opened but not reindexed. pub fn opened_vault(dir: &Path) -> (Vault, PathBuf) { let root = dir.join("vault"); golden_vault_copy(&root); @@ -61,9 +50,7 @@ pub fn opened_vault(dir: &Path) -> (Vault, PathBuf) { (vault, root) } -/// A golden-vault copy under `dir/vault`, opened and fully reindexed (projected + -/// fake-embedded) — the ordinary starting point for façade tests. Returns -/// `(vault, vault_root)`. +/// A golden-vault copy under `dir/vault`, opened and reindexed with the fake embedder. pub fn reindexed_vault(dir: &Path) -> (Vault, PathBuf) { let (vault, root) = opened_vault(dir); vault.reindex().unwrap(); @@ -72,8 +59,7 @@ pub fn reindexed_vault(dir: &Path) -> (Vault, PathBuf) { // --- index read-back ------------------------------------------------------------- -/// A second connection onto a vault root's index, for assertions the façade does -/// not surface (raw rows, table counts). +/// A second connection onto a vault's index, for rows the façade doesn't surface. pub fn index_conn(root: &Path) -> Connection { open(&root.join(".b2").join("b2.sqlite")).unwrap() } @@ -84,8 +70,7 @@ pub fn count(conn: &Connection, table: &str) -> i64 { .unwrap() } -/// A note's inbound set as sortable `(label, src_path)` pairs — the shape the graph -/// exposes, and the thing a move must carry to the destination intact. +/// A note's inbound set as sorted `(label, src_path)` pairs. pub fn inbound(vault: &Vault, note_ref: &str) -> Vec<(String, String)> { let mut ns: Vec<(String, String)> = vault .neighbors(note_ref) @@ -109,8 +94,7 @@ pub fn write_note(vault: &Path, name: &str, body: &str) { .unwrap(); } -/// A token callback that keeps streaming and discards every token — the plain, -/// uncancelled run. +/// A token callback that keeps streaming and discards every token. pub fn keep_streaming() -> impl FnMut(&str) -> ControlFlow<()> { |_| ControlFlow::Continue(()) } @@ -123,9 +107,8 @@ pub fn stream_into(buf: &mut String) -> impl FnMut(&str) -> ControlFlow<()> + '_ } } -/// Ingest the golden vault into a standalone `dir/b2.sqlite`, for the module-level -/// tests that drive `ingest_vault` directly instead of going through the façade. -/// The embedder is explicit because the dimension is load-bearing in some suites. +/// Ingest the golden vault into a standalone `dir/b2.sqlite`, bypassing the façade. The +/// embedder is explicit because some suites depend on its dimension. pub fn ingest_golden(dir: &Path, embedder: &FakeEmbedder) -> Connection { let vault = dir.join("vault"); golden_vault_copy(&vault); @@ -136,13 +119,10 @@ pub fn ingest_golden(dir: &Path, embedder: &FakeEmbedder) -> Connection { // --- a geometric embedder, for the suites that need real distances -------------- // -// The fake embedder's hash vectors have no geometry, so discovery's statistics can't be -// exercised on them. These hand-placed vectors can. Shared by `discover_surfacing.rs` and -// `explain_similar.rs`. +// The fake embedder's hash vectors have no geometry, so discovery's statistics need these. -/// Hand-placed unit vectors keyed by a `VEC:` marker in the chunk text. -/// Dim 4: axis 0 is the "anchor topic", axis 2 the "noise topic"; small designed -/// offsets on axes 1/3 give the noise cloud a nonzero, controlled variance. +/// Hand-placed unit vectors keyed by a `VEC:` marker in the chunk text. Axis 0 is +/// the anchor topic, axis 2 the noise topic; offsets on axes 1 and 3 give controlled variance. pub struct GeometricEmbedder; impl GeometricEmbedder { @@ -162,17 +142,13 @@ impl GeometricEmbedder { "MATE" => vec![0.995, 0.0998, 0.0, 0.0], // A diffuse anchor living inside the noise cloud itself. "DIFFUSE" => vec![0.0, 0.0, 1.0, 0.0], - // The buried-gem pair (see `a_buried_gem_outranks_and_is_served`): - // `MID` is one middling chunk, nearer the anchor than a split note's - // *centroid* but further than that note's best *chunk* (`NEAR`, whose - // other half `FAR` drags the centroid away). + // The buried gem: `MID` is nearer the anchor than the split note's centroid, + // but further than its best chunk `NEAR` (`FAR` drags the centroid away). "MID" => vec![0.8, 0.6, 0.0, 0.0], "NEAR" => vec![0.995, 0.0998, 0.0, 0.0], "FAR" => vec![0.0, 0.0, 1.0, 0.0], - // The noise cloud: all near axis 2, fanned evenly on axis 3 so the - // diffuse anchor sees one smooth spread of distances (max-z ≈ 1.6 - // for 13 evenly spaced values — under the retired leader gate by - // construction, which is what made that anchor's pane dark). + // The noise cloud: near axis 2, fanned evenly on axis 3, so the diffuse + // anchor sees one smooth spread of distances. t if t.starts_with('N') => { let i: f32 = t[1..].parse().unwrap_or(0.0); vec![0.0, 0.0, 1.0, 0.05 + i * 0.03] @@ -206,11 +182,9 @@ pub fn write_geometric_note(vault: &Path, name: &str, tag: &str) { .unwrap(); } -/// A note long enough to chunk in two, each half carrying its own `VEC:` tag. -/// ~1400 chars per half against the 450-token (≈1800-char) target: short enough -/// that the two halves don't make a third chunk, long enough that the H2 between -/// them is inside the backscan and becomes the boundary. The 15% overlap re-shares -/// only filler, leaving each chunk's *first* `VEC:` its own. +/// A note that chunks in two at its H2, each half with its own `VEC:` tag. ~1400 chars +/// per half against the ~1800-char target puts the H2 inside the backscan; the overlap +/// shares only filler, so each chunk's first `VEC:` is its own. pub fn write_split_note(vault: &Path, name: &str, first: &str, second: &str) { let filler = "alpha beta gamma delta epsilon zeta eta theta iota kappa. ".repeat(24); fs::write( diff --git a/crates/b2-core/tests/dirs.rs b/crates/b2-core/tests/dirs.rs index 6a22129..4ab757e 100644 --- a/crates/b2-core/tests/dirs.rs +++ b/crates/b2-core/tests/dirs.rs @@ -1,8 +1,5 @@ -//! Folder structure through the [`Vault`] façade — `list_dirs` + `create_dir`. -//! Folders are user-authored vault *structure*, and the filesystem is authoritative -//! for them (data-model.md §1): both ops go straight to disk, never the index, so -//! the listing can't go stale and an **empty** folder is as real as a full one — -//! the file tree must be one-to-one with the filesystem in both directions. +//! Folders (`list_dirs`, `create_dir`): the filesystem is authoritative (data-model.md §1), +//! so both ops go straight to disk and an empty folder is as real as a full one. mod common; @@ -15,7 +12,7 @@ fn list_dirs_returns_every_folder_sorted_including_empty_ones() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); - // An empty folder (with an empty nested child) made outside B2 — Finder, mkdir. + // Made outside B2. fs::create_dir_all(root.join("projects/2026")).unwrap(); let dirs = vault.list_dirs().unwrap(); @@ -36,8 +33,7 @@ fn list_dirs_is_index_free_and_skips_dot_folders() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); - // Never reindexed — `.b2/` exists (Vault::open creates it) and `.obsidian/` - // simulates a sibling tool; both are dot-folders, never vault structure. + // Never reindexed. `.b2/` and `.obsidian/` are dot-folders, never vault structure. fs::create_dir_all(root.join(".obsidian/plugins")).unwrap(); let dirs = vault.list_dirs().unwrap(); @@ -60,7 +56,7 @@ fn create_dir_creates_missing_parents_and_tolerates_a_trailing_slash() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); - // The UI's inline input allows nesting ("projects/2026"), like `mkdir -p`. + // Like `mkdir -p`. let report = vault.create_dir("projects/2026/q3/").unwrap(); assert_eq!(report.dir, "projects/2026/q3"); assert!(root.join("projects/2026/q3").is_dir()); @@ -71,13 +67,11 @@ fn create_dir_refuses_an_existing_folder_or_file() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = opened_vault(tmp.path()); - // An existing folder: refused, not silently a no-op — the user asked to - // *create* something, and it's already there. + // Refused, not a silent no-op: the user asked to create something. match vault.create_dir("concepts") { Err(Error::DirTargetExists(p)) => assert_eq!(p, "concepts"), other => panic!("expected DirTargetExists, got {other:?}"), } - // A file in the way: same refusal (the vault never clobbers). match vault.create_dir("concepts/memory.md") { Err(Error::DirTargetExists(p)) => assert_eq!(p, "concepts/memory.md"), other => panic!("expected DirTargetExists, got {other:?}"), diff --git a/crates/b2-core/tests/discover.rs b/crates/b2-core/tests/discover.rs index a3871a0..1b53aba 100644 --- a/crates/b2-core/tests/discover.rs +++ b/crates/b2-core/tests/discover.rs @@ -1,12 +1,6 @@ -//! Connection-discovery candidate generation (index-engine.md §3, resolved -//! 2026-07-01): candidates are the *complement* of the graph — notes near an anchor -//! in vector space but **not** already connected (self + direct neighbors excluded), -//! with 2-hop (triadic-closure) notes deliberately kept. -//! -//! Scope: with the deterministic *fake* embedder, vector nearness is content- -//! addressed, not semantic, so these prove the **plumbing** — the exclusion set, the -//! max-sim ranking, determinism — not model quality (that is the real-embedder eval, -//! deferred to step ②). +//! Discovery candidates (index-engine.md §3) are the complement of the graph: notes near +//! an anchor but not already connected, with 2-hop (triadic-closure) notes kept. Plumbing +//! only, under the fake embedder. mod common; @@ -21,14 +15,12 @@ use std::collections::BTreeSet; use std::fs; use std::path::Path; -// The four notes, by the thing that identifies them: their vault-relative paths (L1). const A: &str = "a.md"; const B: &str = "b.md"; const C: &str = "c.md"; const E: &str = "e.md"; -/// a → b → e (a links b, b links e); c is disconnected. So within 1 hop of a is -/// `{a, b}`; e is 2 hops (a triadic-closure candidate that must survive), c is far. +/// a → b → e; c is disconnected. e is 2 hops from a, a triadic-closure candidate. fn linked_chain_vault(dir: &Path) -> Connection { let vault = dir.join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -121,8 +113,6 @@ fn generation_is_deterministic() { #[test] fn a_directly_connected_pair_yields_no_candidates() { - // The golden vault is two notes, directly connected (spaced-repetition supports - // /references human-memory), so each is within 1 hop of the other → no candidates. let tmp = tempfile::TempDir::new().unwrap(); let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); @@ -134,14 +124,9 @@ fn a_directly_connected_pair_yields_no_candidates() { .is_empty()); } -/// Two-stage discovery (coarse centroid shortlist → exact rescore, #38) must equal -/// the **exhaustive** whole-space max-sim whenever the shortlist covers the -/// candidate set — which it always does at test scale (the shortlist floor is 200 -/// notes). Ground truth is recomputed here straight from the stored vectors, -/// independent of `discover`'s code path: same max-sim, same tie rules, over every -/// chunk in the vault. With the fake embedder the *ordering* is arbitrary (random -/// vectors), which is exactly why full equality — notes, scores, evidence chunks — -/// is a strong plumbing check. +/// Two-stage discovery (GH #38) must equal an exhaustive max-sim, recomputed here from +/// the stored vectors, whenever the shortlist covers the candidates (always, below 200 +/// notes). Fake vectors make the order arbitrary, so full equality is a strong check. #[test] fn two_stage_equals_exhaustive_max_sim_when_shortlist_covers() { use b2_core::embed::{l2_sq, unpack_f32}; @@ -173,13 +158,12 @@ fn two_stage_equals_exhaustive_max_sim_when_shortlist_covers() { .map(|(_, v)| v) .collect(); - // Exhaustive ground truth: every stored vector, min over the anchor's vectors, - // best chunk per note (strictly-less keeps the first-seen chunk, as discover does). + // Strictly-less keeps the first-seen chunk, as discover does. let mut best: HashMap = HashMap::new(); db::for_each_stored_vector(&conn, |chunk_id, blob| { let note = &db::note_for_chunk(&conn, chunk_id).unwrap().unwrap(); if note == anchor { - return; // no links in this vault → the anchor is the whole exclusion set + return; // no links, so the anchor is the whole exclusion set } let v = unpack_f32(blob); for a in &anchor_vecs { diff --git a/crates/b2-core/tests/discover_query_count.rs b/crates/b2-core/tests/discover_query_count.rs index 5f24404..40d5643 100644 --- a/crates/b2-core/tests/discover_query_count.rs +++ b/crates/b2-core/tests/discover_query_count.rs @@ -1,12 +1,8 @@ -//! Regression: `discover::candidates` must not issue O(chunks) SQL — neither a per-hit -//! `note_for_chunk` round-trip (#37's N+1: ~463k statements, a ~130s `b2 similar` on a real -//! vault) nor a whole-space vector scan per open (#38: ~38.6k rows read per note-open). This -//! locks the two-stage shape: **one** O(notes) centroid scan, then one bounded per-note -//! fetch per shortlisted note. +//! `discover::candidates` must not issue O(chunks) SQL (GH #37's N+1, GH #38's whole-space +//! scan): one O(notes) centroid scan, then one fetch per shortlisted note. //! -//! Sole test in its binary on purpose: it installs a scoped tracing subscriber, and tracing's -//! global callsite-interest cache races when a sibling test evaluates the same callsites on -//! another thread with no subscriber. +//! Sole test in its binary: tracing's global callsite-interest cache races when a sibling +//! test hits the same callsites on another thread with no subscriber. use b2_core::embed::FakeEmbedder; use b2_core::ingest::ingest_vault; @@ -16,8 +12,7 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use tracing_subscriber::fmt::MakeWriter; -/// A `MakeWriter` that captures everything the subscriber renders, so the test can -/// count the SQL templates SQLite's profiler emitted. +/// Captures everything the subscriber renders, to count SQL templates. #[derive(Clone, Default)] struct Capture(Arc>>); @@ -38,24 +33,18 @@ impl<'a> MakeWriter<'a> for Capture { } } -/// The exact template `db::note_for_chunk` emits — a per-hit chunk→note resolution. +/// `db::note_for_chunk`'s template: a per-hit chunk-to-note resolution. const PER_HIT_SQL: &str = "SELECT note_path FROM chunks WHERE id = ?1"; -/// The stage-1 coarse scan `db::for_each_note_centroid` emits — must run **once** -/// per call: it is the only whole-space read discovery is allowed. +/// The stage-1 centroid scan, the only whole-space read discovery may make, once. const CENTROID_SCAN_SQL: &str = "SELECT note_path, centroid FROM note_centroids"; -/// The whole-space chunk-vector scan (`db::for_each_stored_vector`) — search's -/// primitive, which discovery must **never** run: reading every stored vector per -/// note-open is exactly the O(vault) cost #38 removed. +/// Search's whole-space vector scan, which discovery must never run (GH #38). const SPACE_SCAN_SQL: &str = "SELECT chunk_id, vector FROM embeddings"; -/// The per-note vector fetch (`db::note_chunk_vectors`) — stage 2's unit, allowed -/// once for the anchor plus once per *shortlisted note*, never per chunk. +/// Stage 2's per-note fetch: once per shortlisted note, never per chunk. const PER_NOTE_SQL: &str = "SELECT c.id, e.vector FROM chunks c JOIN embeddings e"; #[test] fn candidates_issues_bounded_sql_never_o_chunks() { - // Multi-chunk notes so any per-chunk statement pattern would visibly exceed the - // note count (a scaled-down mirror of the real vault). Each paragraph is sized - // well past the qmd ~450-token target, so every note splits into several chunks. + // Multi-chunk notes, so a per-chunk statement pattern would exceed the note count. const NOTES: usize = 12; const PARAS: usize = 6; @@ -95,8 +84,7 @@ fn candidates_issues_bounded_sql_never_o_chunks() { (anchor={anchor_chunks}, total={total_chunks})" ); - // Run candidates under a DEBUG JSON subscriber so SQLite's per-statement profiler - // (target b2::sqlite) is captured; count the statement templates. + // A DEBUG subscriber captures SQLite's per-statement profiler (b2::sqlite). let capture = Capture::default(); let subscriber = tracing_subscriber::fmt() .json() @@ -112,30 +100,25 @@ fn candidates_issues_bounded_sql_never_o_chunks() { let text = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap(); - // (1) chunk→note resolution never happens per hit (stage 2 works note-by-note, - // so it needs no resolution at all). + // Stage 2 works note by note, so it needs no resolution. let per_hit = text.matches(PER_HIT_SQL).count(); assert_eq!( per_hit, 0, "discovery must not resolve chunk→note per hit (saw {per_hit})" ); - // (2) exactly one whole-space read, and it is the O(notes) centroid scan. let centroid_scans = text.matches(CENTROID_SCAN_SQL).count(); assert_eq!( centroid_scans, 1, "the coarse centroid scan must run exactly once per call" ); - // (3) the O(chunks) whole-space vector scan never runs on the note-open path. let space_scans = text.matches(SPACE_SCAN_SQL).count(); assert_eq!( space_scans, 0, "discovery must not scan every stored chunk vector (#38): saw {space_scans}" ); - // (4) per-note vector fetches are bounded by the shortlist (≤ one per note in - // this small vault: the anchor + every candidate), never by the chunk count. let per_note = text.matches(PER_NOTE_SQL).count(); assert!( (1..=NOTES).contains(&per_note), diff --git a/crates/b2-core/tests/discover_surfacing.rs b/crates/b2-core/tests/discover_surfacing.rs index 739613b..a038706 100644 --- a/crates/b2-core/tests/discover_surfacing.rs +++ b/crates/b2-core/tests/discover_surfacing.rs @@ -1,18 +1,7 @@ -//! Discovery surfacing (ADR-0014): **the ranked candidate list is served**. `limit` is a -//! cap, the order is the deterministic best-passage sort, the z travels with every row as -//! the strength band's input, and no statistic gates membership. Tiny pools are served -//! band-less (no statistic exists, so none is claimed) and a fake-embedded space is served -//! statistic-less — grading changes what the rows *carry*, never which rows exist. -//! -//! The fake embedder can't exercise the grading half, which is exactly why `Vault::similar` -//! never grades a fake-embedded space (the last test pins that). So these tests inject a -//! **geometric** embedder whose vectors are hand-placed: notes carry `VEC:` markers and -//! the embedder maps each tag to a designed unit vector. The scenarios keep the measured -//! shapes the retired rule was calibrated (and falsified) on: a cluster anchor whose mate -//! stands far above a tight noise cloud, and a diffuse anchor whose best candidate is just -//! the least-far member of one undifferentiated cloud — which the retired gate emptied, and -//! which now serves its ranked nearest, because "everything here is middling" is an answer a -//! strength band can carry and an empty pane cannot. +//! Discovery surfacing (ADR-0014): the ranked list is served, `limit` is a cap, and z rides +//! on every row as the band's input. Grading changes what rows carry, never which rows +//! exist. A geometric embedder with hand-placed vectors exercises the grading the fake +//! embedder can't. mod common; @@ -29,9 +18,8 @@ use std::path::Path; const NOISE_NOTES: usize = 13; -/// anchor + mate + a 13-note noise cloud + a diffuse anchor inside the cloud: -/// 16 notes, so every anchor's candidate pool (15) clears STATS_MIN_POPULATION -/// and the z statistics exist to travel. +/// Anchor, mate, a 13-note noise cloud and a diffuse anchor inside it: 16 notes, so every +/// pool clears STATS_MIN_POPULATION and z exists. fn geometric_vault(dir: &Path) -> (Connection, String, String, String) { let vault = dir.join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -43,7 +31,6 @@ fn geometric_vault(dir: &Path) -> (Connection, String, String, String) { } let conn = open(&dir.join("b2.sqlite")).unwrap(); ingest_vault(&conn, &vault, &GeometricEmbedder).unwrap(); - // The three named notes, by the thing that identifies them: their paths (L1). ( conn, "anchor.md".to_string(), @@ -54,11 +41,7 @@ fn geometric_vault(dir: &Path) -> (Connection, String, String, String) { #[test] fn the_ranked_list_is_served_with_z_on_every_row() { - // Under the retired gate this anchor served exactly one candidate (the mate - // cleared the member bar; the cloud did not). Always-serve keeps the same - // ranking — the mate leads by a wide margin — and hands the human the rest - // of the field with the z that *says* it is a wide margin, instead of - // deciding for them that the field isn't worth seeing. + // The mate leads by a wide margin, and z says so; the rest of the field is served. let tmp = tempfile::TempDir::new().unwrap(); let (conn, anchor, mate, _) = geometric_vault(tmp.path()); let cands = discover::candidates(&conn, &anchor, 10, true).unwrap(); @@ -78,7 +61,7 @@ fn the_ranked_list_is_served_with_z_on_every_row() { z0 > z1 + 1.0, "the band's input still says the mate towers over the field: {z0} vs {z1}" ); - // One order, two names: z descends with the rows, strictly (distinct scores). + // z descends with the rows. for pair in cands.windows(2) { assert!( pair[0].z.unwrap() >= pair[1].z.unwrap(), @@ -89,12 +72,8 @@ fn the_ranked_list_is_served_with_z_on_every_row() { #[test] fn a_diffuse_anchor_serves_its_ranked_nearest() { - // THE INVERSION GH #197 RULED: this anchor's pool is one undifferentiated - // cloud, and the retired leader gate emptied its pane on exactly that - // reading. But "one undifferentiated cloud" and "a coherent single-subject - // vault" are the same geometry (GH #196 measured a real one dark on 16 of - // 17 notes), so the honest answer is the ranked nearest with middling - // bands — a relative answer to what was always a relative question. + // GH #197: one undifferentiated cloud is also the geometry of a coherent + // single-subject vault (GH #196), so serve the ranked nearest with middling bands. let tmp = tempfile::TempDir::new().unwrap(); let (conn, _, _, diffuse) = geometric_vault(tmp.path()); let cands = discover::candidates(&conn, &diffuse, 10, true).unwrap(); @@ -107,16 +86,13 @@ fn a_diffuse_anchor_serves_its_ranked_nearest() { cands.iter().all(|c| c.z.is_some()), "graded: the bands can say every card is middling, which the empty pane could not" ); - // Deterministic order: two runs agree row for row. let again = discover::candidates(&conn, &diffuse, 10, true).unwrap(); assert_eq!(cands, again, "the served order is deterministic"); } #[test] fn ungraded_serving_changes_banding_never_membership() { - // `grade: false` (the façade's choice for a fake-embedded space) must serve - // the SAME rows in the SAME order and differ only in what they carry — the - // A7 continuity claim: no statistic setting may move membership. + // A7: no statistic setting may move membership or order. let tmp = tempfile::TempDir::new().unwrap(); let (conn, anchor, mate, _) = geometric_vault(tmp.path()); let graded = discover::candidates(&conn, &anchor, 10, true).unwrap(); @@ -142,12 +118,8 @@ fn ungraded_serving_changes_banding_never_membership() { #[test] fn a_buried_gem_outranks_and_is_served() { - // The multi-topic shape the GH #192 reorder exists for: `split.md` holds a passage - // almost parallel to the anchor (its stage-2 max-sim is far the best) while its second - // half drags its *centroid* away. The retired stage-1 floor judged that centroid, so - // `mid.md` outranked the gem — and a corpus-measured version of the same shape was - // suppressed outright (GH #187) or served to loner anchors on content it did not contain - // (GH #189). Judged after stage 2, the gem's own best pair is the signal. + // GH #192: `split.md` has one passage almost parallel to the anchor, while its other + // half drags its centroid away. Judged after stage 2, its best pair is the signal. let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -173,7 +145,7 @@ fn a_buried_gem_outranks_and_is_served() { "the buried gem leads: its best passage is the judged signal" ); assert_eq!(cands[1].note_path, "mid.md"); - // One order, three names: score, z, and the rows may never disagree. + // Score, z and row order never disagree. assert!( cands[0].score > cands[1].score, "score descends with the rows: {} then {}", @@ -186,8 +158,6 @@ fn a_buried_gem_outranks_and_is_served() { "z (the shown band) descends with the rows: {z0} then {z1}" ); - // Ungraded, the exact stage-2 score is still the order — grading changes - // what rows carry, never how the rows are ranked. let raw = discover::candidates(&conn, "anchor.md", 10, false).unwrap(); assert_eq!(raw[0].note_path, "split.md"); assert_eq!(raw[1].note_path, "mid.md"); @@ -195,11 +165,8 @@ fn a_buried_gem_outranks_and_is_served() { #[test] fn tiny_pools_are_served_in_full_and_band_less() { - // 3 candidates is no distribution: everything is served (as everywhere), - // and no z is claimed — the statistics threshold moves *banding only*. - // Under the retired gate this same threshold was a serve-everything/ - // serve-nothing cliff crossed by adding four notes (GH #196's amplifier C); - // now crossing it changes what the cards carry and nothing else. + // 3 candidates is no distribution, so no z; the threshold moves banding only + // (GH #196). let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -219,15 +186,12 @@ fn tiny_pools_are_served_in_full_and_band_less() { #[test] fn crossing_the_stats_population_changes_banding_never_membership() { - // The continuity claim (A7) across the n = 12 threshold: a vault just - // under it, one exactly at it (the inclusive edge — an off-by-one on the - // `>=` would slip past the other two cases), and one over it all serve - // their complete pools; the only difference is whether the rows carry a z. + // A7 across the n = 12 threshold, including the inclusive edge. let tmp = tempfile::TempDir::new().unwrap(); for (name, extra, expect_z) in [ - ("under", 10usize, false), // pool 11 — one short of the threshold - ("at", 11usize, true), // pool 12 — the threshold itself, inclusive - ("over", 13usize, true), // pool 14 — comfortably past it + ("under", 10usize, false), // pool 11 + ("at", 11usize, true), // pool 12 + ("over", 13usize, true), // pool 14 ] { let vault = tmp.path().join(name).join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -238,7 +202,7 @@ fn crossing_the_stats_population_changes_banding_never_membership() { } let conn = open(&tmp.path().join(name).join("b2.sqlite")).unwrap(); ingest_vault(&conn, &vault, &GeometricEmbedder).unwrap(); - let pool = extra + 1; // mate + noise notes + let pool = extra + 1; let cands = discover::candidates(&conn, "anchor.md", 100, true).unwrap(); assert_eq!( cands.len(), @@ -255,10 +219,7 @@ fn crossing_the_stats_population_changes_banding_never_membership() { #[test] fn facade_grades_a_geometric_space_but_never_a_fake_one() { - // Through the façade: same 16-note vault, once embedded geometrically (the - // z travels — the diffuse anchor's list included, the pane the retired gate - // used to darken) and once with the fake embedder (no statistic claimed: - // hash geometry would make any z noise wearing a band). + // Hash geometry would make any z noise wearing a band. let tmp = tempfile::TempDir::new().unwrap(); let vault_dir = tmp.path().join("v"); fs::create_dir_all(&vault_dir).unwrap(); @@ -287,7 +248,6 @@ fn facade_grades_a_geometric_space_but_never_a_fake_one() { "the mate still leads its anchor's list" ); - // A fake-embedded space serves the same way but claims no statistic. let fake_dir = tmp.path().join("vf"); fs::create_dir_all(&fake_dir).unwrap(); write_note(&fake_dir, "anchor.md", "ANCHOR"); diff --git a/crates/b2-core/tests/dry_run.rs b/crates/b2-core/tests/dry_run.rs index d55ea3b..2bf110d 100644 --- a/crates/b2-core/tests/dry_run.rs +++ b/crates/b2-core/tests/dry_run.rs @@ -1,11 +1,5 @@ -//! `b2 reindex --dry-run` — a read-only preview, driven through the [`Vault`] façade against -//! the golden vault. The contract: it forecasts exactly what a real reindex *would* do, and -//! touches nothing — no note projected, no byte written to the vault. -//! -//! The preview is one column now. It used to answer "which notes would be stamped" and -//! "which files collide", because a real run *wrote* to the vault. A run that writes nothing -//! (ADR-0004) has only work to size, so what is left to test is that the forecast matches -//! the run. +//! `b2 reindex --dry-run`: forecasts exactly what a real reindex would do, and touches +//! nothing, neither the vault nor the index. mod common; @@ -15,8 +9,7 @@ use common::{golden_vault_copy, MEMORY_PATH}; use std::fs; use std::path::Path; -/// A golden vault copy plus one extra note, so the counts are not all 2. Returns -/// (vault, root, the extra file's path). +/// The golden vault plus one note, so the counts are not all 2. fn vault_with_an_extra_note(dir: &Path) -> (Vault, std::path::PathBuf, std::path::PathBuf) { let root = dir.join("vault"); golden_vault_copy(&root); @@ -40,7 +33,6 @@ fn dry_run_previews_counts_without_writing_anything() { .chain(std::iter::once(fs::read_to_string(&fresh).unwrap())) .collect(); - // Pristine index: all three notes would be indexed and embedded. let plan = vault.plan_reindex(false).unwrap(); assert_eq!(plan.would_index, 3); assert_eq!( @@ -48,8 +40,7 @@ fn dry_run_previews_counts_without_writing_anything() { "a never-embedded vault embeds every note" ); - // Nothing on disk moved — which is now true of the real run too, so this - // asserts the *dry* half: nothing was projected into the index either. + // The real run writes nothing either, so the dry half is the index. for (path, was) in paths .iter() .map(|p| root.join(p)) @@ -72,15 +63,12 @@ fn dry_run_matches_what_a_real_reindex_then_does() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root, _fresh) = vault_with_an_extra_note(tmp.path()); - // The preview… let plan = vault.plan_reindex(false).unwrap(); - // …exactly equals what the real reindex reports it did. let report = vault.reindex().unwrap(); assert_eq!(plan.would_index, report.indexed); assert_eq!(plan.would_embed, report.embedded); - // A second preview, now against the populated index, would embed nothing: - // every note is unchanged, so its chunks hash to vectors already stored. + // Unchanged chunks hash to vectors already stored. let plan2 = vault.plan_reindex(false).unwrap(); assert_eq!(plan2.would_index, 3); assert_eq!(plan2.would_embed, 0, "unchanged notes would not re-embed"); @@ -102,7 +90,6 @@ fn dry_run_flags_only_a_changed_note() { let (vault, root, _fresh) = vault_with_an_extra_note(tmp.path()); vault.reindex().unwrap(); - // Edit one note's body; a dry-run should predict exactly that one re-embed. let memory = root.join("concepts/memory.md"); let mut text = fs::read_to_string(&memory).unwrap(); text.push_str("\nAn appended paragraph changes the body hash.\n"); diff --git a/crates/b2-core/tests/embed.rs b/crates/b2-core/tests/embed.rs index 5e29d4f..d3252fc 100644 --- a/crates/b2-core/tests/embed.rs +++ b/crates/b2-core/tests/embed.rs @@ -1,8 +1,5 @@ -//! Step 3 — the vector store + the embedder seam -//! (index-engine.md): a deterministic fake embedder -//! produces reproducible KNN; `embed_model_id`/`embed_dim` are recorded; a -//! model/dim swap recreates the vector space; note centroids (discovery's coarse -//! stage, #38) track the stored chunk vectors. +//! The vector store and the embedder seam (index-engine.md): reproducible KNN, the +//! recorded model identity, model swaps, and note centroids (GH #38). mod common; @@ -35,8 +32,7 @@ fn fake_embedder_is_deterministic() { assert_eq!(e.embed("x").unwrap().len(), 16); } -/// Constructing the fake is on a production path (`Vault::open`), so a zero -/// dimension degrades to the smallest real vector rather than panicking. +/// The fake is built on a production path (`Vault::open`), so zero degrades, not panics. #[test] fn a_zero_dimension_fake_is_clamped_to_one() { let e = FakeEmbedder::new(0); @@ -46,8 +42,7 @@ fn a_zero_dimension_fake_is_clamped_to_one() { #[test] fn embed_batch_matches_embed_per_element() { - // The default `embed_batch` (which the fake inherits) must be a faithful map of - // `embed` — that equivalence is what lets the reindex path batch freely. + // The default `embed_batch` must equal mapping `embed`, so reindex can batch freely. let e = FakeEmbedder::new(32); let texts = ["alpha", "beta", "", "gamma delta"]; let refs: Vec<&str> = texts.to_vec(); @@ -81,15 +76,11 @@ fn reindex_with_progress_reports_cumulative_and_fully_embeds() { }) .unwrap(); - // Batched embed still populates a vector for every chunk. let total = count(&conn, "chunks"); assert!(total > 0); assert_eq!(count(&conn, "embeddings"), total); - // Progress: reported, per-note fields populated, notes_embedded within the - // stable denominator and monotonic, chunks_done non-decreasing and ending - // exactly at the chunk total. A fresh index embeds every note, so the "notes to - // embed" denominator equals the full note count here. + // A fresh index embeds every note, so the denominator is the full note count. assert!(!events.is_empty(), "at least one batch is reported"); let notes = count(&conn, "notes") as usize; assert!(events.iter().all(|e| e.notes_to_embed == notes)); @@ -115,30 +106,22 @@ fn reindex_is_incremental_and_force_reembeds_everything() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); - // First index: both notes are new → both embedded. let first = vault.reindex().unwrap(); assert_eq!(first.indexed, 2); assert_eq!(first.embedded, 2, "a fresh index embeds every note"); - // Nothing changed on disk → the incremental reindex re-embeds nothing. let again = vault.reindex().unwrap(); assert_eq!(again.indexed, 2); assert_eq!(again.embedded, 0, "unchanged notes reuse their vectors"); - // Edit exactly one note's BODY → only that note re-embeds. let srs = root.join("notes/spaced-repetition.md"); let text = std::fs::read_to_string(&srs).unwrap(); std::fs::write(&srs, format!("{text}\n\nA newly appended paragraph.")).unwrap(); let edited = vault.reindex().unwrap(); assert_eq!(edited.embedded, 1, "only the changed note re-embeds"); - // --force re-chunks everything regardless of change — and, since the vector - // store is content-addressed (M4), re-*embeds* only what genuinely differs. - // On an unchanged vault that is nothing: the same chunk text hashes to the - // vector already stored, and recomputing it could only produce the same bytes. - // Where force still matters — a chunker-config change, the eval harness's - // `set_chunk_config` → `project(force)` — the chunk text *does* change, so the - // hashes miss and the model runs. + // --force re-chunks everything, but the store is content-addressed (M4), so + // unchanged text re-embeds nothing. let forced = vault .reindex_with_progress(true, &mut |_| ControlFlow::Continue(())) .unwrap(); @@ -148,8 +131,7 @@ fn reindex_is_incremental_and_force_reembeds_everything() { "identical chunk text needs no second forward pass" ); - // The proof that force is still doing its job: change the chunking, and every - // note's text — and therefore every hash — moves. + // Changing the chunking moves every hash, so force does embed. let mut rechunked = Vault::open(&root).unwrap(); rechunked.set_chunk_config(b2_core::chunk::ChunkConfig { target_tokens: 20, @@ -169,7 +151,6 @@ fn ingest_populates_embeddings_and_records_meta() { let tmp = tempfile::TempDir::new().unwrap(); let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); - // one vector per chunk assert!(count(&conn, "chunks") > 0); assert_eq!(count(&conn, "chunks"), count(&conn, "embeddings")); @@ -180,10 +161,8 @@ fn ingest_populates_embeddings_and_records_meta() { assert_eq!(meta(&conn, "embed_dim").as_deref(), Some("64")); } -/// `note_centroids` is derived data with the vectors' own lifecycle: after any embed -/// pass, every note with stored vectors carries a centroid, and it equals -/// `centroid_of` over exactly those vectors — including after a body edit re-chunks -/// and re-embeds the note (the stale centroid must not survive). +/// `note_centroids` shares the vectors' lifecycle: after any embed pass each embedded note's +/// centroid equals `centroid_of` its current vectors, and a stale one never survives. #[test] fn centroids_track_the_stored_chunk_vectors() { use b2_core::embed::{centroid_of, pack_f32}; @@ -230,7 +209,6 @@ fn centroids_track_the_stored_chunk_vectors() { }; assert_centroids_current(&conn); - // Edit one note's body → re-project + re-embed → its centroid must follow. let srs = root.join("notes/spaced-repetition.md"); let text = std::fs::read_to_string(&srs).unwrap(); std::fs::write(&srs, format!("{text}\n\nFreshly appended centroid bait.")).unwrap(); @@ -244,7 +222,7 @@ fn knn_finds_the_chunk_whose_text_we_query() { let embedder = FakeEmbedder::new(64); let conn = ingest_golden(tmp.path(), &embedder); - // pick a known chunk, query with the embedding of its own text + // Query with the embedding of a known chunk's own text. let (id, text): (i64, String) = conn .query_row( "SELECT id, text FROM chunks WHERE note_path = ?1 ORDER BY seq LIMIT 1", @@ -285,7 +263,6 @@ fn reindex_yields_identical_vectors() { ingest_vault(&conn, &vault, &embedder).unwrap(); let before = vec_for_srs_seq0(&conn); - // A full re-index re-embeds deterministically → byte-identical vectors. ingest_vault(&conn, &vault, &embedder).unwrap(); assert_eq!(before, vec_for_srs_seq0(&conn)); } @@ -296,9 +273,7 @@ fn changing_dim_recreates_the_vector_space_and_clears_vectors() { let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); assert!(count(&conn, "embeddings") > 0); - // A model/dim swap: the only place it can be detected is meta. Vectors are - // dropped (a full re-embed is required) and the dim is updated. Centroids share - // the vectors' lifecycle, so the swap empties them too. + // A swap is detected via meta; vectors and centroids are dropped. db::ensure_embedding_space(&conn, "fake-deterministic-v1", 128).unwrap(); assert_eq!(meta(&conn, "embed_dim").as_deref(), Some("128")); assert_eq!( @@ -313,23 +288,16 @@ fn changing_dim_recreates_the_vector_space_and_clears_vectors() { ); } -/// Concurrent embed passes leave **one** intact vector space (ADR-0021, invariant C1). -/// -/// `ensure_embedding_space` is the second drop-and-rebuild in `db.rs`, and two embed passes -/// genuinely overlap: the `#55` advisory lock is `b2-cli`'s alone, so a desktop reindex and a -/// `b2 reindex` are exactly this test. Where the migration race needed twenty rounds to bite, -/// this one is near-certain — every caller runs the batch. Measured against the unfixed -/// engine: **70 of 80 workers errored** and **every** round lost vectors, in each of three -/// runs. Losing vectors is the quiet half: a `DROP` landing after another pass started -/// writing leaves an index reporting a complete embed over a half-empty space. +/// Concurrent embed passes leave one intact vector space (ADR-0021, C1). The GH #55 lock is +/// the CLI's alone, so a desktop and a CLI reindex really overlap. The quiet failure is a +/// late `DROP` leaving a "complete" embed over a half-empty space. #[test] fn concurrent_embed_passes_leave_one_intact_vector_space() { use std::sync::{Arc, Barrier}; const ROUNDS: usize = 3; const PASSES: i64 = 8; - /// Distinct text per racing pass, so the eight writes are eight rows rather - /// than one row written eight times (which would pass this vacuously). + /// Distinct text per pass, so eight writes are eight rows, not one (M4). fn text_of(seq: i64) -> String { format!("chunk text {seq}") } @@ -337,7 +305,7 @@ fn concurrent_embed_passes_leave_one_intact_vector_space() { for round in 0..ROUNDS { let tmp = tempfile::TempDir::new().unwrap(); let db_path = tmp.path().join("b2.sqlite"); - // A projected-but-unembedded index: one note, one chunk per racing pass. + // Projected, unembedded: one note, one chunk per racing pass. { let conn = open(&db_path).unwrap(); conn.execute( @@ -346,8 +314,6 @@ fn concurrent_embed_passes_leave_one_intact_vector_space() { [], ) .unwrap(); - // One chunk per racing pass, each with its own text so each addresses - // its own vector (the store is content-addressed — M4). for seq in 0..PASSES { conn.execute( "INSERT INTO chunks @@ -367,7 +333,7 @@ fn concurrent_embed_passes_leave_one_intact_vector_space() { std::thread::spawn(move || { let conn = open(&db_path).unwrap(); start.wait(); - // What `embed_vault` does: ensure the space, then write vectors into it. + // What `embed_vault` does. db::ensure_embedding_space(&conn, "fake-deterministic-v1", 128)?; db::set_vector(&conn, &db::text_hash(&text_of(chunk_id)), &[0.5; 128]) }) @@ -388,12 +354,8 @@ fn concurrent_embed_passes_leave_one_intact_vector_space() { } } -/// The other half of the model-swap contract. `ensure_embedding_space` (above) -/// covers what a *reindex* does — drop the stale vectors and re-embed. This covers -/// what happens **before** anyone reindexes: `open` deliberately never touches the -/// vector space, so a vault can sit with vectors from one model while a different -/// one is configured. Ranking those stored vectors against a query vector from the -/// new model would be silently wrong, so `search` refuses instead. +/// Before any reindex, `open` never touches the vector space, so a vault can hold vectors +/// from another model. Ranking them would be silently wrong, so `search` refuses. #[test] fn search_fails_fast_on_a_model_swap_and_a_reindex_heals_it() { use b2_core::vault::Vault; @@ -403,14 +365,12 @@ fn search_fails_fast_on_a_model_swap_and_a_reindex_heals_it() { let root = tmp.path().join("vault"); golden_vault_copy(&root); - // Index the vault under a 64-dim embedder. let vault = Vault::open_with_embedder(&root, Box::new(FakeEmbedder::new(64))).unwrap(); vault.reindex().unwrap(); assert!(!vault.search("forgetting", 5).unwrap().is_empty()); drop(vault); - // Reopen with a different dimension — a model swap, as far as the recorded - // identity is concerned. + // A different dimension is a model swap to the recorded identity. let swapped = Vault::open_with_embedder(&root, Box::new(FakeEmbedder::new(128))).unwrap(); let err = swapped.search("forgetting", 5).unwrap_err(); assert!( @@ -418,14 +378,13 @@ fn search_fails_fast_on_a_model_swap_and_a_reindex_heals_it() { "a swap must fail fast, not rank on incomparable vectors: {err:?}" ); - // `open` left the stored vectors alone (so a misconfigured model can never wipe - // a vault's embeddings) — the refusal is a query-time guard, not a migration. + // A misconfigured model can never wipe a vault's embeddings. let conn = index_conn(&root); assert!(count(&conn, "embeddings") > 0, "vectors survive the reopen"); assert_eq!(meta(&conn, "embed_dim").as_deref(), Some("64")); drop(conn); - // The documented fix: reindex re-creates the space at the new dimension. + // The documented fix. swapped.reindex().unwrap(); assert!(!swapped.search("forgetting", 5).unwrap().is_empty()); let conn = index_conn(&root); diff --git a/crates/b2-core/tests/explain.rs b/crates/b2-core/tests/explain.rs index 87e7e2f..8ae4ac8 100644 --- a/crates/b2-core/tests/explain.rs +++ b/crates/b2-core/tests/explain.rs @@ -1,7 +1,4 @@ -//! `b2 explain` — a note's connections with their "why". Driven through the -//! [`Vault`] façade against the golden vault, deterministic under the FakeEmbedder. -//! `explain` is a pure graph read; these pin the header + the per-edge shape -//! (label, target, explanation, origin) it presents, and the orphan case. +//! `b2 explain`: a note's connections with their "why", a pure graph read. mod common; @@ -14,14 +11,12 @@ fn explain_shows_the_header_and_outbound_edges_with_their_why() { let (vault, _root) = reindexed_vault(tmp.path()); let view = vault.explain("notes/spaced-repetition").unwrap(); - // Header: the note resolved to its identity + display fields. assert_eq!(view.path, SRS_PATH); assert_eq!(view.path, "notes/spaced-repetition.md"); assert_eq!(view.title.as_deref(), Some("spaced-repetition")); - // Two outbound edges to memory — a typed `supports` (with a "why", from the - // `b2_relations:` entry) and a bare body `references` (none). The two homes, - // side by side (data-model §8). + // A typed `supports` with a why, and a bare body `references`: the two homes + // (data-model §8). assert_eq!(view.connections.len(), 2, "{:?}", view.connections); assert!(view.connections.iter().all(|c| c.direction == "outbound")); assert!(view.connections.iter().all(|c| c.path == MEMORY_PATH)); @@ -45,8 +40,7 @@ fn explain_shows_the_header_and_outbound_edges_with_their_why() { .find(|c| c.label == "references") .expect("the bare body link is a references edge"); assert_eq!(references.origin, "inline", "the body home"); - // Every edge carries the other note's `created` (GH #22) — resolved from - // the projection, not a file re-read. + // The other note's `created` (GH #22), from the projection. assert!( view.connections .iter() @@ -54,7 +48,6 @@ fn explain_shows_the_header_and_outbound_edges_with_their_why() { "neighbors carry their created date: {:?}", view.connections ); - // Every link resolves, so there are no unresolved (dangling) links. assert!( view.unresolved.is_empty(), "resolved note has no unresolved links: {:?}", @@ -64,9 +57,7 @@ fn explain_shows_the_header_and_outbound_edges_with_their_why() { #[test] fn explain_surfaces_outbound_resource_links() { - // GH #22: an edge can target a note, a resource, or nothing. The resource kind - // must be visible from the *note's* side (not only as the resource's backlinks), - // else a graph over `explain` silently hides a note's file links. + // GH #22: resource links must be visible from the note's side too. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); fs::write( @@ -79,7 +70,6 @@ fn explain_surfaces_outbound_resource_links() { vault.reindex().unwrap(); let view = vault.explain("notes/uses-diagram").unwrap(); - // The note link is a normal connection; the file link is a resource link. assert_eq!(view.connections.len(), 1, "{:?}", view.connections); assert_eq!(view.resources.len(), 1, "{:?}", view.resources); let r = &view.resources[0]; @@ -89,7 +79,6 @@ fn explain_surfaces_outbound_resource_links() { assert_eq!(r.origin, "inline"); assert_eq!(r.caption.as_deref(), Some("a tiny diagram")); assert!(r.embed, "an image embed reads as embed=true"); - // A note with no file links reports an empty list, never an error. assert!(vault .explain("concepts/memory") .unwrap() @@ -99,9 +88,7 @@ fn explain_surfaces_outbound_resource_links() { #[test] fn explain_surfaces_unresolved_folder_and_typo_links() { - // GH #12: a `[[Hermes]]` link naming a *folder* (a note is one `.md` file) — or a - // typo — resolves to nothing. `explain` must surface it as an unresolved link, - // distinct from a resolved connection, so a broken link reads as broken not gone. + // GH #12: a link naming a folder or a typo reads as broken, not gone. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); fs::write( @@ -114,11 +101,9 @@ fn explain_surfaces_unresolved_folder_and_typo_links() { vault.reindex().unwrap(); let view = vault.explain("guide").unwrap(); - // The resolvable link is a normal outbound connection… assert_eq!(view.connections.len(), 1, "{:?}", view.connections); assert_eq!(view.connections[0].path, MEMORY_PATH); assert_eq!(view.connections[0].direction, "outbound"); - // …and the folder link is surfaced as unresolved, carrying its authored target. assert_eq!(view.unresolved.len(), 1, "{:?}", view.unresolved); assert_eq!(view.unresolved[0].target, "Hermes"); assert_eq!(view.unresolved[0].relation, "references"); @@ -130,7 +115,7 @@ fn explain_shows_inbound_backlinks_with_inverse_labels() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // Memory is only pointed *at* (by SRS) — inbound edges, inverse-labelled. + // Memory is only pointed at, by SRS. let view = vault.explain(MEMORY_PATH).unwrap(); assert_eq!(view.title.as_deref(), Some("memory")); assert!(!view.connections.is_empty()); @@ -153,9 +138,7 @@ fn explain_shows_inbound_backlinks_with_inverse_labels() { #[test] fn explain_resolves_a_stem_and_a_full_path_to_the_same_note() { - // The two ref forms `explain` still accepts since GH #170 — the extensionless - // wikilink habit and the full vault-relative path. There is no third form: the - // path *is* the identity (L1), so "or by b2id" is gone rather than renamed. + // The two ref forms since GH #170: the extensionless stem and the full path (L1). let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); @@ -167,8 +150,7 @@ fn explain_resolves_a_stem_and_a_full_path_to_the_same_note() { #[test] fn explain_surfaces_frontmatter_provenance() { - // An edge accepted into (or authored in) frontmatter reads as origin=frontmatter, - // distinct from a human body link — the provenance data-model §0 says explain shows. + // The provenance data-model §0 says explain shows. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); fs::write( diff --git a/crates/b2-core/tests/explain_similar.rs b/crates/b2-core/tests/explain_similar.rs index cd680d2..4e78ed1 100644 --- a/crates/b2-core/tests/explain_similar.rs +++ b/crates/b2-core/tests/explain_similar.rs @@ -1,13 +1,6 @@ -//! **Explain** for a *Similar & unlinked* card (GH #236): `Vault::explain_similar`, the -//! model-free read behind the Compare view. It must describe the row the surface served, -//! from the same numbers that ranked it, so the explanation can never disagree with the -//! list: the same rank, the same z, the same winning passage. And it must answer for any -//! note, not only a served one: a linked note, one past the list, one not embedded yet, -//! each says why it is not a card. -//! -//! Distances need geometry, so most of these run on the hand-placed -//! [`GeometricEmbedder`]; the fake embedder is used only to pin that a hash space is -//! never graded. +//! `Vault::explain_similar` (GH #236), the model-free read behind the Compare view. It uses +//! the numbers that ranked the list, so it can never disagree with it, and says why any +//! other note is not a card. Runs on [`GeometricEmbedder`], since distances need geometry. mod common; @@ -20,8 +13,7 @@ use std::path::{Path, PathBuf}; const NOISE_NOTES: usize = 13; -/// anchor + mate + diffuse + a 13-note noise cloud: every anchor's pool (15) is big -/// enough to grade. +/// Anchor, mate, diffuse and a 13-note noise cloud: every pool (15) is big enough to grade. fn geometric_vault(dir: &Path) -> (Vault, PathBuf) { let root = dir.join("vault"); fs::create_dir_all(&root).unwrap(); @@ -214,9 +206,7 @@ fn shared_neighbors_are_reported_with_titles() { #[test] fn identical_passages_are_flagged() { - // The template case from a real vault: the same text in two notes shares one - // content-addressed vector, so the pair is a perfect match that says nothing about - // what the notes are about. The view must be able to say so. + // Shared template text is a perfect match that says nothing about the notes. let tmp = tempfile::TempDir::new().unwrap(); let (v, root) = geometric_vault(tmp.path()); fs::write( @@ -239,10 +229,9 @@ fn identical_passages_are_flagged() { fn an_unembedded_candidate_says_so() { let tmp = tempfile::TempDir::new().unwrap(); let (v, root) = geometric_vault(tmp.path()); - // Text no other note holds: vectors are content-addressed, so a copy of an existing - // passage would arrive already embedded. + // Unique text: a copied passage would arrive already embedded. fs::write(root.join("fresh.md"), "fresh words VEC:MATE unseen.\n").unwrap(); - v.project(false).unwrap(); // indexed for keywords, no vectors yet + v.project(false).unwrap(); let ex = v.explain_similar("anchor.md", "fresh.md", 10).unwrap(); assert_eq!(ex.standing, SimilarStanding::Unembedded); @@ -264,15 +253,13 @@ fn an_unembedded_anchor_says_so() { #[test] fn a_note_outside_the_stage_one_shortlist_says_so() { - // Stage 1 keeps max(limit × 20, 200) notes by centroid. Past that, a note is never - // scored at all, which is a different answer from "ranked too low". + // Stage 1 keeps max(limit × 20, 200) notes; past that a note is never scored. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); fs::create_dir_all(&root).unwrap(); write_geometric_note(&root, "anchor.md", "DIFFUSE"); for i in 0..205 { - // The N-tagged notes fan away from the DIFFUSE axis as `i` grows, so their - // whole-note order is their index: `n204.md` is last, at rank 205. + // Notes fan away from DIFFUSE as `i` grows, so `n204.md` ranks last (205). write_geometric_note(&root, &format!("n{i:03}.md"), &format!("N{i}")); } let v = Vault::open_with_embedder(&root, Box::new(GeometricEmbedder)).unwrap(); diff --git a/crates/b2-core/tests/frontmatter.rs b/crates/b2-core/tests/frontmatter.rs index 3e0cb31..4762515 100644 --- a/crates/b2-core/tests/frontmatter.rs +++ b/crates/b2-core/tests/frontmatter.rs @@ -29,7 +29,6 @@ fn add_relation_creates_a_block_when_absent() { .unwrap(); let expected = "---\ntype: note\nb2_relations:\n - \"contradicts [[concepts/memory|Human memory]] — because\"\n---\nBody.\n"; assert_eq!(n.as_str(), expected); - // re-parse is stable + the entry reads back assert_eq!(parse(n.as_str()).as_str(), expected); assert_eq!( parse(n.as_str()).fields().relations, @@ -58,7 +57,7 @@ fn add_relation_preserves_other_keys_and_body() { ); assert!(out.contains("Body stays.\nLine 2.\n"), "body preserved"); assert!(out.contains("b2_relations:\n - \"relates [[x|X]]\"\n")); - assert_eq!(parse(out).as_str(), out); // round-trip + assert_eq!(parse(out).as_str(), out); } #[test] @@ -67,7 +66,6 @@ fn add_relation_quotes_safely() { let mut n = parse(raw); n.add_relation("relates [[x|X]] — has \"quotes\" inside") .unwrap(); - // the embedded quotes are escaped, and it re-parses to the original spec assert_eq!( parse(n.as_str()).fields().relations, vec!["relates [[x|X]] — has \"quotes\" inside".to_string()] @@ -76,8 +74,7 @@ fn add_relation_quotes_safely() { #[test] fn a_generic_relations_key_is_not_b2s() { - // The namespace is the point (data-model §1): another tool's `relations:` is - // an unknown key — preserved verbatim, never projected into edges. + // Another tool's `relations:` is an unknown key, never projected (data-model §1). let raw = "---\ntype: note\nrelations:\n - \"supports [[a|A]]\"\n---\nBody.\n"; let n = parse(raw); assert!(n.fields().relations.is_empty(), "generic key must not read"); @@ -119,8 +116,7 @@ fn frontmatter_wins_when_the_same_edge_is_in_both_body_and_frontmatter() { let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("vault"); fs::create_dir_all(&vault).unwrap(); - // a references b in the BODY *and* declares the same (target, type) in - // frontmatter — with an explanation only the frontmatter home can carry. + // The same (target, type) in body and frontmatter; only frontmatter carries a why. fs::write( vault.join("a.md"), "---\ntype: note\ntitle: A\nb2_relations:\n - \"references [[b|B]] — the why\"\n---\nSee [[b|B]].\n", @@ -133,8 +129,7 @@ fn frontmatter_wins_when_the_same_edge_is_in_both_body_and_frontmatter() { .unwrap(); let conn = ingest(&vault, &tmp.path().join("b2.sqlite")); - // exactly one references edge a→b, origin=frontmatter (it wins — data-model - // §0/§3), and its explanation survives. + // One edge, and frontmatter wins (data-model §0/§3). let rows: Vec<(String, Option)> = { let mut s = conn .prepare("SELECT origin, explanation FROM edges WHERE src_path = ?1 AND dst_path = ?2 AND type = 'references'") @@ -155,8 +150,7 @@ fn a_typed_relation_augments_a_body_link_as_a_second_edge() { let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("vault"); fs::create_dir_all(&vault).unwrap(); - // The augment flow (data-model §2): the body's plain link stays an untyped - // reference; a `supports` entry over the same target adds the typed edge. + // The augment flow (data-model §2): the typed entry adds a second edge. fs::write( vault.join("a.md"), "---\ntype: note\ntitle: A\nb2_relations:\n - \"supports [[b|B]] — backs it\"\n---\nSee [[b|B]].\n", diff --git a/crates/b2-core/tests/fts_tokenizer.rs b/crates/b2-core/tests/fts_tokenizer.rs index fae4456..161f759 100644 --- a/crates/b2-core/tests/fts_tokenizer.rs +++ b/crates/b2-core/tests/fts_tokenizer.rs @@ -1,21 +1,14 @@ -//! The FTS tokenizer (GH #157): `porter unicode61` is the shipped default (schema -//! v5 — the A/B's verdict, GH #157), and -//! [`Vault::rebuild_fts`] swaps `chunks_fts`'s tokenizer over the identical stored -//! chunk text — the lever that lets the eval harness keep the unstemmed ablation -//! measurable without re-chunking or re-embedding anything (the tokenizer only -//! touches the lexical half — index-engine.md §3). -//! -//! These tests run on the BM25-only fallback path (projected, never embedded), so -//! every match below is the tokenizer's alone. +//! The FTS tokenizer (GH #157): `porter unicode61` is the default, and +//! [`Vault::rebuild_fts`] swaps it over the same chunk text, so the eval can measure the +//! unstemmed ablation without re-embedding. BM25-only here, so every match is the +//! tokenizer's. use b2_core::db::FtsTokenizer; use b2_core::vault::Vault; use std::fs; use std::path::Path; -/// A purpose-built vault: one stub note whose body says "pedals" and "ascents" — -/// the surface forms the eval corpus's pre-verdict misses hinged on — projected -/// but deliberately not embedded. +/// One note saying "pedals" and "ascents", projected but not embedded. fn projected_vault(dir: &Path) -> Vault { let root = dir.join("vault"); fs::create_dir_all(&root).unwrap(); @@ -43,11 +36,9 @@ fn the_shipped_default_matches_inflected_query_terms() { let tmp = tempfile::tempdir().unwrap(); let vault = projected_vault(tmp.path()); - // The note says "pedals" and "ascents"; a stemmed lexical half resolves every - // inflection out of the box — the exact queries unstemmed BM25 scored ✗>10 on. assert_eq!(hit_paths(&vault, "pedalling"), vec!["bicycle.md"]); assert_eq!(hit_paths(&vault, "ascent"), vec!["bicycle.md"]); - // And the surface forms still match, stemming being a widening, not a swap. + // Stemming widens; surface forms still match. assert_eq!(hit_paths(&vault, "pedals"), vec!["bicycle.md"]); } @@ -56,13 +47,11 @@ fn unicode61_rebuild_restores_literal_only_matching_and_back() { let tmp = tempfile::tempdir().unwrap(); let vault = projected_vault(tmp.path()); - // The ablation arm: same chunk rows, unstemmed tokenizer — surface forms only. vault.rebuild_fts(FtsTokenizer::Unicode61).unwrap(); assert!(hit_paths(&vault, "pedalling").is_empty()); assert_eq!(hit_paths(&vault, "pedals"), vec!["bicycle.md"]); - // Round trip: the lever leaves no residue, so the eval's ablation arm can - // hand the vault back to the shipped default untainted. + // The round trip leaves no residue. vault.rebuild_fts(FtsTokenizer::PorterUnicode61).unwrap(); assert_eq!(hit_paths(&vault, "pedalling"), vec!["bicycle.md"]); } @@ -73,9 +62,7 @@ fn incremental_ingest_stays_in_sync_after_a_rebuild() { let vault = projected_vault(tmp.path()); vault.rebuild_fts(FtsTokenizer::Unicode61).unwrap(); - // The chunks_ai/ad/au triggers live on `chunks` and reference `chunks_fts` by - // name; a rebuild recreates the table they point at, so ingest after the swap - // must keep FTS in sync exactly as before it. + // The chunks triggers reference `chunks_fts` by name, and a rebuild recreates it. let root = tmp.path().join("vault"); fs::write( root.join("volcano.md"), diff --git a/crates/b2-core/tests/graph.rs b/crates/b2-core/tests/graph.rs index 46bf037..b67689a 100644 --- a/crates/b2-core/tests/graph.rs +++ b/crates/b2-core/tests/graph.rs @@ -1,5 +1,4 @@ -//! Step 2 — the typed graph projection + `neighbors`, and the -//! `incremental ≡ full` invariant (index-engine.md). +//! The typed graph projection, `neighbors`, and `incremental ≡ full` (index-engine.md). mod common; @@ -11,9 +10,8 @@ use common::{golden_vault_copy, ingest_golden, MEMORY_PATH, SRS_PATH}; use rusqlite::Connection; use std::fs; -/// (src_path, dst_path, dst_path_raw, type, origin, occ, explanation), ordered — the -/// comparable shape of the whole edge set (id excluded; it is a deterministic -/// function of the rest). Every edge is authored + active, so there is no `status`. +/// `(src_path, dst_path, dst_path_raw, type, origin, occ, explanation)`; id is derived +/// from the rest, so it is excluded. type EdgeTuple = ( String, Option, @@ -57,7 +55,7 @@ fn golden_graph_has_inline_references_and_frontmatter_supports() { assert_eq!( edges, vec![ - // references: spaced-repetition → memory (prose bare wikilink) + // A bare wikilink in prose. ( SRS_PATH.to_string(), Some(MEMORY_PATH.to_string()), @@ -67,8 +65,7 @@ fn golden_graph_has_inline_references_and_frontmatter_supports() { 0, None, ), - // supports: spaced-repetition → memory (`b2_relations:` entry, with - // explanation — the augment shape, data-model §2/§8) + // A `b2_relations:` entry with explanation (data-model §2/§8). ( SRS_PATH.to_string(), Some(MEMORY_PATH.to_string()), @@ -82,16 +79,13 @@ fn golden_graph_has_inline_references_and_frontmatter_supports() { ); } -/// `graph::neighbors` at the raw-edge layer: the same stored edge reads as the -/// verb from the source end and as its inverse label from the target end, with no -/// reciprocal row (B2 stores each edge once, directed). The façade's resolved view -/// of the same pair — paths, titles, ref-form equivalence — is `tests/vault.rs`. +/// One stored edge reads as its verb from the source and its inverse from the target, with +/// no reciprocal row. The façade's view is in `tests/vault.rs`. #[test] fn neighbors_label_by_direction_at_both_ends() { let tmp = tempfile::TempDir::new().unwrap(); let conn = ingest_golden(tmp.path(), &FakeEmbedder::default()); - // The target end: inbound edges, inverse-labelled, all from spaced-repetition. let inbound = neighbors(&conn, MEMORY_PATH).unwrap(); let mut labels: Vec<&str> = inbound.iter().map(|n| n.label.as_str()).collect(); labels.sort_unstable(); @@ -100,7 +94,6 @@ fn neighbors_label_by_direction_at_both_ends() { .iter() .all(|n| n.other == SRS_PATH && n.direction == Direction::Inbound)); - // The source end: the very same two edges, outbound, labelled by their verbs. let outbound = neighbors(&conn, SRS_PATH).unwrap(); let mut labels: Vec<&str> = outbound.iter().map(|n| n.label.as_str()).collect(); labels.sort_unstable(); @@ -113,10 +106,8 @@ fn neighbors_label_by_direction_at_both_ends() { #[test] fn unresolved_outbound_surfaces_folder_and_typo_links() { - // GH #12: a note is one `.md` file, so a `[[Hermes]]` naming a *folder* (or a - // typo) resolves to nothing. Those dangling links must be surfaced, not dropped — - // `neighbors` keeps only resolved edges, `unresolved_outbound` returns the rest, - // and together they cover every outbound link the note authored. + // GH #12: `neighbors` and `unresolved_outbound` together cover every outbound link, + // including a folder name or a typo. let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("vault"); golden_vault_copy(&vault); @@ -132,21 +123,19 @@ fn unresolved_outbound_surfaces_folder_and_typo_links() { let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); ingest_vault(&conn, &vault, &FakeEmbedder::default()).unwrap(); - // Only the one resolvable link is an outbound neighbor. let ns = neighbors(&conn, guide).unwrap(); assert_eq!(ns.len(), 1, "only the memory link resolves: {ns:?}"); assert_eq!(ns[0].other, MEMORY_PATH); assert_eq!(ns[0].direction, Direction::Outbound); - // The folder + typo links are surfaced as unresolved (ordered by target), each an - // inline `references` edge that resolved to nothing. + // Ordered by target. let dangling = unresolved_outbound(&conn, guide).unwrap(); let targets: Vec<&str> = dangling.iter().map(|u| u.target.as_str()).collect(); assert_eq!(targets, vec!["Hermes", "concepts/memoryy"]); assert!(dangling.iter().all(|u| u.edge_type == "references")); assert!(dangling.iter().all(|u| u.origin == "inline")); - // A fully-resolved note has none — no false positives. + // No false positives. assert!(unresolved_outbound(&conn, SRS_PATH).unwrap().is_empty()); } @@ -160,7 +149,6 @@ fn one_note_reindex_equals_full() { ingest_vault(&conn, &vault, &FakeEmbedder::default()).unwrap(); let after_full = edge_snapshot(&conn); - // Re-project a single note against the already-complete index. let cfg = b2_core::chunk::ChunkConfig::default(); let embedder = FakeEmbedder::default(); let ctx = EmbedCtx::new(ProjectionCtx::new(&conn, &vault, &cfg), &embedder); @@ -172,7 +160,6 @@ fn one_note_reindex_equals_full() { "incremental re-index must match full" ); - // And a second full reindex is identical too (idempotent). ingest_vault(&conn, &vault, &FakeEmbedder::default()).unwrap(); assert_eq!( after_full, diff --git a/crates/b2-core/tests/hidden.rs b/crates/b2-core/tests/hidden.rs index 13dac35..4f39b64 100644 --- a/crates/b2-core/tests/hidden.rs +++ b/crates/b2-core/tests/hidden.rs @@ -1,11 +1,6 @@ -//! **Hidden means hidden** (GH #136, data-model.md §1): a dot-prefixed name is not -//! vault material of any kind. The walk applies the rule *above* its note/resource -//! routing, so a `.scratch.md` is as invisible as a `.DS_Store` — and no authoring -//! command will create one, since a member b2 never sees is a silent fs/index desync. -//! -//! The sibling cases live where their own surface is tested: dot-*folders* dropping -//! out of the structure listing in `dirs.rs`, dot-*resources* dropping out of the -//! inventory in `resources.rs`. This file owns the note route and the write guard. +//! Hidden means hidden (GH #136, data-model.md §1): a dot-prefixed name is not vault +//! material, and no authoring command creates one. Dot-folders are in `dirs.rs`, +//! dot-resources in `resources.rs`; this file owns notes and the write guard. mod common; @@ -15,9 +10,7 @@ use common::{count, index_conn}; use std::fs; use std::path::Path; -/// A vault whose Markdown is split across the managed subtree and three hidden -/// places: a dot-prefixed note at the root, one inside a managed folder, and an -/// ordinary-looking note inside a dot-folder. +/// One visible note, plus hidden Markdown at the root, in a folder, and in a dot-folder. fn vault_with_hidden_markdown(root: &Path) -> Vault { fs::create_dir_all(root.join("notes")).unwrap(); fs::create_dir_all(root.join(".templates")).unwrap(); @@ -36,9 +29,7 @@ fn vault_with_hidden_markdown(root: &Path) -> Vault { Vault::open(root).unwrap() } -/// The three hidden files are absent from every projection the walk feeds — and, -/// because the skip happens before routing, absent as *notes* specifically: no -/// row, no chunk, no embedding. +/// The skip happens before routing, so hidden files leave no row, chunk or embedding. #[test] fn dot_prefixed_markdown_is_not_a_note() { let tmp = tempfile::TempDir::new().unwrap(); @@ -58,9 +49,7 @@ fn dot_prefixed_markdown_is_not_a_note() { let conn = index_conn(&root); assert_eq!(count(&conn, "notes"), 1); - // Chunks are keyed by note, so a hidden file that never became a note cannot - // have contributed any — asserting it directly keeps the claim honest if the - // note route ever grows a second entry point. + // Asserted directly in case the note route grows a second entry point. let chunk_notes: Vec = { let mut stmt = conn .prepare("SELECT DISTINCT note_path FROM chunks") @@ -74,16 +63,12 @@ fn dot_prefixed_markdown_is_not_a_note() { }; assert_eq!(chunk_notes, vec!["notes/real.md".to_string()]); - // …and the term they all share finds only the managed one. let hits = vault.search("capybaras", 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].path, "notes/real.md"); } -/// W4 holds for a skipped file: not indexing it is not touching it. Since GH #170 -/// b2 makes no unbidden write to *any* file (W1), so this is now the weaker half of -/// a stronger rule — kept because it is the rule this issue's walk is responsible -/// for, and a future write path would have to pass here before it shipped. +/// W4: not indexing a file is not touching it (the weaker half of W1). #[test] fn a_hidden_markdown_file_is_never_written_to() { let tmp = tempfile::TempDir::new().unwrap(); @@ -107,9 +92,7 @@ fn a_hidden_markdown_file_is_never_written_to() { } } -/// `plan_reindex` shares `collect_vault_files` with the real pass, so the preview -/// must agree about what is *not* a note. A dry run that counted a `.scratch.md` -/// the real run then skipped would be a lying preview. +/// The dry run must agree with the real pass about what is not a note. #[test] fn dry_run_agrees_the_hidden_files_are_not_notes() { let tmp = tempfile::TempDir::new().unwrap(); @@ -124,18 +107,11 @@ fn dry_run_agrees_the_hidden_files_are_not_notes() { assert_eq!(plan.would_embed, report.embedded); } -// The byte-wise half of the same rule — a name UTF-8 rejects, which must still read -// as hidden — is a unit test on the predicate in `src/pathspec.rs`, not a case here: -// APFS refuses to *create* such a name (EILSEQ), so there is no file to walk on the -// platform B2 ships on. See that test for why the predicate must answer anyway. - -/// The migration path a pre-#136 vault takes, and the one a rename opens: a `.md` -/// that *was* indexed under a managed name and is then hidden drops out of the -/// projection on the next pass — ghost-pruned (#31), because incremental must equal -/// a from-scratch rebuild (S3) and a rebuild would never have collected it. The file -/// keeps its bytes (W4), so renaming it back re-adopts it at exactly the path it -/// left — which, since GH #170, *is* its identity, so the round trip is lossless -/// without anything having been stamped into it. +// Non-UTF-8 hidden names are a unit test in `src/pathspec.rs`: APFS refuses to create +// them, so there is no file to walk here. + +/// A note renamed into hiding is ghost-pruned (GH #31, S3) with its bytes kept (W4), and +/// renaming it back re-adopts it at the same path, its identity (GH #170). #[test] fn a_note_renamed_into_hiding_is_pruned_and_readopted_on_the_way_back() { let tmp = tempfile::TempDir::new().unwrap(); @@ -149,7 +125,6 @@ fn a_note_renamed_into_hiding_is_pruned_and_readopted_on_the_way_back() { assert_eq!(report.notes_pruned, 1, "the hidden note is a ghost row now"); assert!(vault.list_notes().unwrap().is_empty()); - // Pruned from the index, byte-untouched on disk. let hidden = fs::read_to_string(root.join("notes/.real.md")).unwrap(); assert_eq!( hidden, @@ -163,9 +138,7 @@ fn a_note_renamed_into_hiding_is_pruned_and_readopted_on_the_way_back() { assert_eq!(notes[0].path, "notes/real.md"); } -/// The write side of the same rule: b2 refuses to *create* what it would then never -/// index. Every authoring destination shares one validator, so the refusal covers a -/// note, a resource, and a folder alike — each mapped onto its own error variant. +/// b2 refuses to create what it would never index: notes, resources and folders alike. #[test] fn authoring_refuses_a_hidden_destination() { let tmp = tempfile::TempDir::new().unwrap(); @@ -204,8 +177,7 @@ fn authoring_refuses_a_hidden_destination() { Error::DirDestination(_) )); - // The refusals are about the *leading* dot, not any dot: an extension-looking - // interior dot is an ordinary name and must still be creatable. + // Only a leading dot hides; an interior dot is ordinary. assert!(vault.create_note("notes/v1.2.md").is_ok()); assert!(vault.create_dir("notes/v1.2").is_ok()); } diff --git a/crates/b2-core/tests/import.rs b/crates/b2-core/tests/import.rs index 5c42f4f..3218727 100644 --- a/crates/b2-core/tests/import.rs +++ b/crates/b2-core/tests/import.rs @@ -1,13 +1,6 @@ -//! Importing an outside file through the [`Vault`] façade — the kernel behind the desktop's -//! drag-from-Finder gesture and its OS-picker twin. -//! -//! What these pin beyond "the file arrives": an import is a **byte-honest copy** (a dropped -//! `.md` keeps its own frontmatter, a dropped binary its bytes), it **projects** what it -//! placed so the tree and search see it with no reindex, it routes on the extension exactly -//! as the vault walk does, and it never clobbers or lands anywhere but where it was aimed. -//! An arriving *copy of a note* used to need its own refusal (the copy carried the -//! original's `b2id`); with identity being the path (ADR-0003) it is simply a second note at -//! a second path, which is what it looks like in Finder too. +//! Importing an outside file (the desktop's drag-from-Finder and file picker): a byte-honest +//! copy, projected with no reindex, routed by extension like the walk, never clobbering or +//! landing anywhere but where it was aimed. mod common; @@ -15,8 +8,7 @@ use b2_core::Error; use common::{count, index_conn, opened_vault, reindexed_vault, MEMORY_PATH}; use std::fs; -/// The PNG header bytes — a binary payload that is *not* valid UTF-8, so an import -/// that quietly treated everything as text would fail this rather than pass. +/// Not valid UTF-8, so an import that treated everything as text would fail. const PNG_BYTES: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0x00]; #[test] @@ -35,7 +27,7 @@ fn a_dropped_binary_lands_in_the_folder_byte_for_byte_and_inventories() { PNG_BYTES, "the bytes are copied verbatim — B2 authors nothing here" ); - // Projected on the way in: the tree lists it with no reindex. + // Listed with no reindex. let listed = vault.list_resources().unwrap(); assert!( listed.iter().any(|r| r.path == "resources/schematic.png"), @@ -48,7 +40,6 @@ fn a_dropped_markdown_file_lands_as_a_note_with_its_own_frontmatter_intact() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // A note from somewhere else: its own frontmatter, its own keys. let doc = "---\ntitle: \"From elsewhere\"\ncreated: 2026-01-02\nsource: web\n---\n\nA clipped paragraph about hydroponics.\n"; let report = vault .import_file("notes", "clipped.md", doc.as_bytes()) @@ -57,12 +48,10 @@ fn a_dropped_markdown_file_lands_as_a_note_with_its_own_frontmatter_intact() { assert_eq!(report.path, "notes/clipped.md"); assert!(report.note, "a `.md` is routed as a note"); - // Byte-honest, and now byte-*identical*: B2 adds nothing at all (W1), because - // the destination path it was given is already the note's identity (L1). + // B2 adds nothing (W1): the path is already the identity (L1). let on_disk = fs::read_to_string(root.join("notes/clipped.md")).unwrap(); assert_eq!(on_disk, doc, "the bytes are the human's, verbatim"); - // Projected: readable and keyword-searchable immediately, no reindex. assert_eq!( vault.read("notes/clipped.md").unwrap().path, "notes/clipped.md" @@ -84,7 +73,6 @@ fn an_imported_note_authors_body_links_into_the_graph() { .import_file("notes", "arrival.md", doc.as_bytes()) .unwrap(); - // The edge is derived from the arriving Markdown like any other note's. let neighbors = vault.neighbors("notes/arrival.md").unwrap(); assert!( neighbors.iter().any(|n| n.path == MEMORY_PATH), @@ -103,7 +91,7 @@ fn the_vault_root_is_a_destination_and_a_nested_folder_is_created() { ); assert!(root.join("top.png").is_file()); - // A folder that doesn't exist yet is created, mirroring `add`'s parent creation. + // A missing folder is created, as `add` does. assert_eq!( vault .import_file("archive/2026", "old.png", PNG_BYTES) @@ -140,8 +128,7 @@ fn a_name_that_is_really_a_path_cannot_redirect_the_import() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // The safety property: the human dropped on `notes/`, so nothing may land - // outside it — whatever the file claims to be called. + // Dropped on `notes/`, so nothing may land outside it, whatever the name says. for name in ["../escaped.png", "sub/nested.png", ".hidden.png"] { let err = vault.import_file("notes", name, PNG_BYTES).unwrap_err(); assert!( @@ -154,11 +141,7 @@ fn a_name_that_is_really_a_path_cannot_redirect_the_import() { assert!(!root.join("notes/sub").exists()); } -/// A copy of a note you already have is, since GH #170, simply a second note — the -/// same thing it is in Finder. The refusal this replaces (`Error::B2idCollision`) -/// existed because the copy carried the incumbent's stamped id and projecting it -/// would have moved that identity, and every inbound edge, onto the copy. With the -/// path as the identity there is nothing to steal: two paths, two notes. +/// Since GH #170 a copy of a note is simply a second note: two paths, two identities. #[test] fn an_arriving_copy_of_a_note_is_just_a_second_note() { let tmp = tempfile::TempDir::new().unwrap(); @@ -170,8 +153,7 @@ fn an_arriving_copy_of_a_note_is_just_a_second_note() { .unwrap(); assert_eq!(report.path, "notes/memory-copy.md"); - // Both exist, both index, and the original keeps every inbound edge it had — - // the copy took nothing from it. + // The copy takes nothing from the original. assert_eq!( fs::read_to_string(root.join("notes/memory-copy.md")).unwrap(), original, @@ -195,7 +177,6 @@ fn import_path_copies_the_picked_file_keeping_its_name() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // A file outside the vault, as an OS file picker would hand it over. let outside = tmp.path().join("Downloads"); fs::create_dir_all(&outside).unwrap(); let source = outside.join("paper.pdf"); @@ -229,8 +210,7 @@ fn import_path_refuses_an_occupied_destination_rather_than_truncating_it() { let err = vault.import_path("concepts", &source).unwrap_err(); assert!(matches!(err, Error::ImportTargetExists(_)), "{err:?}"); - // The destination is reserved by a create-new open, so the occupied case never - // reaches a write — where a plain `fs::copy` would have truncated the incumbent. + // A create-new open reserves the destination; `fs::copy` would have truncated it. assert_eq!(fs::read_to_string(root.join(MEMORY_PATH)).unwrap(), before); } @@ -249,9 +229,7 @@ fn import_path_refuses_a_folder() { #[test] fn importing_into_a_never_reindexed_vault_needs_no_model_and_no_index_first() { let tmp = tempfile::TempDir::new().unwrap(); - // `opened_vault` — projected by nothing yet. Import is model-free (a - // `ProjectionCtx`, like `create_note`), so it works before any embedding space - // exists; the note's vectors fill on the next embed pass. + // Import is model-free, so it works before any embedding space exists. let (vault, root) = opened_vault(tmp.path()); let report = vault @@ -291,8 +269,7 @@ fn a_reindex_after_an_import_changes_nothing_it_did() { .unwrap(); let after_import = fs::read_to_string(root.join("notes/arrival.md")).unwrap(); - // S2/S3: what the import projected is what a full rebuild derives — an import is - // ordinary vault material the moment it lands, with nothing special recorded. + // S2/S3: an import is ordinary vault material the moment it lands. vault.reindex().unwrap(); assert_eq!(vault.read("notes/arrival.md").unwrap().path, imported.path); diff --git a/crates/b2-core/tests/ingest_resolver.rs b/crates/b2-core/tests/ingest_resolver.rs index baa6484..e7d9ca5 100644 --- a/crates/b2-core/tests/ingest_resolver.rs +++ b/crates/b2-core/tests/ingest_resolver.rs @@ -1,11 +1,6 @@ -//! Step 1 — ingest into `notes` and the link resolver -//! (index-engine.md): ingest the golden vault, then resolve a wikilink in both the -//! extensionless and `.md` forms. -//! -//! Since GH #170 the resolver is one-way and one-step: a note's identity **is** its -//! vault-relative path, so there is nothing to translate — `resolve_link_target` -//! only decides whether the authored target names a note, and the `+ ".md"` ladder -//! is the whole of the tolerance it offers. +//! Ingest and the link resolver (index-engine.md). Since GH #170 the path is the identity, +//! so `resolve_link_target` only decides whether a target names a note, tolerating a +//! missing `.md`. mod common; @@ -18,9 +13,7 @@ fn ingests_golden_vault_and_resolves_a_link_in_both_authored_forms() { let tmp = tempfile::TempDir::new().unwrap(); let conn = ingest_golden(tmp.path(), &FakeEmbedder::default()); - // The resolver canonicalizes both authored forms onto the one identity: the - // Obsidian habit writes `[[concepts/memory]]`, a Markdown link writes the - // extension, and each must name the same note. + // Obsidian writes `[[concepts/memory]]`, a Markdown link the extension. assert_eq!( db::resolve_link_target(&conn, "concepts/memory").unwrap(), Some(MEMORY_PATH.to_string()), @@ -37,6 +30,5 @@ fn ingests_golden_vault_and_resolves_a_link_in_both_authored_forms() { "a target naming no note is dangling, not an error (G5)" ); - // both golden notes landed assert_eq!(common::count(&conn, "notes"), 2); } diff --git a/crates/b2-core/tests/list.rs b/crates/b2-core/tests/list.rs index f9fe024..f9c148f 100644 --- a/crates/b2-core/tests/list.rs +++ b/crates/b2-core/tests/list.rs @@ -1,7 +1,5 @@ -//! `Vault::list_notes` — the vault listing the desktop UI's file tree is built from. -//! Its contract: every indexed note as a lightweight `NoteSummary` (`path`, -//! `title`; no body), ordered by `path`, and each entry `read`-resolvable. A pure -//! read, model-free (FakeEmbedder), against the golden-vault fixture. +//! `Vault::list_notes`, the desktop file tree's source: every indexed note, no body, +//! ordered by path, each `read`-resolvable. mod common; @@ -14,15 +12,13 @@ fn list_notes_returns_every_note_ordered_by_path() { let notes = vault.list_notes().unwrap(); - // The whole vault, in path order (concepts/… before notes/…). let paths: Vec<&str> = notes.iter().map(|n| n.path.as_str()).collect(); assert_eq!( paths, vec!["concepts/memory.md", "notes/spaced-repetition.md"] ); - // Identity + display title come through; the title is the filename (data-model.md - // §1), so `concepts/memory.md` lists as "memory". No body field to carry. + // The title is the filename (data-model.md §1). assert_eq!(notes[0].path, MEMORY_PATH); assert_eq!(notes[0].title.as_deref(), Some("memory")); assert_eq!(notes[1].path, SRS_PATH); @@ -33,7 +29,7 @@ fn every_listed_note_is_readable() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // The tree only shows what the index knows, so a click on any entry always opens. + // So a click on any tree entry always opens. for summary in vault.list_notes().unwrap() { let note = vault.read(&summary.path).unwrap(); assert_eq!(note.path, summary.path); diff --git a/crates/b2-core/tests/logging.rs b/crates/b2-core/tests/logging.rs index cf1570a..cc63af5 100644 --- a/crates/b2-core/tests/logging.rs +++ b/crates/b2-core/tests/logging.rs @@ -1,10 +1,6 @@ -//! The kernel's structured debug logging: SQLite's own profiler -//! (`sqlite3_trace_v2` + `SQLITE_TRACE_PROFILE`, wired in `db::open`) must emit a -//! `b2::sqlite` tracing event per statement — the SQL **template** (never bound -//! values) plus a numeric `duration_us` — and the façade ops must run inside named -//! spans. Asserted through a real JSON subscriber, so what is checked is the exact -//! contract the CLI's `B2_LOG` sink exposes for reporting/plotting: every line is -//! one parseable JSON object with flat, typed fields. +//! Structured debug logging: SQLite's profiler emits a `b2::sqlite` event per statement +//! (the SQL template, never bound values, plus `duration_us`), and façade ops run in named +//! spans. Checked through a JSON subscriber like the CLI's `B2_LOG` sink. mod common; @@ -14,8 +10,7 @@ use std::sync::{Arc, Mutex}; use tracing_subscriber::fmt::format::FmtSpan; use tracing_subscriber::fmt::MakeWriter; -/// A `MakeWriter` capturing everything the subscriber renders, so the test can -/// parse the produced JSONL back. +/// Captures everything the subscriber renders. #[derive(Clone, Default)] struct Capture(Arc>>); @@ -36,8 +31,7 @@ impl<'a> MakeWriter<'a> for Capture { } } -/// Run `f` under a JSON subscriber configured like the CLI's `B2_LOG` sink -/// (flattened events, span-close events) and return the captured log text. +/// Run `f` under a JSON subscriber configured like the CLI's `B2_LOG` sink. fn capture_logs(f: impl FnOnce()) -> String { let capture = Capture::default(); let subscriber = tracing_subscriber::fmt() @@ -49,22 +43,17 @@ fn capture_logs(f: impl FnOnce()) -> String { .with_writer(capture.clone()) .with_ansi(false) .finish(); - // Thread-local subscriber: SQLite's trace callback fires synchronously on this - // thread, so its events land here without touching other tests' threads. + // Thread-local: SQLite's trace callback fires synchronously on this thread. tracing::subscriber::with_default(subscriber, f); let bytes = capture.0.lock().expect("capture lock").clone(); String::from_utf8(bytes).expect("log output is UTF-8") } -/// One test, two phases run in sequence. They must not be separate `#[test]`s: -/// the harness would run them on parallel threads, and tracing's **global** -/// callsite-interest cache races when one thread evaluates callsites with no -/// subscriber while the other holds a scoped one — events are then dropped -/// nondeterministically. (Real processes install at most one subscriber, so the -/// race is a test-harness artifact, not a kernel one.) +/// Two phases in one test: as separate tests on parallel threads, tracing's global +/// callsite-interest cache would race and drop events. A harness artifact only. #[test] fn sqlite_queries_emit_parseable_timing_events() { - // Phase 1 — inert without a subscriber: same results, no output path, no panic. + // Phase 1: inert without a subscriber. { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); @@ -75,7 +64,7 @@ fn sqlite_queries_emit_parseable_timing_events() { assert!(!vault.search("memory", 5).unwrap().is_empty()); } - // Phase 2 — under a JSON subscriber, the same flow emits the reporting contract. + // Phase 2: under a JSON subscriber. let text = capture_logs(|| { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); @@ -88,28 +77,23 @@ fn sqlite_queries_emit_parseable_timing_events() { let mut sqlite_events = 0usize; let mut saw_vault_span_close = false; for line in text.lines() { - // The reporting contract: every emitted line is one JSON object. let v: serde_json::Value = serde_json::from_str(line) .unwrap_or_else(|e| panic!("non-JSON log line ({e}): {line}")); if v["target"] == "b2::sqlite" { sqlite_events += 1; - // Numeric duration (µs) straight from SQLite's profiler — plottable as-is. assert!(v["duration_us"].is_u64(), "duration_us not a u64: {line}"); assert!(v["vm_steps"].is_number(), "vm_steps missing: {line}"); assert!(v["slow"].is_boolean(), "slow flag missing: {line}"); - // The SQL template, single-line; bound values are never expanded into it. let sql = v["sql"].as_str().expect("sql is a string"); assert!(!sql.contains('\n'), "sql not collapsed to one line: {line}"); } - // Façade ops are spans; their close events carry the op's own timing. if v["target"] == "b2::vault" && v["span"]["name"] == "search" { saw_vault_span_close = true; } } - // A reindex + search runs many statements (migration, upserts, FTS, KNN…). assert!( sqlite_events > 10, "expected many b2::sqlite events, got {sqlite_events}" diff --git a/crates/b2-core/tests/mv.rs b/crates/b2-core/tests/mv.rs index 2e1d957..448abcd 100644 --- a/crates/b2-core/tests/mv.rs +++ b/crates/b2-core/tests/mv.rs @@ -1,7 +1,5 @@ -//! `b2 mv` — move/rename a note and repair inbound links (invariants.md, -//! the locked invariant "rename keeps every backlink resolving"). Driven through -//! the [`Vault`] façade against the golden vault (and a small purpose-built vault -//! for prefix-safety), fully deterministic under the FakeEmbedder. +//! `b2 mv`: move a note or folder and repair inbound links, so every backlink keeps +//! resolving (invariants.md). mod common; @@ -16,7 +14,6 @@ fn move_rewrites_inbound_links_and_the_graph_is_unchanged() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // The backlink set of memory, before the move (SRS supports + references it). let before = inbound(&vault, MEMORY_PATH); assert_eq!( before, @@ -41,20 +38,15 @@ fn move_rewrites_inbound_links_and_the_graph_is_unchanged() { "the body link + the frontmatter relation's link" ); - // The file moved on disk. assert!(!root.join("concepts/memory.md").exists()); assert!(root.join("concepts/human-memory.md").exists()); - // The inbound text was rewritten to the new path; no stale link remains. let srs = fs::read_to_string(root.join("notes/spaced-repetition.md")).unwrap(); assert!(srs.contains("[[concepts/human-memory|Human memory]]")); assert!(!srs.contains("[[concepts/memory|")); - // The graph arrives intact at the destination: the note's identity moved with - // it (L1), and every backlink came along — index-side through the cascading - // re-key, Markdown-side through the rewritten link text above. + // Identity moved with the path (L1): the index re-keys, the Markdown is rewritten. assert_eq!(inbound(&vault, "concepts/human-memory.md"), before); - // The old path no longer resolves. assert!(matches!( vault.neighbors("concepts/memory.md").unwrap_err(), Error::NoteNotFound(_) @@ -73,12 +65,10 @@ fn move_changes_only_the_link_path_every_other_byte_identical() { .move_note("concepts/memory.md", "concepts/human-memory.md") .unwrap(); - // The moved note's content is byte-for-byte what it was (only its path changed). let memory_after = fs::read_to_string(root.join("concepts/human-memory.md")).unwrap(); assert_eq!(memory_after, memory_before); - // The inbound file differs by *exactly* the rewritten target token — nothing - // else. (Story 1: "only their link `path` changed — every other byte identical".) + // Only the link target changed. let srs_after = fs::read_to_string(root.join("notes/spaced-repetition.md")).unwrap(); assert_eq!( srs_after, @@ -90,7 +80,6 @@ fn move_changes_only_the_link_path_every_other_byte_identical() { fn move_leaves_unrelated_files_byte_identical() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // A note that links to nothing relevant. let bystander = root.join("unrelated.md"); fs::write( &bystander, @@ -130,7 +119,6 @@ fn move_into_a_new_subdirectory_creates_it() { .unwrap(); assert!(root.join("archive/deep/memory.md").is_file()); - // Backlinks still resolve after crossing directories. assert_eq!(inbound(&vault, "archive/deep/memory").len(), 2); } @@ -143,7 +131,6 @@ fn move_onto_an_existing_file_is_refused() { .move_note("concepts/memory.md", "notes/spaced-repetition.md") .unwrap_err(); assert!(matches!(err, Error::MoveTargetExists(p) if p == "notes/spaced-repetition.md")); - // Nothing moved. assert!(root.join("concepts/memory.md").exists()); } @@ -161,7 +148,7 @@ fn an_invalid_destination_is_rejected() { "destination {dest:?} must be rejected" ); } - // Moving a note onto itself is a no-op error, not a silent clobber. + // Onto itself: an error, not a silent clobber. assert!(matches!( vault .move_note("concepts/memory.md", "concepts/memory.md") @@ -181,9 +168,8 @@ fn moving_an_unknown_note_is_note_not_found() { assert!(matches!(err, Error::NoteNotFound(r) if r == "does/not/exist")); } -/// A purpose-built vault for the dir-move suite: `docs/` holds two linked notes -/// and a resource, with inbound links from outside in both syntaxes and an -/// unindexed dotfile that must travel with the folder. +/// `docs/` holds two linked notes, a resource and an unindexed dotfile, with inbound +/// links from outside in both syntaxes. fn dir_move_vault(root: &Path) -> Vault { fs::create_dir_all(root.join("docs")).unwrap(); fs::write( @@ -239,9 +225,7 @@ fn move_dir_moves_an_empty_folder() { let vault = dir_move_vault(&root); fs::create_dir_all(root.join("scratch")).unwrap(); - // An empty folder is a real vault member (fs-authoritative structure): the - // move resolves against the filesystem, not the index, so the rename works - // exactly like a full folder's — just with nothing indexed to repoint. + // Folder moves resolve against the filesystem, not the index. let report = vault.move_dir("scratch", "archive/scratch").unwrap(); assert_eq!(report.moved_notes, 0); assert_eq!(report.moved_resources, 0); @@ -259,17 +243,15 @@ fn move_dir_rewrites_inbound_and_intra_folder_links_and_graph_is_unchanged() { let before = inbound(&vault, "docs/alpha.md"); let report = vault.move_dir("docs", "media").unwrap(); - // The outside file's wikilink and Markdown resource link are both rewritten. let hub = fs::read_to_string(root.join("hub.md")).unwrap(); assert!(hub.contains("[[media/alpha|Alpha]]")); assert!(hub.contains("![pic](media/pic.png)")); - // The vault-root wikilink BETWEEN co-moved notes is rewritten too. + // The vault-root wikilink between co-moved notes is rewritten too. let alpha = fs::read_to_string(root.join("media/alpha.md")).unwrap(); assert!(alpha.contains("[[media/beta|Beta]]")); - // `rewrote` reports post-move paths; the intra-folder relative resource link - // (`![p](pic.png)`) was a natural no-op, so alpha counts for its wikilink only. + // Post-move paths; alpha's relative `![p](pic.png)` needed no rewrite. assert_eq!( report.rewrote, vec!["hub.md".to_string(), "media/alpha.md".to_string()] @@ -279,8 +261,6 @@ fn move_dir_rewrites_inbound_and_intra_folder_links_and_graph_is_unchanged() { "hub's two links + alpha's sibling link" ); - // The graph arrives intact at the new paths: every moved note re-keyed with - // the folder, and the inbound link text followed. assert_eq!(inbound(&vault, "media/alpha.md"), before); assert!(matches!( vault.neighbors("docs/alpha").unwrap_err(), @@ -297,15 +277,13 @@ fn move_dir_keeps_relative_intra_folder_resource_links_byte_stable() { let beta_before = fs::read_to_string(root.join("docs/beta.md")).unwrap(); vault.move_dir("docs", "media").unwrap(); - // beta had no links to rewrite — byte-identical at its new path. assert_eq!( fs::read_to_string(root.join("media/beta.md")).unwrap(), beta_before ); - // alpha's relative `![p](pic.png)` survives verbatim (both ends moved). + // Both ends moved, so the relative link survives verbatim. let alpha = fs::read_to_string(root.join("media/alpha.md")).unwrap(); assert!(alpha.contains("![p](pic.png)")); - // And the inventory + backlinks resolved at the new resource path. let view = vault.explain_resource("media/pic.png").unwrap(); let mut sources: Vec = view.backlinks.into_iter().map(|b| b.path).collect(); sources.sort(); @@ -326,7 +304,6 @@ fn move_dir_incremental_equals_full_rebuild() { let neighbors_after_move = inbound(&vault, "archive/media/alpha.md"); drop(vault); - // Drop the disposable index and rebuild from the Markdown alone. fs::remove_dir_all(root.join(".b2")).unwrap(); let rebuilt = Vault::open(&root).unwrap(); rebuilt.reindex().unwrap(); @@ -355,7 +332,6 @@ fn move_dir_invalid_destinations_are_rejected() { let root = tmp.path().join("vault"); let vault = dir_move_vault(&root); - // Into its own subtree, onto itself, and the usual invalid shapes. for dest in ["docs/inner", "docs", "../out", "/abs", " ", ".b2/x"] { assert!( matches!( @@ -365,7 +341,7 @@ fn move_dir_invalid_destinations_are_rejected() { "destination {dest:?} must be rejected" ); } - // A prefix-sharing sibling name is NOT "inside" the moved folder. + // A prefix-sharing sibling is not inside the moved folder. vault.move_dir("docs", "docs2").unwrap(); assert!(root.join("docs2/alpha.md").is_file()); } @@ -381,7 +357,6 @@ fn move_dir_onto_an_existing_entry_is_refused_but_unknown_source_is_dir_not_foun assert!(matches!(err, Error::MoveTargetExists(p) if p == "existing")); assert!(root.join("docs/alpha.md").is_file(), "nothing moved"); - // A file at the destination refuses the same way. let err = vault.move_dir("docs", "hub.md").unwrap_err(); assert!(matches!(err, Error::MoveTargetExists(_))); @@ -396,9 +371,8 @@ fn move_dir_case_only_rename_succeeds_on_a_case_insensitive_fs() { let root = tmp.path().join("vault"); let vault = dir_move_vault(&root); - // On case-sensitive APFS variants this is an ordinary rename; on the default - // case-insensitive APFS the destination "exists" as the source itself and the - // same-dirent carve-out must let it through. Either way it succeeds. + // On case-insensitive APFS the destination "exists" as the source itself; the + // same-dirent carve-out must let it through. let report = vault.move_dir("docs", "Docs").unwrap(); assert_eq!(report.to, "Docs"); assert_eq!( @@ -414,8 +388,7 @@ fn move_dir_case_only_rename_succeeds_on_a_case_insensitive_fs() { #[test] fn move_repairs_only_the_moved_target_not_prefix_siblings() { - // A purpose-built vault where an inbound file links to BOTH the moved note and - // a prefix-sharing sibling — the sibling link must survive untouched. + // The hub links to the moved note and to a prefix-sharing sibling. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); fs::create_dir_all(root.join("concepts")).unwrap(); @@ -454,15 +427,8 @@ fn move_repairs_only_the_moved_target_not_prefix_siblings() { ); } -/// **A move re-embeds nothing** — the content-addressed vector store paying for the -/// pivot (M4, GH #170). Identity is the path, so moving a note changes it; what -/// makes that cheap is that vectors are keyed by chunk *text*, which a move does not -/// touch. Asserted on the vectors themselves, not on a count: the moved note's -/// passages must come back byte-identical, at the new path, with no forward pass. -/// -/// The in-band half. The out-of-band half is below, and matters more: there the note -/// is projected as a delete plus a create, so "re-embeds nothing" is the *only* -/// thing keeping that path cheap. +/// A move re-embeds nothing: vectors are keyed by chunk text, which a move does not touch +/// (M4, GH #170). Asserted on the vector bytes, not a count. #[test] fn a_move_reuses_every_vector() { let tmp = tempfile::TempDir::new().unwrap(); @@ -488,14 +454,8 @@ fn a_move_reuses_every_vector() { ); } -/// The out-of-band move: a `git mv`/Finder rename, which a path-keyed index sees as -/// a delete plus a create. Two claims, and the pivot needs both: -/// -/// * the inbound links **surface as dangling** rather than silently resolving or -/// vanishing (G5) — the scope decision GH #170 made, "identification, not repair"; -/// * it **re-embeds nothing**, because the re-created note's chunks hash to vectors -/// already stored. That is what keeps the accepted loss to chunk/FTS/edge -/// re-projection instead of a full re-embed of the moved file. +/// An out-of-band rename is a delete plus a create: its backlinks surface as dangling +/// (G5; GH #170, "identification, not repair"), and its chunks reuse stored vectors. #[test] fn an_out_of_band_move_dangles_its_backlinks_and_re_embeds_nothing() { let tmp = tempfile::TempDir::new().unwrap(); @@ -503,7 +463,7 @@ fn an_out_of_band_move_dangles_its_backlinks_and_re_embeds_nothing() { let conn = common::index_conn(&root); let before = note_vectors(&conn, MEMORY_PATH); - // Behind B2's back — no `b2 mv`, so no link repair and no re-key. + // Behind B2's back: no link repair, no re-key. fs::create_dir_all(root.join("archive")).unwrap(); fs::rename(root.join(MEMORY_PATH), root.join("archive/human-memory.md")).unwrap(); @@ -519,8 +479,7 @@ fn an_out_of_band_move_dangles_its_backlinks_and_re_embeds_nothing() { "and they are the same vectors, reachable at the new path" ); - // The accepted loss, surfaced rather than hidden: SRS still links the old path, - // and that link now reads as broken instead of resolving to nothing in silence. + // SRS still links the old path, and that link reads as broken. let dangling = vault.unresolved_links(SRS_PATH).unwrap(); assert!( dangling.iter().any(|u| u.target == "concepts/memory"), @@ -535,9 +494,7 @@ fn an_out_of_band_move_dangles_its_backlinks_and_re_embeds_nothing() { ); } -/// A note's stored chunk vectors in `seq` order, as raw blobs — the unit both move -/// tests compare, so "re-embeds nothing" is checked on the bytes rather than on a -/// count that a coincidence could satisfy. +/// A note's stored chunk vectors in `seq` order, as raw blobs. fn note_vectors(conn: &rusqlite::Connection, note_path: &str) -> Vec> { let mut stmt = conn .prepare( @@ -551,13 +508,11 @@ fn note_vectors(conn: &rusqlite::Connection, note_path: &str) -> Vec> { // --- a failed move changes nothing (GH #230) ------------------------------------ // -// A move writes the vault in two kinds of step: the inbound files' link text, then the -// rename. A move that fails must leave every file byte-identical — reindexing cannot -// repair rewritten link text, because it faithfully projects whatever the Markdown now -// says. Two routes to a failure: a destination refused up front, and an I/O failure -// after the rewrites have been written, which must be rolled back. +// A move rewrites inbound link text, then renames. A failed move must leave every file +// byte-identical, since a reindex would faithfully project rewritten links. Failures are +// refused up front, or rolled back after an I/O error. -/// A purpose-built vault under `dir/vault` from `(path, contents)` pairs, reindexed. +/// A reindexed vault under `dir/vault` from `(path, contents)` pairs. fn small_vault(dir: &Path, files: &[(&str, &str)]) -> (Vault, std::path::PathBuf) { let root = dir.join("vault"); for (path, contents) in files { @@ -570,8 +525,7 @@ fn small_vault(dir: &Path, files: &[(&str, &str)]) -> (Vault, std::path::PathBuf (vault, root) } -/// Every file under `root` (outside `.b2/`) with its bytes, sorted by path — the -/// whole authored vault, so an assertion on it catches a stray write anywhere. +/// Every file under `root` outside `.b2/`, with its bytes, sorted by path. fn vault_bytes(root: &Path) -> Vec<(String, Vec)> { fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, Vec)>) { for entry in fs::read_dir(dir).unwrap() { @@ -600,8 +554,7 @@ fn vault_bytes(root: &Path) -> Vec<(String, Vec)> { #[test] fn a_move_under_a_file_is_refused_and_changes_nothing() { - // The issue's reproduction: `blocked` is a regular file, so `blocked/a.md` can - // never exist. The move must be refused before a single inbound link is touched. + // `blocked` is a regular file, so `blocked/a.md` can never exist. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = small_vault( tmp.path(), @@ -629,7 +582,6 @@ fn a_move_under_a_file_is_refused_and_changes_nothing() { assert_eq!(vault_bytes(&root), before, "every file byte-identical"); assert_eq!(inbound(&vault, "a.md"), backlinks, "the index is untouched"); - // And the Markdown still says what it said: a rebuild resolves the same links. vault.reindex().unwrap(); assert_eq!( inbound(&vault, "a.md"), @@ -640,7 +592,6 @@ fn a_move_under_a_file_is_refused_and_changes_nothing() { #[test] fn a_move_deeper_under_a_file_is_refused_too() { - // The blocking file may be any ancestor, not just the immediate parent. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = small_vault( tmp.path(), @@ -659,10 +610,8 @@ fn a_move_deeper_under_a_file_is_refused_too() { #[test] fn a_move_whose_rename_fails_restores_every_rewritten_file() { - // A failure *after* the rewrites: the note vanished from disk (an out-of-band - // delete the index hasn't seen), so the destination checks pass, both inbound - // files are rewritten, and only then does the rename fail. The rewrites and the - // destination folder the move created must all be undone. + // The note vanished out of band, so the rename fails after the rewrites. The + // rewrites and the created destination folder must be undone. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = small_vault( tmp.path(), @@ -709,8 +658,7 @@ fn a_resource_move_under_a_file_is_refused_and_changes_nothing() { #[test] fn a_resource_move_whose_rename_fails_restores_every_rewritten_note() { - // The resource arm of the rollback: both link syntaxes are rewritten, then the - // rename fails because the file is gone from disk. + // Both link syntaxes are rewritten, then the rename fails. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = small_vault( tmp.path(), @@ -760,10 +708,8 @@ fn move_dir_under_a_file_is_refused_and_changes_nothing() { #[test] fn re_running_a_move_interrupted_before_its_rename_finishes_it() { - // The one window no undo covers is a crash between the rewrites and the rename: - // the inbound links already name the destination, the note is still at its source, - // and the index hasn't heard. Simulated by writing that state by hand; re-running - // the same move must complete it rather than refuse or double-rewrite. + // A crash between the rewrites and the rename, which no undo covers, simulated by + // hand. Re-running must complete the move, not refuse or double-rewrite. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = small_vault( tmp.path(), @@ -787,13 +733,11 @@ fn re_running_a_move_interrupted_before_its_rename_finishes_it() { // --- one grammar, one pipeline ------------------------------------------------- // -// The move reads link text with ingest's own scanner and runs every kind of move through -// one pipeline, so what a move repairs is what ingest projected, and each kind leaves -// the index a rebuild would produce. +// A move reads links with ingest's scanner, so every kind of move leaves the index a +// rebuild would produce. -/// Every projected note, chunk, edge and inventory row, sorted — the index state a move -/// must leave equal to a from-scratch rebuild (S3). Edge ids are derived from the -/// resolved target, so a wrongly (un)resolved edge shows up here too. +/// Every projected note, chunk, edge and inventory row, sorted (S3). Edge ids derive from +/// the resolved target, so a wrongly resolved edge shows up too. fn projection(root: &Path) -> Vec { let conn = common::index_conn(root); let mut rows: Vec = Vec::new(); @@ -905,9 +849,7 @@ fn linked_vault(dir: &Path) -> (Vault, std::path::PathBuf) { #[test] fn a_move_rewrites_a_link_after_a_stray_open_bracket() { - // Ingest scans per line, so `see [[a]]` is an edge even though an earlier line - // left a `[[` open. The move used to scan the whole file, pair that stray `[[` - // with the `]]` below, miss the link, and leave the backlink dangling. + // Ingest scans per line, so `see [[a]]` is an edge despite the stray `[[` above. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = small_vault( tmp.path(), @@ -999,8 +941,7 @@ fn a_folder_move_leaves_the_index_a_rebuild_would_project() { #[test] fn a_resource_move_to_a_note_path_is_refused_and_changes_nothing() { - // A `.md` path names a note: the moved bytes would be indexed as one by the next - // rebuild, so the resource would silently stop being a resource. + // A `.md` path would be indexed as a note by the next rebuild. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = linked_vault(tmp.path()); let before = vault_bytes(&root); diff --git a/crates/b2-core/tests/project_embed.rs b/crates/b2-core/tests/project_embed.rs index c56b55d..6ab8ae4 100644 --- a/crates/b2-core/tests/project_embed.rs +++ b/crates/b2-core/tests/project_embed.rs @@ -1,8 +1,6 @@ -//! The projection/embedding split (index-engine.md): `project` alone builds the complete -//! keyword + graph index with **no** vectors and no embedding space; `embed` fills -//! exactly the DB-derived missing vectors; and project→embed is **observably** -//! equivalent to the fused `reindex` (counts, chunk text, text→vector, edges — never -//! rowid equality, per §7.1). Model-free throughout (fake embedder). +//! The projection/embedding split (index-engine.md): `project` builds the keyword and +//! graph index with no vectors, `embed` fills exactly the missing ones, and the two are +//! observably equivalent to the fused `reindex` (§7.1: never rowid equality). mod common; @@ -26,13 +24,11 @@ fn project_only_builds_keyword_graph_index_with_no_vectors() { golden_vault_copy(&vault_dir); let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); - // Projection alone: no embedder anywhere near the call. If it issued any query - // against `embeddings` (which does not exist yet), this would error. + // No embedder: any query against the not-yet-created `embeddings` would error. let cfg = ChunkConfig::default(); let outcome = project_vault(ProjectionCtx::new(&conn, &vault_dir, &cfg), false).unwrap(); assert_eq!(outcome.notes.len(), 2); - // The keyword + graph index is complete… let chunks = count(&conn, "chunks"); assert!(chunks > 0); assert_eq!( @@ -41,7 +37,6 @@ fn project_only_builds_keyword_graph_index_with_no_vectors() { "FTS mirrors every chunk" ); assert!(count(&conn, "edges") > 0, "typed graph projected"); - // …and the embedding space was never created (that is the embed pass's job). assert!( !db::embedding_space_exists(&conn).unwrap(), "projection must not create the vector tables" @@ -59,22 +54,18 @@ fn embed_fills_exactly_the_missing_vectors() { project_vault(ProjectionCtx::new(&conn, &vault_dir, &cfg), false).unwrap(); - // First embed: every chunk lacks a vector → both notes embed, space is full. let first = embed_vault(&conn, &embedder, &mut |_| ControlFlow::Continue(())).unwrap(); assert!(!first.cancelled); assert_eq!(first.embedded.len(), 2, "both projected notes embed"); assert_eq!(count(&conn, "embeddings"), count(&conn, "chunks")); - // Second embed: the DB-derived pending set is empty → fills 0, changes nothing. let second = embed_vault(&conn, &embedder, &mut |_| ControlFlow::Continue(())).unwrap(); assert!(!second.cancelled); assert!(second.embedded.is_empty(), "a second embed fills nothing"); assert_eq!(count(&conn, "embeddings"), count(&conn, "chunks")); } -/// The observable projection of an index: note count, `(note, seq) → chunk text`, -/// `chunk text → vector bytes`, and the full edge rows — everything §7.1 calls -/// observable, and deliberately **not** chunk rowids. +/// Everything §7.1 calls observable in an index, and not chunk rowids. #[derive(Debug, PartialEq)] struct Observable { notes: i64, @@ -83,8 +74,7 @@ struct Observable { edges: Vec, } -/// An edge's identity + typing, minus the internal columns: `(id, src, dst, type, -/// origin, occurrence)`. +/// `(id, src, dst, type, origin, occurrence)`. type EdgeKey = (String, String, Option, String, String, i64); fn observable_state(root: &Path) -> Observable { @@ -147,20 +137,16 @@ fn project_then_embed_matches_reindex() { golden_vault_copy(&split_root); golden_vault_copy(&fused_root); - // One fresh copy through the split façade ops… let split = Vault::open(&split_root).unwrap(); let p = split.project(false).unwrap(); let e = split.embed(&mut |_| ControlFlow::Continue(())).unwrap(); assert!(!e.cancelled); - // …a sibling fresh copy through the composed reindex. let fused = Vault::open(&fused_root).unwrap(); let r = fused.reindex().unwrap(); - // The reports agree… assert_eq!((p.indexed, e.embedded), (r.indexed, r.embedded)); - // …and so does every observable aspect of the two indexes (§7.1). drop(split); drop(fused); let split_obs = observable_state(&split_root); @@ -179,24 +165,20 @@ fn project_then_embed_matches_reindex() { // --- resilience: one unreadable file must never abort the whole reindex ---------- // -// A real vault holds the odd non-UTF-8 or unreadable `.md`. Before this, a single such -// file made `fs::read_to_string` fail and took the entire projection (and thus the -// reindex) down with a generic error. The pass must skip it and index everything else. +// A real vault holds the odd non-UTF-8 `.md`; the pass skips it and indexes the rest. #[test] fn project_skips_unreadable_file_and_indexes_the_rest() { let tmp = tempfile::TempDir::new().unwrap(); let vault_dir = tmp.path().join("vault"); golden_vault_copy(&vault_dir); - // A `.md` file that is not valid UTF-8 (a stray 0xFF byte). `read_to_string` fails - // with `InvalidData` on it — the exact shape a large primary vault trips over. + // A stray 0xFF byte: not valid UTF-8. fs::write(vault_dir.join("bad.md"), [b'#', b' ', 0xff, b'\n']).unwrap(); let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); let cfg = ChunkConfig::default(); let outcome = project_vault(ProjectionCtx::new(&conn, &vault_dir, &cfg), false).unwrap(); - // Both readable notes projected; the bad one is skipped, not fatal. assert_eq!(outcome.notes.len(), 2, "both readable notes still index"); assert_eq!( outcome.skipped.len(), @@ -205,15 +187,11 @@ fn project_skips_unreadable_file_and_indexes_the_rest() { ); assert_eq!(outcome.skipped[0].path, "bad.md"); assert_eq!(outcome.skipped[0].reason, "not valid UTF-8 text"); - // The good notes' keyword index is intact. assert!(count(&conn, "chunks") > 0); } -/// A file replaced out of band — deleted and another renamed onto its path — is -/// simply that path's note now. The `UNIQUE constraint failed: notes.path` crash this -/// regresses came from two identities contending for one path, and GH #170 removed the -/// contention rather than the reconciliation: the path *is* the identity, so -/// `ON CONFLICT(path)` is the whole of it. +/// A file replaced out of band is simply that path's note now: the path is the identity +/// (GH #170), so there is no `UNIQUE` conflict to crash on. #[test] fn reindex_reconciles_a_path_taken_over_by_another_file() { let tmp = tempfile::TempDir::new().unwrap(); @@ -225,12 +203,10 @@ fn reindex_reconciles_a_path_taken_over_by_another_file() { let vault = Vault::open(&root).unwrap(); assert_eq!(vault.reindex().unwrap().indexed, 2); - // Out-of-b2 edit: delete foo.md, rename bar.md → foo.md. fs::remove_file(root.join("foo.md")).unwrap(); fs::rename(root.join("bar.md"), root.join("foo.md")).unwrap(); - // The incremental reindex converges on the current truth: one note at foo.md, - // carrying bar's content — byte-identical to a from-scratch rebuild (S3). + // Converges on one note at foo.md carrying bar's content (S3). let report = vault.reindex().unwrap(); assert_eq!(report.indexed, 1); let notes = vault.list_notes().unwrap(); @@ -242,18 +218,15 @@ fn reindex_reconciles_a_path_taken_over_by_another_file() { ); } -/// Write a minimal note at `root/name` — the same bytes in every root, so two roots -/// build comparable indexes (a note's identity is its path, which the caller picks). +/// Write a minimal note at `root/name`, byte-identical across roots so indexes compare. fn write_note(root: &Path, name: &str, body: &str) { fs::write(root.join(name), format!("---\n---\n\n{body}\n")).unwrap(); } #[test] fn reindex_prunes_a_deleted_note_like_a_full_rebuild() { - // A note file deleted outside b2 with *no replacement* must not linger as a ghost - // row (#31): before this, only a path reuse or a from-scratch rebuild evicted it, - // so an incremental reindex diverged from `full-reindex ≡ incremental-update`. - // foo links to bar so the deletion also exercises inbound-edge re-dangling. + // A note deleted outside b2 must not linger as a ghost row (GH #31). foo links to + // bar so the deletion also re-dangles an inbound edge. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); fs::create_dir_all(&root).unwrap(); @@ -264,34 +237,29 @@ fn reindex_prunes_a_deleted_note_like_a_full_rebuild() { let vault = Vault::open(&root).unwrap(); assert_eq!(vault.reindex().unwrap().indexed, 3); - // Out-of-b2 deletion, no replacement — the path is never reused. fs::remove_file(root.join("bar.md")).unwrap(); let report = vault.reindex().unwrap(); assert_eq!(report.indexed, 2); assert_eq!(report.notes_pruned, 1, "the ghost row is pruned"); - // Gone from every read surface: the listing… + // Gone from the listing, search and discovery. let notes = vault.list_notes().unwrap(); assert_eq!(notes.len(), 2); assert!(notes.iter().all(|n| n.path != "bar.md")); - // …search (its chunks and FTS entries cascaded with the row)… assert!(vault .search("tidal", 10) .unwrap() .iter() .all(|h| h.path != "bar.md")); - // …and discovery (its vectors are gone; the unlinked survivor still surfaces). let candidates = vault.similar("foo.md", 5).unwrap(); assert!(candidates.iter().any(|c| c.path == "baz.md")); assert!(candidates.iter().all(|c| c.path != "bar.md")); drop(vault); let conn = index_conn(&root); - // FTS stayed in lockstep through the cascade (no ghost text in the index)… assert_eq!(count(&conn, "chunks_fts"), count(&conn, "chunks")); - // …and foo's `[[bar]]` re-dangled: phase 2 re-resolved it against the pruned - // resolver, keeping the authored link visible for repair (#12), not dropped. + // foo's `[[bar]]` re-dangles, staying visible for repair (GH #12). let (dst_path, dst_path_raw): (Option, String) = conn .query_row( "SELECT dst_path, dst_path_raw FROM edges WHERE src_path = ?1", @@ -303,8 +271,7 @@ fn reindex_prunes_a_deleted_note_like_a_full_rebuild() { assert_eq!(dst_path_raw, "bar"); drop(conn); - // The invariant itself: the incrementally-reconciled index equals a from-scratch - // rebuild of the same final vault (the same files at the same paths). + // The incrementally reconciled index equals a rebuild of the same final vault. let fresh_root = tmp.path().join("fresh"); fs::create_dir_all(&fresh_root).unwrap(); write_note(&fresh_root, "foo.md", "Alpha body. See [[bar]]."); @@ -319,9 +286,7 @@ fn reindex_prunes_a_deleted_note_like_a_full_rebuild() { #[test] fn prune_spares_a_file_skipped_as_unreadable() { - // The #31 carve-out: a file the walk *saw* but could not read yields no - // projection this run — and "not projected" must not mean "deleted", since the - // file is plainly still on disk at its path. Its existing row stays. + // The GH #31 carve-out: a file still on disk but unreadable is not "deleted". let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); fs::create_dir_all(&root).unwrap(); @@ -331,7 +296,6 @@ fn prune_spares_a_file_skipped_as_unreadable() { let vault = Vault::open(&root).unwrap(); assert_eq!(vault.reindex().unwrap().indexed, 2); - // bar.md turns unreadable in place (a stray non-UTF-8 write) — still on disk. fs::write(root.join("bar.md"), [0xff, 0xfe, b'x']).unwrap(); let report = vault.reindex().unwrap(); @@ -345,10 +309,7 @@ fn prune_spares_a_file_skipped_as_unreadable() { #[test] fn single_note_ingest_never_prunes() { - // Pruning is a *whole-vault* reconciliation: only `project_vault` sees every file, - // so only it may decide a note is gone. The single-note paths (`project_file` / - // `ingest_file`, the add/mv/link/write substrate) touch their one note and must - // leave every other row alone — even a genuine ghost. + // Only `project_vault` sees every file, so only it may decide a note is gone. let tmp = tempfile::TempDir::new().unwrap(); let vault_dir = tmp.path().join("vault"); fs::create_dir_all(&vault_dir).unwrap(); @@ -361,7 +322,6 @@ fn single_note_ingest_never_prunes() { fs::remove_file(vault_dir.join("bar.md")).unwrap(); - // Neither single-note path evicts the now-ghost row… let cfg = ChunkConfig::default(); let proj = ProjectionCtx::new(&conn, &vault_dir, &cfg); project_file(proj, "foo.md").unwrap(); @@ -369,7 +329,6 @@ fn single_note_ingest_never_prunes() { ingest_file(EmbedCtx::new(proj, &embedder), "foo.md").unwrap(); assert_eq!(count(&conn, "notes"), 2, "ingest_file prunes nothing"); - // …only the whole-vault pass reconciles the deletion. let outcome = project_vault(ProjectionCtx::new(&conn, &vault_dir, &cfg), false).unwrap(); assert_eq!(outcome.notes_pruned, 1); assert_eq!(count(&conn, "notes"), 1); @@ -383,7 +342,6 @@ fn reindex_completes_and_reports_skipped_files() { fs::write(root.join("bad.md"), [0xff, 0xfe, b'x']).unwrap(); let vault = Vault::open(&root).unwrap(); - // The composed reindex succeeds (no abort) and reports the skip truthfully. let report = vault.reindex().unwrap(); assert_eq!(report.indexed, 2); assert_eq!(report.embedded, 2); @@ -391,7 +349,6 @@ fn reindex_completes_and_reports_skipped_files() { assert_eq!(report.skipped.len(), 1); assert_eq!(report.skipped[0].path, "bad.md"); - // …and the vault is fully usable: keyword search over the good notes still answers. assert!(!vault.search("forgetting", 5).unwrap().is_empty()); } @@ -403,7 +360,6 @@ fn projected_vault_answers_keyword_search_and_similar_degrades_empty() { let (vault, _) = opened_vault(tmp.path()); vault.project(false).unwrap(); - // Keyword search answers before any embedding — BM25-only, no model touched. let hits = vault.search("forgetting", 10).unwrap(); assert!( !hits.is_empty(), @@ -413,7 +369,7 @@ fn projected_vault_answers_keyword_search_and_similar_degrades_empty() { assert!(hits[0].snippet.contains("forgetting")); assert!(hits[0].score > 0.0); - // The graph resolves, and discovery degrades to empty — never an error. + // Discovery degrades to empty, never an error. assert!(!vault.neighbors("concepts/memory").unwrap().is_empty()); assert!( vault.similar("concepts/memory", 5).unwrap().is_empty(), @@ -421,17 +377,13 @@ fn projected_vault_answers_keyword_search_and_similar_degrades_empty() { ); } -/// The honest "N/M embedded" coverage read (#26): 0/M while projected-but-unembedded, -/// M/M once fully embedded, and a precise partial fraction when a projected note still -/// lacks vectors — the signal an adapter flags "keyword-only for now" from. Model-free. +/// The "N/M embedded" coverage read (GH #26) adapters flag "keyword-only for now" from. #[test] fn embed_status_reports_the_coverage_fraction() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); - // Projected but unembedded: every note counts toward the total, none is embedded, and - // the embedding space doesn't exist yet — reads as 0/M, no error (the query short- - // circuits before touching the absent `embeddings` table). + // No embedding space yet: 0/M, not an error. vault.project(false).unwrap(); let s = vault.embed_status().unwrap(); assert_eq!( @@ -440,14 +392,11 @@ fn embed_status_reports_the_coverage_fraction() { "projected-but-unembedded: 0/M" ); - // After a full embed: every note is embedded — M/M, semantic ranking complete. vault.embed(&mut |_| ControlFlow::Continue(())).unwrap(); let s = vault.embed_status().unwrap(); assert_eq!((s.embedded, s.total), (2, 2), "fully embedded: M/M"); - // A newly added note (projected, not yet embedded) makes coverage partial — the - // precise fraction #26 surfaces, distinct from the binary "is a model installed". - // The two unchanged notes keep their vectors (project never re-embeds them). + // A new projected note makes coverage partial. fs::write( root.join("fresh.md"), "---\n---\n\nA fresh unembedded note.\n", @@ -462,31 +411,16 @@ fn embed_status_reports_the_coverage_fraction() { ); } -/// A note with **no body** — frontmatter only, or an empty file — counts as embedded, and -/// a reindex forecasts no work for it. It has no chunks, so there is no vector it could -/// ever be waiting for: "embedded" is vacuously true, and the alternative is a note that -/// reads as forever-pending. -/// -/// **The bug this pins is a stuck fraction, not a rounding error.** The coverage predicate -/// required `>= 1 chunk`, so an empty note could never join the numerator. On a real vault -/// that is a permanent `1183/1188` — five `Untitled.md` and stub entity notes — and the -/// fraction is not decoration: every UI surface keyed on `embedded < total` read it as -/// unfinished work. The chat pane said grounding was keyword-first "while this vault -/// embeds", the graph pane withheld ghost connections pending an embed that would never -/// come, search wore a permanent "keyword-first" caveat, and `autoIndexOnOpen` plus the -/// fs-watch's `vectorsPending` scheduled a no-op embed pass on every open and every pulse. -/// A vault could not reach "done" by any amount of reindexing. -/// -/// So the assertions come in pairs: the *fraction* is what the user sees, and `would_embed` -/// is the work it kept commissioning. +/// A note with no body has no chunks, so it counts as embedded and forecasts no work. +/// Otherwise the fraction never reaches M/M, and every surface keyed on +/// `embedded < total` (caveats, auto-embed passes) stays stuck. #[test] fn a_note_with_no_body_counts_as_embedded_and_forecasts_no_work() { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_vault_copy(&root); - // The two shapes a real vault produces: a stub note that is all frontmatter (the - // entity/`Untitled.md` case), and a file with nothing in it at all. + // All frontmatter, and an empty file. fs::write(root.join("stub.md"), "---\ntags:\n - People\n---\n").unwrap(); fs::write(root.join("blank.md"), "").unwrap(); @@ -497,8 +431,7 @@ fn a_note_with_no_body_counts_as_embedded_and_forecasts_no_work() { "both empty notes are projected like any other" ); - // Non-vacuity: they really do contribute no chunks, so this is the chunkless case and - // not some other note quietly carrying the fraction. + // Non-vacuity: they really are chunkless. let conn = index_conn(&root); for path in ["stub.md", "blank.md"] { let chunks: i64 = conn @@ -519,7 +452,6 @@ fn a_note_with_no_body_counts_as_embedded_and_forecasts_no_work() { must be able to reach M/M, or every surface keyed on it is stuck" ); - // And the work the stuck fraction used to commission: none. let plan = vault.plan_reindex(false).unwrap(); assert_eq!( plan.would_embed, 0, diff --git a/crates/b2-core/tests/props.rs b/crates/b2-core/tests/props.rs index 32b26db..9952748 100644 --- a/crates/b2-core/tests/props.rs +++ b/crates/b2-core/tests/props.rs @@ -1,18 +1,9 @@ -//! #18 — property tests over **generated vaults**, pinning the load-bearing invariants the -//! golden-vault scenarios cover only pointwise: +//! Property tests over generated vaults (GH #18): lossless round-trip, `full reindex ≡ +//! incremental` after external mutations (ADR-0002), and rename keeping every backlink +//! resolving, with the repair in the Markdown (ADR-0003). //! -//! 1. **Round-trip is lossless** — `parse -> serialize` is byte-identical for *any* text, -//! and the surgical edit is an exact splice that touches nothing else. -//! 2. **`full reindex ≡ incremental`** — after any sequence of *external* vault mutations -//! through plain `fs`, the incrementally maintained index equals a from-scratch rebuild -//! (ADR-0002). -//! 3. **Rename keeps every backlink resolving** — and the repair lives in the *Markdown*, -//! not just the DB. Since identity is the path (ADR-0003) the property is that the -//! inbound set arrives intact at the destination. -//! -//! **Determinism (the suite's hard rule):** the runner uses a *fixed* ChaCha seed, so every -//! run explores the identical case sequence and a failure reproduces exactly. To explore new -//! ground change `SEED` locally; commit any find as a regular regression test. +//! The seed is fixed, so every run is identical. To explore, change `SEED` locally and +//! commit any find as a regular regression test. mod common; @@ -25,11 +16,10 @@ use rusqlite::Connection; use std::fs; use std::path::{Path, PathBuf}; -/// 32 bytes for ChaCha — the one knob that picks the (fixed) case sequence. +/// The ChaCha seed that fixes the case sequence. const SEED: &[u8; 32] = b"b2-props-deterministic-seed-01!!"; -/// Run `cases` deterministic cases of `strategy` through `test`, panicking with the -/// shrunken counterexample on failure (proptest's own report). +/// Run `cases` deterministic cases, panicking with proptest's shrunken counterexample. fn check( cases: u32, strategy: S, @@ -38,8 +28,7 @@ fn check( let mut runner = TestRunner::new_with_rng( Config { cases, - // Deterministic seed ⇒ nothing to persist; keeps the repo free of - // proptest-regressions files. + // A fixed seed has nothing to persist. failure_persistence: None, ..Config::default() }, @@ -61,8 +50,7 @@ fn any_text_round_trips_byte_identical() { }); } -/// `replace_body` is the byte-honest splice behind `Vault::write`: the result is -/// exactly (everything up to the old body) + the new body, for any inputs. +/// `replace_body`, the splice behind `Vault::write`, keeps every byte before the body. #[test] fn replace_body_splices_bytes_exactly() { check(512, (any::(), any::()), |(s, new_body)| { @@ -93,8 +81,7 @@ struct NoteSpec { title: Option, /// A frontmatter key B2 doesn't model — must survive everything verbatim. custom: Option, - /// Inject lines that make the frontmatter unparseable YAML: the note must - /// still index, and its raw bytes must survive every pass untouched. + /// Make the frontmatter unparseable YAML; the note must still index, bytes untouched. bad_yaml: bool, paras: Vec, /// Body wikilinks: (target note index (mod n), aliased?). @@ -207,8 +194,8 @@ fn write_file(path: &Path, bytes: &[u8]) { fs::write(path, bytes).unwrap(); } -/// Render one note's raw text. Self-links are skipped (the rename property is about -/// *inbound* repair; a self-link's semantics under `mv` is its own question). +/// Render one note's raw text, skipping self-links (the rename property is about inbound +/// repair). fn render_note( n: &NoteSpec, own_path: &str, @@ -223,9 +210,7 @@ fn render_note( fm.push(format!("x_custom: {c}")); } if n.bad_yaml { - // A tab-key line and an unclosed flow sequence — this frontmatter will - // never YAML-parse, but the note must behave (it indexes, its projected - // fields come back empty, and its bytes are never touched). + // A tab-key line and an unclosed flow sequence never YAML-parse. fm.push("\t: :".to_string()); fm.push("broken: [unclosed".to_string()); } @@ -288,10 +273,8 @@ fn render_note( // --- invariant 2: full reindex ≡ incremental ------------------------------------ -/// External mutations — everything through plain `fs`, never the façade's write -/// ops, because *this* is the path the incremental reindex has to reconcile (an -/// Obsidian edit, a `git pull`, a Finder rename). Indices are taken modulo the -/// live file lists at apply time; an op against an empty list is a no-op. +/// External mutations through plain `fs`, the path the incremental reindex must reconcile. +/// Indices are taken modulo the live file lists; an op on an empty list is a no-op. #[derive(Debug, Clone)] enum Mutation { EditBody { @@ -344,8 +327,7 @@ fn mutation() -> impl Strategy { ] } -/// The live vault-file bookkeeping mutations run against (paths only — content -/// lives on disk, where the invariant says it must). +/// The live vault's paths; content lives on disk. struct LiveVault { root: PathBuf, notes: Vec, @@ -423,16 +405,13 @@ fn apply(m: &Mutation, lv: &mut LiveVault) { fs::remove_file(lv.root.join(&lv.resources[ix])).unwrap(); lv.resources.remove(ix); } - _ => {} // an op against an emptied list — no-op + _ => {} } } -/// The index's *logical* content, one sorted line per row — everything the -/// projection derives, minus what may legitimately differ between two builds of -/// the same files: `indexed_at` (wall-clock, set DB-side), `mtime` (fs metadata, -/// not projection output), and `chunks.id` (an internal rowid; chunks are keyed -/// here by their identity `(note, seq)`). Vectors and centroids are included — -/// the FakeEmbedder is content-addressed, so they too must be pure projections. +/// The index's logical content, one line per row, minus what legitimately differs between +/// builds: `indexed_at`, `mtime` and `chunks.id`. Vectors and centroids are included; the +/// fake embedder is content-addressed. fn dump(root: &Path) -> Vec { let conn = common::index_conn(root); let sections: &[(&str, &str, usize)] = &[ @@ -479,9 +458,7 @@ fn dump(root: &Path) -> Vec { ]; let mut out = Vec::new(); for (section, sql, cols) in sections { - // The vector tables exist only once an embed pass has run (their existence - // is the "this vault has an embedding space" signal) — absent table, - // empty section. + // The vector tables exist only once an embed pass has run. let guard_table = match *section { "vec" => Some("embeddings"), "centroid" => Some("note_centroids"), @@ -534,8 +511,7 @@ fn incremental_reindex_equals_full_rebuild() { resources: spec.res_paths(), minted: 0, }; - // Reindex after EVERY mutation, so the incremental path is a *chain* of - // reconciles — the shape a live vault actually produces — not one lump. + // Reindex after every mutation: a chain of reconciles, as a live vault produces. for m in &muts { apply(m, &mut lv); vault.reindex().unwrap(); @@ -543,7 +519,6 @@ fn incremental_reindex_equals_full_rebuild() { let incremental = dump(&root); drop(vault); - // The index is disposable: drop it and rebuild from the same files. fs::remove_dir_all(root.join(".b2")).unwrap(); let fresh = Vault::open(&root).unwrap(); fresh.reindex().unwrap(); @@ -570,8 +545,7 @@ fn rename_keeps_every_backlink_resolving() { let paths = spec.note_paths(); let mover_ix = mover % paths.len(); - // Guarantee at least one inbound link, whatever the generator rolled: - // a neighboring note gains a body wikilink to the mover. + // Guarantee at least one inbound link. let other = &paths[(mover_ix + 1) % paths.len()]; let other_abs = root.join(other); let mut other_raw = fs::read_to_string(&other_abs).unwrap(); @@ -587,14 +561,11 @@ fn rename_keeps_every_backlink_resolving() { "the appended link guarantees an inbound edge" ); - // `moved-` + an alpha-only slug can never collide with the generated - // files (whose names always end in a `-` suffix). + // Generated names end in `-`, so this can't collide. let dest = in_dir(dest_dir, &format!("moved-{dest_slug}.md")); vault.move_note(&paths[mover_ix], &dest).unwrap(); - // The property, path-keyed (L1): every backlink arrives at the - // destination. `before` was read at the old path, so an equal set at the - // new one is exactly "no edge was lost, dropped, or re-pointed elsewhere". + // Every backlink arrives at the destination (L1). prop_assert_eq!( &inbound(&vault, &dest), &before, @@ -606,10 +577,8 @@ fn rename_keeps_every_backlink_resolving() { ); drop(vault); - // The repair must live in the Markdown, not just the DB: a fresh - // rebuild from the files alone sees the identical backlink set. This is - // the half that would fail if `move_note` had only re-keyed the index - // and left the inbound `[[oldpath]]` text pointing at nothing. + // The repair must live in the Markdown: a rebuild from the files alone sees the + // same backlinks. fs::remove_dir_all(root.join(".b2")).unwrap(); let fresh = Vault::open(&root).unwrap(); fresh.reindex().unwrap(); diff --git a/crates/b2-core/tests/read.rs b/crates/b2-core/tests/read.rs index 7dd3fa8..ef25ddc 100644 --- a/crates/b2-core/tests/read.rs +++ b/crates/b2-core/tests/read.rs @@ -1,9 +1,5 @@ -//! `Vault::read` — the one façade op the Desktop UI MVP adds -//! (crates/b2-desktop/CLAUDE.md). Its contract: resolve a note by its -//! vault-relative path — with or without the `.md`, the only two ref forms since -//! GH #170 — and return the note's raw Markdown body **from disk** (source of -//! truth, frontmatter stripped) plus the display metadata. A pure read, model-free -//! (FakeEmbedder), against the golden-vault fixture. +//! `Vault::read`: resolve a note by path, with or without the `.md` (GH #170), and return +//! its raw body from disk, frontmatter stripped, plus display metadata. mod common; @@ -17,16 +13,14 @@ fn read_returns_body_and_metadata_with_frontmatter_stripped() { let note = vault.read("concepts/memory.md").unwrap(); - // Identity + display metadata: the title is the filename (data-model.md §1 — the - // frontmatter `title:` is inert); type/created still come from the frontmatter. + // The title is the filename (data-model.md §1); type and created are frontmatter's. assert_eq!(note.path, MEMORY_PATH); assert_eq!(note.path, "concepts/memory.md"); assert_eq!(note.title.as_deref(), Some("memory")); assert_eq!(note.r#type.as_deref(), Some("concept")); assert_eq!(note.created.as_deref(), Some("2026-06-20")); - // the body is the Markdown *after* the frontmatter — the raw source, not a - // projection. It must not carry any frontmatter: neither the fence nor a key. + // The raw source, not a projection. assert!(note.body.contains("The brain encodes")); assert!( !note.body.contains("---"), @@ -46,14 +40,11 @@ fn read_returns_the_raw_frontmatter_block_verbatim() { let note = vault.read("concepts/memory.md").unwrap(); let fm = note.frontmatter.expect("golden note has frontmatter"); - // The verbatim YAML between the fences — the source keys, not a re-serialization. - // Byte-honest: the raw block still carries the (inert) `title:` key verbatim, - // while the note's display `title` is its filename (data-model.md §1). + // Verbatim, not re-serialized: the inert `title:` key is still there (data-model.md §1). assert!(fm.contains(r#"title: "Human memory""#)); assert_eq!(note.title.as_deref(), Some("memory")); assert!(fm.contains("type: concept")); assert!(!fm.contains("---"), "fences are excluded from the block"); - // …and it is genuinely separate from the body (frontmatter isn't duplicated there). assert!(!note.body.contains("title:")); } @@ -62,9 +53,7 @@ fn read_body_is_verbatim_markdown_including_wikilinks() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // The body is byte-honest Markdown: wikilinks survive verbatim so the adapter - // renders them (clickable wikilinks are the MVP's navigation). The typed - // relation is metadata — it shows in the frontmatter block, never the body. + // Wikilinks survive verbatim for the adapter; the typed relation stays in frontmatter. let note = vault.read("notes/spaced-repetition").unwrap(); assert!(note.body.contains("[[concepts/memory|Human memory]]")); assert!( @@ -79,8 +68,6 @@ fn read_body_is_verbatim_markdown_including_wikilinks() { #[test] fn read_resolves_a_path_and_its_stem_to_the_same_note() { - // Both accepted ref forms, and since GH #170 the only two: the full - // vault-relative path, and the extensionless stem the wikilink habit writes. let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); @@ -99,7 +86,7 @@ fn read_surfaces_tags_from_frontmatter() { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_vault_copy(&root); - // a note with tags + a body, to exercise the metadata that the golden notes lack. + // The golden notes have no tags. std::fs::write( root.join("tagged.md"), "---\ntype: note\ntitle: Tagged\ntags: [alpha, beta]\n---\nHello body.\n", @@ -110,7 +97,7 @@ fn read_surfaces_tags_from_frontmatter() { let note = vault.read("tagged").unwrap(); assert_eq!(note.tags, vec!["alpha".to_string(), "beta".to_string()]); - // Title is the filename (`tagged.md`), not the frontmatter `title: Tagged`. + // Title is the filename, not `title: Tagged`. assert_eq!(note.title.as_deref(), Some("tagged")); assert_eq!(note.body.trim(), "Hello body."); } diff --git a/crates/b2-core/tests/resources.rs b/crates/b2-core/tests/resources.rs index 6ba68b6..19e66bd 100644 --- a/crates/b2-core/tests/resources.rs +++ b/crates/b2-core/tests/resources.rs @@ -1,9 +1,5 @@ -//! Resources slice 1 — inventory & graph -//! (data-model.md §10). -//! -//! Step 0: the v4 schema — the `resources` table exists, `edges` carries the -//! resource-target columns (`dst_resource_path`, `embed`, `caption`), dangling -//! means *neither* target resolved, and the version gate drops a v3 index. +//! Resources: inventory and graph (data-model.md §10). The `resources` table, resource +//! edges (dangling means neither target resolved), the walk, and dispatch by shape. use b2_core::{open, Vault, SCHEMA_VERSION}; use rusqlite::Connection; @@ -12,8 +8,7 @@ use std::path::Path; mod common; -/// `(path, class, size, content_hash)` rows, path-ordered — the comparable -/// projection of `resources` (mtime/indexed_at are host state, not projection). +/// `(path, class, size, content_hash)` rows, path-ordered (mtime is host state). fn resource_rows(root: &Path) -> Vec<(String, String, i64, String)> { let conn = common::index_conn(root); let mut stmt = conn @@ -27,7 +22,7 @@ fn resource_rows(root: &Path) -> Vec<(String, String, i64, String)> { rows } -/// Column names of `table` via `pragma table_info`, for shape assertions. +/// Column names of `table`. fn columns(conn: &Connection, table: &str) -> Vec { let mut stmt = conn .prepare(&format!("SELECT name FROM pragma_table_info('{table}')")) @@ -65,7 +60,7 @@ fn v4_schema_has_resources_table_and_widened_edges() { assert!(edges.iter().any(|c| c == col), "edges.{col} missing"); } - // The class vocabulary is closed (research §3): a value outside it must refuse. + // The class vocabulary is closed (research §3). let bad = conn.execute( "INSERT INTO resources(path, class, size, content_hash, indexed_at) VALUES ('x.xyz', 'mystery', 0, 'h', 'now')", @@ -74,8 +69,7 @@ fn v4_schema_has_resources_table_and_widened_edges() { assert!(bad.is_err(), "an unknown class must violate the CHECK"); } -/// The schema-version gate: a v3 index is dropped wholesale and rebuilt at v4 — -/// no migration code, ever (the disposable-index tenet). +/// A v3 index is dropped and rebuilt, never migrated (the index is disposable). #[test] fn v3_index_is_dropped_and_rebuilt_at_v4() { let tmp = tempfile::TempDir::new().unwrap(); @@ -90,7 +84,6 @@ fn v3_index_is_dropped_and_rebuilt_at_v4() { VALUES ('img.png', 'image', 3, 'h', 'now');", ) .unwrap(); - // Simulate an index built by the previous schema. conn.execute( "UPDATE meta SET value = '3' WHERE key = 'schema_version'", [], @@ -116,9 +109,8 @@ fn v3_index_is_dropped_and_rebuilt_at_v4() { assert_eq!((notes, resources), (0, 0), "the gate must drop v3 rows"); } -/// A resource-targeted edge is FK-checked, deduped by the partial unique index, -/// and **re-dangles** (dst_resource_path → NULL, dst_path_raw retained) when its -/// target row is pruned — the ON DELETE SET NULL that keeps pruning one statement. +/// A resource edge is FK-checked, deduped by a partial unique index, and re-dangles when +/// its target is pruned (ON DELETE SET NULL keeps pruning one statement). #[test] fn resource_edges_are_fk_checked_and_redangle_on_prune() { let tmp = tempfile::TempDir::new().unwrap(); @@ -130,7 +122,6 @@ fn resource_edges_are_fk_checked_and_redangle_on_prune() { ) .unwrap(); - // FK: the target row must exist. let orphan = conn.execute( "INSERT INTO edges(id, src_path, dst_resource_path, dst_path_raw, type, origin) VALUES ('e0', 'a.md', 'missing.png', 'missing.png', @@ -148,8 +139,7 @@ fn resource_edges_are_fk_checked_and_redangle_on_prune() { ) .unwrap(); - // Dedup: same (src, resource, type, occurrence) must refuse — NULL dst_path makes - // the note-edge UNIQUE constraint inert here, hence the partial index. + // NULL dst_path makes the note-edge UNIQUE inert, hence the partial index. let dup = conn.execute( "INSERT INTO edges(id, src_path, dst_resource_path, dst_path_raw, type, origin) VALUES ('e2', 'a.md', 'img.png', 'img.png', @@ -161,7 +151,6 @@ fn resource_edges_are_fk_checked_and_redangle_on_prune() { "duplicate resource edge must violate the partial unique index" ); - // Prune the resource: the edge survives as dangling, raw text retained. conn.execute("DELETE FROM resources WHERE path = 'img.png'", []) .unwrap(); let (dst_resource, raw): (Option, String) = conn @@ -182,8 +171,7 @@ fn resource_edges_are_fk_checked_and_redangle_on_prune() { // Step 2 — the generalized walk: inventory, hashing, pruning (spec §2) // --------------------------------------------------------------------------- -/// The walk inventories every non-`.md` file, classified by extension, and skips -/// dot-prefixed files and folders (`.DS_Store` is not vault material). +/// The walk inventories every non-`.md` file by extension and skips dot-prefixed entries. #[test] fn walk_inventories_and_classifies_resources() { let tmp = tempfile::TempDir::new().unwrap(); @@ -213,8 +201,7 @@ fn walk_inventories_and_classifies_resources() { assert!(report.skipped.is_empty(), "a clean vault skips nothing"); } -/// An unchanged `(size, mtime)` short-circuits the byte read: the stored hash is -/// only recomputed when the stat changes (hashing is the pass's one byte-read). +/// An unchanged `(size, mtime)` skips the re-hash. #[test] fn unchanged_stat_short_circuits_the_rehash() { let tmp = tempfile::TempDir::new().unwrap(); @@ -226,8 +213,7 @@ fn unchanged_stat_short_circuits_the_rehash() { let before = resource_rows(tmp.path()); let original_mtime = fs::metadata(&txt).unwrap().modified().unwrap(); - // Same-length different bytes, mtime restored: the stat is identical, so the - // pass must not re-read — the stored hash stays (observably) stale. + // Same length, mtime restored: the stored hash stays observably stale. let stale_bytes = "PLAIN text resource for the inventory tests\n"; fs::write(&txt, stale_bytes).unwrap(); fs::File::options() @@ -243,10 +229,8 @@ fn unchanged_stat_short_circuits_the_rehash() { "matching (size, mtime) must not re-hash" ); - // A touched mtime re-reads and refreshes the hash. Bump it a clear +2s past the - // original: stored mtime is whole-second granularity (ingest.rs `as_secs`), so - // `now()` can land in the *same second* as `original_mtime` and (correctly) not - // re-hash — a flake. A fixed offset makes the stat change deterministically. + // +2s, not `now()`: stored mtime is whole seconds, so `now()` could land in the + // same second and flake. fs::File::options() .write(true) .open(&txt) @@ -272,7 +256,6 @@ fn unchanged_stat_short_circuits_the_rehash() { ); } -/// A deleted file's inventory row is pruned on the next projection pass. #[test] fn pruning_deletes_rows_the_walk_no_longer_sees() { let tmp = tempfile::TempDir::new().unwrap(); @@ -293,9 +276,7 @@ fn pruning_deletes_rows_the_walk_no_longer_sees() { ); } -/// `full-reindex ≡ incremental-update`, extended over resource add/change/delete -/// (spec §7): a vault mutated then incrementally re-projected matches a fresh -/// build of the same tree. +/// `full-reindex ≡ incremental-update` over resource add, change and delete (spec §7). #[test] fn incremental_resource_update_equals_full_rebuild() { let mutate = |root: &Path| { @@ -308,7 +289,6 @@ fn incremental_resource_update_equals_full_rebuild() { fs::remove_file(root.join("resources/blob.bin")).unwrap(); }; - // Incremental: project, mutate, project again. let a = tempfile::TempDir::new().unwrap(); common::golden_vault_copy(a.path()); let vault_a = Vault::open(a.path()).unwrap(); @@ -316,7 +296,6 @@ fn incremental_resource_update_equals_full_rebuild() { mutate(a.path()); vault_a.project(false).unwrap(); - // Fresh: the same final tree, projected once from scratch. let b = tempfile::TempDir::new().unwrap(); common::golden_vault_copy(b.path()); mutate(b.path()); @@ -334,8 +313,7 @@ fn incremental_resource_update_equals_full_rebuild() { // Step 4 — resolution: kind dispatch, dst_resource_path, dangling (spec §3) // --------------------------------------------------------------------------- -/// All edges out of one source path: `(dst_path, dst_resource_path, dst_path_raw, -/// type, embed, caption)`, in raw-target order. +/// `(dst_path, dst_resource_path, dst_path_raw, type, embed, caption)`. type EdgeTuple = ( Option, Option, @@ -370,9 +348,8 @@ fn edges_from(root: &Path, src_path: &str) -> Vec { rows } -/// Resource links resolve to `dst_resource_path` (never `dst_path`), capture the -/// authored caption + embed marker, and a missing target dangles with both -/// resolution columns NULL and the raw text retained. +/// Resource links resolve to `dst_resource_path`, keep caption and embed marker, and a +/// missing target dangles with the raw text retained. #[test] fn resource_links_resolve_capture_and_dangle() { let tmp = tempfile::TempDir::new().unwrap(); @@ -395,7 +372,7 @@ fn resource_links_resolve_capture_and_dangle() { assert_eq!( edges, vec![ - // note-relative Markdown targets resolve against resources/ + // Markdown targets are note-relative. ( None, Some("resources/data.txt".into()), @@ -412,7 +389,6 @@ fn resource_links_resolve_capture_and_dangle() { 1, Some("a tiny diagram".into()), ), - // a missing target dangles: both columns NULL, raw retained ( None, None, @@ -421,7 +397,7 @@ fn resource_links_resolve_capture_and_dangle() { 0, Some("gone".into()), ), - // wikilink embeds resolve vault-root + // Wikilink embeds are vault-root. ( None, Some("resources/blob.bin".into()), @@ -435,8 +411,7 @@ fn resource_links_resolve_capture_and_dangle() { ); } -/// Markdown-form links to notes are edges too (extension dispatch), and a -/// `#fragment` is stripped for the lookup while `dst_path_raw` keeps it. +/// A `#fragment` is stripped for the lookup; `dst_path_raw` keeps it. #[test] fn markdown_note_links_resolve_with_fragment_stripped() { let tmp = tempfile::TempDir::new().unwrap(); @@ -459,7 +434,6 @@ fn markdown_note_links_resolve_with_fragment_stripped() { assert_eq!(*dst_resource, None); assert_eq!(r#type, "references"); } - // occurrence disambiguates the two edges to the same (target, type) assert!(edges .iter() .any(|e| e.2 == "../concepts/memory.md#retrieval")); @@ -469,7 +443,6 @@ fn markdown_note_links_resolve_with_fragment_stripped() { // Step 5 — the façade: list_resources / explain_resource / move_resource (§4) // --------------------------------------------------------------------------- -/// `list_resources` mirrors the inventory, path-ordered, with class + stat. #[test] fn list_resources_returns_the_inventory() { let tmp = tempfile::TempDir::new().unwrap(); @@ -494,8 +467,7 @@ fn list_resources_returns_the_inventory() { assert!(listed.iter().all(|r| r.size > 0)); } -/// The fallback card: metadata + backlinks with authored context; an unknown -/// path errors ResourceNotFound. +/// The fallback card: metadata plus backlinks with authored context. #[test] fn explain_resource_carries_metadata_and_backlinks() { let tmp = tempfile::TempDir::new().unwrap(); @@ -515,7 +487,7 @@ fn explain_resource_carries_metadata_and_backlinks() { assert_eq!(view.backlinks.len(), 1); let b = &view.backlinks[0]; assert_eq!(b.path, "notes/card.md"); - // The backlink shows the linking note's title — its filename (data-model.md §1). + // Title is the filename (data-model.md §1). assert_eq!(b.title.as_deref(), Some("card")); assert_eq!(b.r#type, "references"); assert_eq!(b.caption.as_deref(), Some("a tiny diagram")); @@ -525,8 +497,7 @@ fn explain_resource_carries_metadata_and_backlinks() { assert!(matches!(missing, Err(b2_core::Error::ResourceNotFound(_)))); } -/// The viewer's read: inventoried bytes come back verbatim; anything the walk never -/// put in the inventory refuses, so the op cannot be steered off the vault. +/// Only inventoried paths are readable, so the op can't be steered off the vault. #[test] fn read_resource_bytes_returns_the_file_and_refuses_the_uninventoried() { let tmp = tempfile::TempDir::new().unwrap(); @@ -541,14 +512,13 @@ fn read_resource_bytes_returns_the_file_and_refuses_the_uninventoried() { "the viewer sees exactly the file on disk" ); - // A path the walk never met: no row, no read — whatever is on disk beside it. + // On disk but never walked: no row, no read. fs::write(tmp.path().join("resources/unwalked.png"), b"not indexed").unwrap(); assert!(matches!( vault.read_resource_bytes("resources/unwalked.png"), Err(b2_core::Error::ResourceNotFound(_)) )); - // A note is not resource inventory, and an escaping path names no row at all. assert!(matches!( vault.read_resource_bytes("notes/alpha.md"), Err(b2_core::Error::ResourceNotFound(_)) @@ -559,9 +529,7 @@ fn read_resource_bytes_returns_the_file_and_refuses_the_uninventoried() { )); } -/// A resource move rewrites inbound links in BOTH syntaxes, each keeping its own -/// convention (note-relative stays relative, vault-root stays root), moves the -/// file, and leaves the index equal to a fresh rebuild. +/// A resource move rewrites inbound links in both syntaxes, each keeping its convention. #[test] fn move_resource_rewrites_inbound_links_and_reprojects() { let tmp = tempfile::TempDir::new().unwrap(); @@ -584,7 +552,6 @@ fn move_resource_rewrites_inbound_links_and_reprojects() { assert_eq!(report.rewrote, vec!["notes/uses-diagram.md".to_string()]); assert_eq!(report.links_rewritten, 2); - // Each authored form kept its convention. let body = fs::read_to_string(tmp.path().join("notes/uses-diagram.md")).unwrap(); assert!( body.contains("![d](../img/diagram.png)"), @@ -595,7 +562,6 @@ fn move_resource_rewrites_inbound_links_and_reprojects() { "wikilink stays vault-root: {body}" ); - // File moved; inventory + edges follow. assert!(tmp.path().join("img/diagram.png").exists()); assert!(!tmp.path().join("resources/diagram.png").exists()); let edges = edges_from(tmp.path(), "notes/uses-diagram.md"); @@ -606,7 +572,6 @@ fn move_resource_rewrites_inbound_links_and_reprojects() { "all edges resolve at the new path: {edges:?}" ); - // Refuses to clobber, refuses unknown source. fs::write(tmp.path().join("img/other.png"), "x").unwrap(); vault.project(false).unwrap(); assert!(matches!( @@ -619,8 +584,7 @@ fn move_resource_rewrites_inbound_links_and_reprojects() { )); } -/// `similar` on a resource anchor is honest: "not yet" for an inventoried -/// resource (never a silent empty), the usual not-found for an unknown path. +/// `similar` on a resource says "not yet", never a silent empty. #[test] fn similar_on_a_resource_errs_not_yet() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-core/tests/rm_delete.rs b/crates/b2-core/tests/rm_delete.rs index 06aa23e..41cb786 100644 --- a/crates/b2-core/tests/rm_delete.rs +++ b/crates/b2-core/tests/rm_delete.rs @@ -1,9 +1,5 @@ -//! Delete a note / resource / folder — the destructive complement of `mv`: -//! remove the file(s) from disk and the projection rows from the index, leaving -//! inbound links **dangling** (never rewritten — the deleted target is simply -//! gone, exactly as if the file had been removed externally and the vault fully -//! reindexed). Driven through the [`Vault`] façade against the golden vault, -//! fully deterministic under the FakeEmbedder. +//! Delete a note, resource or folder: remove the files and their rows, leaving inbound +//! links dangling, never rewritten. Equivalent to an external delete plus a reindex. mod common; @@ -12,9 +8,7 @@ use common::{count, index_conn, reindexed_vault, MEMORY_PATH, SRS_PATH}; use rusqlite::Connection; use std::fs; -/// The vectors any read can actually reach: every one is joined to through -/// `chunks` (M4), so this — not `COUNT(*) FROM embeddings` — is the vector state a -/// caller can observe. +/// The vectors any read can reach, all via `chunks` (M4), unlike a raw `embeddings` count. fn reachable_vectors(conn: &Connection) -> i64 { conn.query_row( "SELECT COUNT(*) FROM chunks c JOIN embeddings e ON e.text_hash = c.text_hash", @@ -24,8 +18,7 @@ fn reachable_vectors(conn: &Connection) -> i64 { .unwrap() } -/// Every edge row's identity + resolution, ordered — the shape that must match a -/// from-scratch rebuild for `delete ≡ external-delete + reindex` to hold. +/// Every edge row's identity and resolution, ordered. fn edge_rows(conn: &Connection) -> Vec<(String, String, Option, String, String, i64)> { let mut stmt = conn .prepare( @@ -58,16 +51,11 @@ fn delete_note_removes_file_and_rows_and_dangles_inbound_links() { assert_eq!(report.path, MEMORY_PATH); assert_eq!(report.dangled, vec![SRS_PATH.to_string()]); - // The file is gone; the linking note's bytes are untouched (a delete never - // rewrites bodies — the links dangle, they aren't repaired). + // The linking note's bytes are untouched: links dangle, never repaired. assert!(!root.join(MEMORY_PATH).exists()); assert_eq!(fs::read_to_string(root.join(SRS_PATH)).unwrap(), srs_before); - // The note no longer resolves by *either* ref form. Both arms are the point: the - // pre-GH #170 version of this test paired the path with a b2id lookup to prove no - // handle survives a delete, and the path being the identity (L1) shrank the handles - // to two spellings of one key — so the stem is what that second arm becomes, not - // something the pivot made redundant. + // No handle survives, in either spelling of the path (L1). assert!(matches!( vault.read(MEMORY_PATH).unwrap_err(), Error::NoteNotFound(_) @@ -77,7 +65,6 @@ fn delete_note_removes_file_and_rows_and_dangles_inbound_links() { Error::NoteNotFound(_) )); - // Its projection rows are gone: only SRS's rows remain. let conn = index_conn(&root); assert_eq!(count(&conn, "notes"), 1); let chunk_owners: i64 = conn @@ -89,7 +76,6 @@ fn delete_note_removes_file_and_rows_and_dangles_inbound_links() { .unwrap(); assert_eq!(chunk_owners, 0); - // The linker's edges re-derived: no neighbor left, the links now dangle. let explain = vault.explain(SRS_PATH).unwrap(); assert!(explain.connections.is_empty(), "no resolved edges remain"); let targets: Vec<&str> = explain @@ -108,7 +94,6 @@ fn delete_note_removes_file_and_rows_and_dangles_inbound_links() { fn delete_note_equals_external_delete_plus_full_reindex() { let tmp = tempfile::TempDir::new().unwrap(); - // Vault A: the delete op. let (vault_a, root_a) = { let dir = tmp.path().join("a"); fs::create_dir_all(&dir).unwrap(); @@ -116,7 +101,7 @@ fn delete_note_equals_external_delete_plus_full_reindex() { }; vault_a.delete_note(MEMORY_PATH).unwrap(); - // Vault B: the file removed externally, then a full reindex reconciles. + // Vault B: an external delete, then a full reindex. let (vault_b, root_b) = { let dir = tmp.path().join("b"); fs::create_dir_all(&dir).unwrap(); @@ -130,17 +115,11 @@ fn delete_note_equals_external_delete_plus_full_reindex() { assert_eq!(edge_rows(&conn_a), edge_rows(&conn_b)); assert_eq!(count(&conn_a, "notes"), count(&conn_b, "notes")); assert_eq!(count(&conn_a, "chunks"), count(&conn_b, "chunks")); - // The vectors *reachable from a chunk* — which is every vector any read can see, - // since each one joins through `chunks` (M4). The raw `embeddings` row count is - // deliberately not compared: since GH #170 a vector outlives the chunk that - // addressed it, so the single-note delete path leaves the deleted note's vectors - // behind as collectible garbage while B's whole-vault pass has already swept - // them. Invisible either way, and warm if the same text returns. + // Not the raw `embeddings` count: since GH #170 a single-note delete leaves its + // vectors as unreachable garbage until a whole-vault pass sweeps them. assert_eq!(reachable_vectors(&conn_a), reachable_vectors(&conn_b)); - // And the delete is stable under a further reindex: nothing left to prune, and - // that pass is where A's garbage is collected — so the two indexes converge - // physically as well. + // A further reindex prunes nothing and collects A's garbage. let again = vault_a.reindex().unwrap(); assert_eq!(again.notes_pruned, 0); let conn_a = index_conn(&root_a); @@ -161,7 +140,6 @@ fn delete_note_unknown_ref_refuses() { vault.delete_note("no/such-note.md").unwrap_err(), Error::NoteNotFound(_) )); - // Nothing was touched. assert!(root.join(MEMORY_PATH).exists()); assert_eq!(count(&index_conn(&root), "notes"), 2); } @@ -171,7 +149,6 @@ fn delete_resource_removes_file_and_inventory_and_dangles_links() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // Give SRS a body link at the resource, through the ordinary save path. let note = vault.read(SRS_PATH).unwrap(); let body = format!( "{}\nSee [[resources/data.txt]] for the raw data.\n", @@ -192,7 +169,6 @@ fn delete_resource_removes_file_and_inventory_and_dangles_links() { let listed = vault.list_resources().unwrap(); assert!(listed.iter().all(|r| r.path != "resources/data.txt")); - // The link now dangles rather than resolving to a resource. let explain = vault.explain(SRS_PATH).unwrap(); assert!(explain.resources.is_empty()); assert!(explain @@ -200,7 +176,6 @@ fn delete_resource_removes_file_and_inventory_and_dangles_links() { .iter() .any(|u| u.target == "resources/data.txt")); - // Rebuild-equivalence: a further full reindex changes nothing. let conn = index_conn(&root); let before = edge_rows(&conn); let again = vault.reindex().unwrap(); @@ -235,12 +210,10 @@ fn delete_dir_removes_subtree_and_dangles_outside_links() { Error::NoteNotFound(_) )); - // The surviving linker dangles, exactly as a single-note delete leaves it. let explain = vault.explain(SRS_PATH).unwrap(); assert!(explain.connections.is_empty()); assert_eq!(explain.unresolved.len(), 2); - // Stable under a further reindex. let again = vault.reindex().unwrap(); assert_eq!(again.notes_pruned, 0); } @@ -250,8 +223,7 @@ fn delete_dir_containing_the_linker_leaves_the_target_intact() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); - // Deleting `notes/` removes SRS (the linker). Memory survives untouched, and - // no surviving file needs re-projection (the linker died with the folder). + // The linker dies with the folder, so nothing dangles. let report = vault.delete_dir("notes").unwrap(); assert_eq!(report.deleted_notes, 1); assert!(report.dangled.is_empty()); @@ -281,9 +253,7 @@ fn delete_dir_deletes_an_empty_folder() { let (vault, root) = reindexed_vault(tmp.path()); fs::create_dir_all(root.join("scratch")).unwrap(); - // An empty folder is a real vault member (fs-authoritative structure): the - // delete resolves against the filesystem, not the index, so it works exactly - // like a full folder — just with nothing indexed to count. + // Folder deletes resolve against the filesystem, not the index. let report = vault.delete_dir("scratch").unwrap(); assert_eq!(report.deleted_notes, 0); assert_eq!(report.deleted_resources, 0); @@ -308,6 +278,5 @@ fn delete_dir_missing_or_invalid_refuses() { vault.delete_dir("").unwrap_err(), Error::DirNotFound(_) )); - // Nothing was touched. assert_eq!(count(&index_conn(&root), "notes"), 2); } diff --git a/crates/b2-core/tests/roundtrip.rs b/crates/b2-core/tests/roundtrip.rs index 159a7ce..f0c2fde 100644 --- a/crates/b2-core/tests/roundtrip.rs +++ b/crates/b2-core/tests/roundtrip.rs @@ -1,10 +1,5 @@ -//! Lossless parse/serialize — `parse -> serialize -> parse` must be byte-identical, -//! preserving unknown frontmatter keys and order, comments, and whitespace. B2 achieves it -//! by keeping the raw text and only ever making the surgical edits it is asked to make. -//! -//! The *headingless* case is not pinned here: `props.rs` already proves it over 512 generated -//! strings, which are overwhelmingly frontmatter-free. What that property cannot reach — a -//! real, messy, human-authored block — is what the cases below hold. +//! Lossless parse and serialize: byte-identical, keeping unknown keys, order, comments and +//! whitespace. `props.rs` covers arbitrary text; these hold real, messy frontmatter. use b2_core::note::parse; use std::fs; @@ -55,8 +50,7 @@ fn extracts_queryable_fields_without_disturbing_raw() { let f = n.fields(); assert_eq!(f.r#type.as_deref(), Some("concept")); assert_eq!(f.created.as_deref(), Some("2026-06-20")); - // A relation is the logical value (the YAML quotes are a serialization detail - // kept in raw). + // The logical value; the YAML quotes stay in raw. assert_eq!( f.relations, vec!["supports [[concepts/memory|Human memory]] — applies the forgetting curve"] diff --git a/crates/b2-core/tests/search.rs b/crates/b2-core/tests/search.rs index 19b9f6c..56cf05d 100644 --- a/crates/b2-core/tests/search.rs +++ b/crates/b2-core/tests/search.rs @@ -1,9 +1,5 @@ -//! Hybrid retrieval (flow ②, index-engine.md §4): BM25 ⊕ vector → RRF fusion (k=60), -//! resolved to notes, and the evidence readings RRF discards. -//! -//! Scope: with the deterministic *fake* embedder, vector ranking is not semantic, -//! so these prove the **plumbing** (fusion math + resolution), not model quality — -//! that is the real-embedder eval suite (testability stack, point 5). +//! Hybrid retrieval (flow ②, index-engine.md §4): BM25 and vector fused by RRF (k=60), +//! resolved to notes. Under the fake embedder these prove plumbing, not model quality. mod common; @@ -26,42 +22,28 @@ fn rrf_uses_k_60() { assert_eq!(RRF_K, 60); } -/// The candidate depth a search actually reaches is the **composition** of two -/// widenings — the façade's per-view headroom and each signal's `pool_size` — and -/// `vault::{note,chunk}_candidate_pool` are the one place that states it, so a -/// measurement can ask "is this corpus bigger than the pool?" without re-deriving -/// the product (GH #141). +/// `vault::{note,chunk}_candidate_pool` state the composed depth a search reaches (view +/// headroom times `pool_size`), so a measurement need not re-derive it (GH #141). #[test] fn candidate_pool_states_the_per_signal_depth_a_search_reaches() { - // A `limit` of 10 (what the eval scores at) reaches 150 candidates per signal - // for the note view, not 50: it asks retrieval for 3 × 10 hits — dedup headroom - // — and each signal pulls 5 × that. + // 3× dedup headroom, then 5× per signal. assert_eq!(b2_core::vault::note_candidate_pool(10), 150); - // The floor still binds at tiny limits — one result still scans 30. + // The floor binds at tiny limits. assert_eq!(b2_core::vault::note_candidate_pool(1), 30); - // Monotone in `limit`: asking for more never narrows the pool. That is what - // makes "corpus smaller than the pool ⇒ pool-invariant" safe to conclude from - // a single K, as the eval's blindness warning does. + // Monotone in `limit`, which the eval's blindness warning relies on. assert!(b2_core::vault::note_candidate_pool(30) > b2_core::vault::note_candidate_pool(10)); assert!(b2_core::vault::chunk_candidate_pool(30) > b2_core::vault::chunk_candidate_pool(10)); } -/// The GH #142 ruling, pinned: the passage view's headroom exists for a torn read, a bounded -/// event, so it is a **constant** — while the note view's exists for dedup, which scales with -/// the ask, so it is a multiple. The two diverge as `limit` grows, and the passage view is -/// always narrower. -/// -/// A ranking commitment, not an arithmetic one: `pool_size`'s 5x turns each unit of headroom -/// into five candidates per signal, and RRF over a wider candidate set returns different -/// results (`2/121 > 1/61` at k = 60). Sharing `search`'s 3x here silently widened passage -/// retrieval from 60 to 150 candidates — a quality change no eval had priced. +/// GH #142: the passage view's headroom covers a torn read, so it is a constant; the note +/// view's covers dedup, so it is a multiple. This is a ranking commitment: a wider pool +/// changes RRF's results. #[test] fn the_passage_view_retrieves_a_narrower_pool_than_the_note_view() { - // The 10-result ask both adapters and the eval use. assert_eq!(b2_core::vault::chunk_candidate_pool(10), 60); assert_eq!(b2_core::vault::note_candidate_pool(10), 150); - // Constant vs multiple: the gap widens with the ask, and never closes or flips. + // The gap widens with the ask and never flips. for limit in [1usize, 2, 5, 10, 50, 500] { assert!( b2_core::vault::chunk_candidate_pool(limit) @@ -75,21 +57,15 @@ fn the_passage_view_retrieves_a_narrower_pool_than_the_note_view() { ); } -/// The blindness #141 names, stated as a property: once a corpus has **no more -/// chunks than the pool**, neither candidate list is truncated, so widening the pool -/// cannot add a candidate and the fused ranking cannot depend on how wide it was — a -/// shallow ask returns a prefix of a deep one, exactly. This is why the 29-chunk eval -/// corpus cannot see a candidate-width change, and why the rank-stability probe -/// (`b2-embed/examples/stability.rs`) runs on a vault big enough for the pool to bind. -/// -/// Scoped to *width*: `RRF_K` re-weights the same lists, so it reorders even here. +/// The blindness GH #141 names: a corpus no bigger than the pool is never truncated, so a +/// shallow ask is a prefix of a deep one. Hence the stability probe needs a bigger vault. +/// Scoped to width: `RRF_K` still reorders here. #[test] fn a_corpus_no_bigger_than_the_pool_ranks_the_same_at_any_depth() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, vault_dir) = reindexed_vault(tmp.path()); - // The premise is about the *corpus*, not the result count, so it is counted on - // the chunk rows themselves — 2 results would sit inside any pool regardless. + // The premise is about the corpus, so count chunk rows, not results. let chunks = count(&index_conn(&vault_dir), "chunks"); assert!( chunks <= b2_core::vault::chunk_candidate_pool(2) as i64, @@ -109,21 +85,16 @@ fn a_corpus_no_bigger_than_the_pool_ranks_the_same_at_any_depth() { ); } -/// `limit` is user input (`b2 search --limit`), and the two widenings compose, so -/// the pool arithmetic must not overflow: before this was saturating, a large -/// `--limit` panicked a debug build outright and would have *wrapped* a release one -/// into a tiny pool — silently wrong results from an absurd-but-harmless ask. +/// `limit` is user input, so the pool arithmetic saturates rather than wrapping a large +/// ask into a tiny pool. #[test] fn an_absurd_limit_saturates_the_pool_instead_of_overflowing_it() { - // Both views saturate: the note view's `× 3` and the passage view's `+ 2` are - // each one overflow away from wrapping a `usize::MAX` ask into a tiny pool. assert_eq!(b2_core::vault::note_candidate_pool(usize::MAX), usize::MAX); assert_eq!(b2_core::vault::chunk_candidate_pool(usize::MAX), usize::MAX); let tmp = tempfile::TempDir::new().unwrap(); let (vault, vault_dir) = reindexed_vault(tmp.path()); - // The whole corpus, once, rather than a panic or a truncated-by-wraparound page. let hits = vault.search("memory", usize::MAX).unwrap(); assert!(!hits.is_empty()); assert!(!vault @@ -135,35 +106,24 @@ fn an_absurd_limit_saturates_the_pool_instead_of_overflowing_it() { #[test] fn rrf_ranks_a_doc_present_in_both_lists_above_single_list_winners() { - // 20 is rank-1 in BM25 and rank-0 in vector → appearing in both lifts it - // above 10, which is rank-0 in BM25 but only rank-2 in vector. This is the - // "hybrid beats either alone" property, at the fusion-math level. + // 20 is in both lists, so it beats 10 (BM25's top, but vector rank 2). let bm25 = vec![10, 20, 30]; let vector = vec![20, 40, 10]; let fused = search::rrf_fuse(&[bm25, vector], RRF_K); assert_eq!(fused[0].0, 20, "doc in both lists wins"); - // every id present, fused score positive and descending assert_eq!(fused.len(), 4); for w in fused.windows(2) { assert!(w[0].1 >= w[1].1, "scores must be descending"); } } -/// RRF over integer ranks lands on a discrete lattice of reachable sums, so exact -/// score ties are structural, not freak events — the eval corpus produced one on -/// its second run (`photosynthesis.md` vs `houseplant-care.md`, bit-identical f64, -/// GH #156). The old secondary key was ascending chunk id -/// — projection walk order, which is semantically arbitrary. The policy now: a -/// photo finish is broken by the candidate's rank in the **last** list handed to -/// `rrf_fuse` — the dense/vector list in hybrid search — because on the tie the -/// eval decomposed, the semantic signal named the labelled answer and BM25 named -/// the wrong one. Id remains only as the final determinism key. +/// RRF over integer ranks makes exact ties structural (GH #156). A tie breaks by rank in +/// the last list given to `rrf_fuse` (the dense list), which named the right answer on +/// the eval's tie; id is only the final determinism key. #[test] fn rrf_breaks_symmetric_ties_by_the_dense_lists_rank() { - // ids 1 and 2 tie exactly: ranks (bm25 0, vector 1) vs (bm25 1, vector 0) sum - // to the same score. The dense list prefers 2, so 2 must win — under the old - // id tie-break, 1 won by being the smaller id. + // 1 and 2 tie exactly; the dense list prefers 2. let bm25 = vec![1, 2]; let vector = vec![2, 1]; let fused = search::rrf_fuse(&[bm25, vector], RRF_K); @@ -176,9 +136,7 @@ fn rrf_breaks_symmetric_ties_by_the_dense_lists_rank() { #[test] fn rrf_breaks_cross_signal_ties_toward_the_dense_list() { - // 7 appears only in BM25 (rank 0), 9 only in the vector list (rank 0): equal - // single-term sums. The candidate the dense signal saw at all outranks the one - // it never surfaced. + // 7 only in BM25, 9 only in the vector list, both rank 0: an exact tie. let fused = search::rrf_fuse(&[vec![7], vec![9]], RRF_K); assert_eq!( fused[0].1, fused[1].1, @@ -194,7 +152,6 @@ fn keyword_search_finds_chunks_by_term() { let ids = search::keyword_search(&conn, "forgetting", 10).unwrap(); assert!(!ids.is_empty()); - // 'forgetting' lives only in spaced-repetition's body. let note: String = conn .query_row( "SELECT note_path FROM chunks WHERE id = ?1", @@ -207,9 +164,7 @@ fn keyword_search_finds_chunks_by_term() { #[test] fn keyword_search_tolerates_natural_language_punctuation() { - // Real semantic search invites NL queries: apostrophes, quotes, punctuation are - // FTS5 *syntax* and would raise a parse error if passed raw (the bug the eval - // surfaced). They must be sanitized to a safe MATCH, still matching real terms. + // Punctuation is FTS5 syntax and would raise a parse error if passed raw. let tmp = tempfile::TempDir::new().unwrap(); let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); @@ -222,7 +177,7 @@ fn keyword_search_tolerates_natural_language_punctuation() { assert!(!ids.is_empty(), "query {q:?} should still find the term"); } - // A query with no usable terms is empty, not an error (vector half still runs). + // No usable terms is empty, not an error (the vector half still runs). assert!(search::keyword_search(&conn, "!!! ??? ...", 10) .unwrap() .is_empty()); @@ -247,14 +202,12 @@ fn hybrid_search_combines_signals_and_resolves_to_notes() { .unwrap() .hits; assert!(!hits.is_empty()); - // every hit resolves to a real note, and SRS (the only keyword match) is present + // SRS is the only keyword match. assert!(hits.iter().all(|h| !h.note_path.is_empty())); assert!(note_set(&hits).contains(SRS_PATH)); } -/// The dense half alone (GH #158): the ablation instrument the eval scores beside -/// bm25-only and hybrid. Plumbing only here (fake vectors are not semantic): it -/// ranks by vector distance, resolves to notes, and honors `limit` and zero-limit. +/// The dense half alone (GH #158), the eval's ablation beside bm25-only and hybrid. #[test] fn vector_only_search_is_the_dense_half_alone() { let tmp = tempfile::TempDir::new().unwrap(); @@ -264,7 +217,7 @@ fn vector_only_search_is_the_dense_half_alone() { search::vector_only_search(&conn, &FakeEmbedder::new(64), "forgetting curve", 5).unwrap(); assert!(!hits.is_empty()); assert!(hits.iter().all(|h| !h.note_path.is_empty())); - // Negated-distance scores, best first — discovery's convention. + // Negated-distance scores, best first (discovery's convention). for w in hits.windows(2) { assert!(w[0].score >= w[1].score, "scores must be descending"); } @@ -277,22 +230,17 @@ fn vector_only_search_is_the_dense_half_alone() { ); } -/// The façade view dedups to notes exactly as `search` does, and a -/// projected-but-unembedded vault returns nothing — where `search` honestly falls -/// back to keywords, an ablation that quietly did the same would measure the -/// wrong signal. +/// Dedups like `search`, but an unembedded vault returns nothing: an ablation that fell +/// back to keywords would measure the wrong signal. #[test] fn search_vector_only_dedups_and_refuses_to_impersonate_keywords() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _) = opened_vault(tmp.path()); - // Projection only: no embedding space yet. The hybrid view falls back to - // BM25; the ablation view must return nothing rather than do the same. vault.project(false).unwrap(); assert!(!vault.search("memory", 5).unwrap().is_empty()); assert!(vault.search_vector_only("memory", 5).unwrap().is_empty()); - // Embedded: hits flow, one per note. vault .embed(&mut |_| std::ops::ControlFlow::Continue(())) .unwrap(); @@ -309,16 +257,14 @@ fn search_vector_only_dedups_and_refuses_to_impersonate_keywords() { } } -/// A result's `snippet` must **window around the matched term**, not just show the -/// chunk's head. Under qmd chunking (#19) a chunk is section-sized — far longer than -/// the snippet budget — so a term buried past the head would otherwise never appear -/// in what the human reads, and every hit would look identical. +/// A snippet windows around the matched term: section-sized chunks (GH #19) are far +/// longer than the snippet budget. #[test] fn a_long_chunks_snippet_windows_around_the_matched_term() { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); fs::create_dir_all(&root).unwrap(); - // ~470 characters of lead-in, then the term — well past the snippet head. + // ~470 characters of lead-in, past the snippet head. let lead = "Filler prose that exists only to push the matched term out of the head. ".repeat(7); fs::write( root.join("long.md"), @@ -346,11 +292,10 @@ fn a_long_chunks_snippet_windows_around_the_matched_term() { "a windowed snippet opens with an ellipsis: {:?}", hit.snippet ); - // Bounded: the 160-char budget plus at most a leading and trailing ellipsis. + // The 160-char budget plus two ellipses. assert!(hit.snippet.chars().count() <= 162, "{:?}", hit.snippet); - // A term already inside the head needs no window — the snippet is the head, so - // it opens with the text itself rather than an ellipsis. + // A term inside the head needs no window. let head_hit = vault .search("Filler", 5) .unwrap() @@ -370,10 +315,8 @@ fn a_long_chunks_snippet_windows_around_the_matched_term() { #[test] fn search_chunks_exposes_passage_level_hits() { - // The sub-note view (`Vault::search_chunks`) the retrieval eval scores passage - // ranks through (the eval harness, crates/b2-embed/evals/): same retrieval as `search`, no note - // dedup, each hit resolved to its note path + heading breadcrumb + the chunk's - // FULL text — containment-scorable, unlike `SearchResult`'s display snippet. + // The passage view the eval scores: no note dedup, and the chunk's full text + // (containment-scorable, unlike a display snippet). let tmp = tempfile::TempDir::new().unwrap(); let (vault, _) = reindexed_vault(tmp.path()); @@ -382,8 +325,6 @@ fn search_chunks_exposes_passage_level_hits() { assert!(hits .iter() .all(|h| !h.path.is_empty() && !h.text.is_empty())); - // 'forgetting' lives only in spaced-repetition; its hit must carry the full - // chunk text (the term itself), not a trimmed snippet. let srs = hits .iter() .find(|h| h.path == SRS_PATH) @@ -392,22 +333,14 @@ fn search_chunks_exposes_passage_level_hits() { assert!(srs.text.contains("forgetting")); } -/// GH #137: a ranked chunk that no longer resolves is **skipped over**, not charged against -/// `limit`. The torn read is legitimate — C1 promises readers are never refused while a -/// writer rebuilds — so a `b2 search` racing a `b2 reindex &` can see a chunk id whose row is -/// already gone, and charging it a result slot would silently under-fill the answer. -/// -/// The fixture stages exactly that window: an FTS row with no `chunks` row behind it, which -/// is what a mid-flight `replace_chunks` produces. Its short, term-dense text ranks it first -/// under BM25, and the test asserts that placement rather than assuming it, so a tokenizer -/// change fails loudly instead of quietly making the case untested. +/// GH #137: a ranked chunk that no longer resolves (a torn read C1 allows during a +/// reindex) is skipped, not charged against `limit`. The fixture is an FTS row with no +/// `chunks` row, and the test asserts it ranks first rather than assuming so. #[test] fn a_ranked_chunk_that_no_longer_resolves_costs_no_hit_slot() { const DEAD_CHUNK: i64 = 999_999; - // A purpose-built vault rather than the golden one: this needs several *keyword* - // matches so a `limit` of 2 has something below it to backfill from, and the - // BM25-only path is what makes the ranking depend on nothing but the text. + // Several keyword matches, so a `limit` of 2 has something to backfill from. let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -441,9 +374,7 @@ fn a_ranked_chunk_that_no_longer_resolves_costs_no_hit_slot() { "the dead chunk must land inside the limit window for this to test anything" ); - // The same live chunks come back, in the same order, still `limit`-many. Their - // RRF *scores* do shift down a notch — the dead chunk really did occupy rank 0 - // of the BM25 list, which is the whole point — so identity is what's compared. + // Compare identity: the dead chunk's rank shifts the live chunks' RRF scores. let after = search::keyword_only_search(&conn, "capybara", 2) .unwrap() .hits; @@ -454,9 +385,7 @@ fn a_ranked_chunk_that_no_longer_resolves_costs_no_hit_slot() { ); } -/// The façade's chunk view retrieves a pool wider than `limit` for the same reason -/// (GH #137): it drops a hit whose path/detail lookup misses, and that drop must -/// come out of the headroom rather than out of the caller's result count. +/// The façade's chunk view drops a dead hit from its headroom, not from `limit` (GH #137). #[test] fn search_chunks_still_fills_limit_when_a_ranked_chunk_is_dead() { const DEAD_CHUNK: i64 = 999_999; @@ -482,8 +411,7 @@ fn search_chunks_still_fills_limit_when_a_ranked_chunk_is_dead() { ); } -/// `limit == 0` asks for nothing and must get nothing — the loops stop *before* -/// pushing, so a zero budget can never be stepped past into "return everything". +/// A zero budget must never be stepped past into "return everything". #[test] fn a_zero_limit_returns_no_hits() { let tmp = tempfile::TempDir::new().unwrap(); @@ -501,12 +429,8 @@ fn a_zero_limit_returns_no_hits() { ); } -/// …and it gets there without *doing* anything. The observable proof is the -/// model-mismatch guard: a vault indexed at one dimension and reopened at another -/// fails every real search fast (`Error::ModelMismatch`, so incomparable vectors -/// never rank), because retrieval embeds the query. A zero-limit search returns -/// cleanly instead — it never reached retrieval, which is also what spares the real -/// model a forward pass for an empty answer. +/// A zero-limit search never reaches retrieval. Observed through the model-mismatch +/// guard: every real search on a mismatched vault fails, but a zero-limit one returns. #[test] fn a_zero_limit_search_does_no_retrieval_work() { let tmp = tempfile::TempDir::new().unwrap(); @@ -546,9 +470,7 @@ fn a_zero_limit_search_does_no_retrieval_work() { // Query evidence — the absolute signals RRF discards (invariants.md D2, GH #201) // --------------------------------------------------------------------------- -/// Document frequency is read over the same sanitized terms the `MATCH` -/// expression searches for, and a word the vault has never seen reads `df == 0` -/// — the honest zero the fused surface could not say. +/// Document frequency over the sanitized `MATCH` terms; an unseen word reads `df == 0`. #[test] fn lexical_evidence_reads_document_frequency_per_term() { let tmp = tempfile::TempDir::new().unwrap(); @@ -561,8 +483,7 @@ fn lexical_evidence_reads_document_frequency_per_term() { assert_eq!(df("shjfasd"), Some(0), "the vault has never seen it"); } -/// A repeated term is one piece of evidence, not two — otherwise a query could -/// talk its own coverage up by saying the same word twice. +/// Otherwise a query could raise its coverage by repeating a word. #[test] fn lexical_evidence_counts_a_repeated_term_once() { let tmp = tempfile::TempDir::new().unwrap(); @@ -572,8 +493,7 @@ fn lexical_evidence_counts_a_repeated_term_once() { assert_eq!(ev.terms.len(), 1); } -/// Absent words carry the **most** weight, which is what makes a query of pure -/// nonsense read as coverage zero rather than as having no content at all. +/// Absent words weigh most, so pure nonsense reads as coverage zero, not as no reading. #[test] fn absent_words_weigh_most_and_drive_coverage_to_zero() { let ev = search::LexicalEvidence { @@ -597,20 +517,14 @@ fn absent_words_weigh_most_and_drive_coverage_to_zero() { ev.idf(0) > ev.idf(95), "a word the vault lacks outweighs one it repeats" ); - // Only "the" is present, and it weighs almost nothing beside the two it is - // measured against. + // Only "the" is present, and it weighs almost nothing. assert!(ev.term_coverage().is_some_and(|c| c < 0.02)); assert!(!ev.anchored(0.20)); } -/// The off-topic query's shape, and why weighting is what catches it: the vault shares only -/// a **function word** with it, which carries almost none of the query's weight. This is the -/// labelled negative "why parrots mimic speech", reading about 0.08 on the eval corpus. -/// -/// Note what the rule does *not* claim: three equally rare words, one present, is about a -/// third of the query's weight and **is** an anchor at the shipped bar. That is deliberate — -/// a vault holding one of a query's rare words has something to say, and ADR-0015's tripwire -/// is cutting a real query, not serving a thin one. +/// The eval's off-topic negative ("why parrots mimic speech"): the vault shares only a +/// function word, which carries almost no weight. One of three rare words present does +/// anchor at the shipped bar; ADR-0015's tripwire is cutting a real query. #[test] fn a_shared_function_word_is_not_a_lexical_anchor() { let ev = search::LexicalEvidence { @@ -637,7 +551,6 @@ fn a_shared_function_word_is_not_a_lexical_anchor() { assert!(ev.term_coverage().is_some_and(|c| c < 0.15)); assert!(!ev.anchored(0.20)); - // The same vault, asked about what it actually holds: every term present. let held = search::LexicalEvidence { chunk_total: 70, terms: vec![ @@ -655,11 +568,8 @@ fn a_shared_function_word_is_not_a_lexical_anchor() { assert!(held.anchored(0.20)); } -/// The rule survives a **single-domain** vault, where a subject word is in most -/// chunks — the geometry that broke the hard-ceiling rule this one replaced (GH -/// #201's transfer check; GH #196's finding on the lexical axis). A word in 7 of -/// 15 chunks is common, but it is not a stopword, and weighting is what tells -/// the difference. +/// A single-domain vault, where a subject word is in most chunks, still anchors: common +/// is not a stopword (GH #201, GH #196). #[test] fn a_saturated_subject_word_still_anchors() { let ev = search::LexicalEvidence { @@ -679,9 +589,7 @@ fn a_saturated_subject_word_still_anchors() { assert!(ev.anchored(0.20)); } -/// An all-stopword query has **no reading**, not a coverage of zero: nothing -/// content-bearing was found or missed, so the lexical half abstains and the -/// cosine half decides alone. +/// An all-stopword query has no reading, not zero coverage: the cosine half decides alone. #[test] fn an_all_stopword_query_has_no_coverage_reading() { let ev = search::LexicalEvidence { @@ -695,8 +603,7 @@ fn an_all_stopword_query_has_no_coverage_reading() { assert!(!ev.anchored(0.0), "abstention is never an anchor"); } -/// D2's verdict is lexical **OR** semantic: either signal alone vouches, and -/// only their joint absence answers "no matches". +/// D2's verdict is lexical OR semantic: only their joint absence answers "no matches". #[test] fn the_verdict_takes_either_signal() { let bar = search::EvidenceBar { @@ -710,25 +617,22 @@ fn the_verdict_takes_either_signal() { df, }], }; - // Lexical alone, with the dense half far away. let anchored = search::QueryEvidence { lexical: lexical(4), best_cos: Some(0.20), }; assert!(anchored.vouched(bar)); - // Semantic alone, with nothing lexical to stand on. let near = search::QueryEvidence { lexical: lexical(0), best_cos: Some(0.80), }; assert!(near.vouched(bar)); - // Neither — the reported defect, answered. let nothing = search::QueryEvidence { lexical: lexical(0), best_cos: Some(0.44), }; assert!(!nothing.vouched(bar)); - // A projected-but-unembedded vault has no dense half to appeal to. + // Unembedded: no dense half to appeal to. let unembedded = search::QueryEvidence { lexical: lexical(0), best_cos: None, @@ -736,9 +640,8 @@ fn the_verdict_takes_either_signal() { assert!(!unembedded.vouched(bar)); } -/// The bar is keyed to the model (M2) and **absent** for anything uncalibrated — -/// no verdict rather than a borrowed one. The device suffix is stripped, so a -/// Metal build answers to the same reading (GH #40). +/// The bar is keyed to the model (M2) and absent when uncalibrated. The device suffix is +/// stripped, so a Metal build shares it (GH #40). #[test] fn the_bar_is_per_model_and_device_suffixes_share_it() { assert!(search::EvidenceBar::for_model("BAAI/bge-base-en-v1.5").is_some()); @@ -750,9 +653,7 @@ fn the_bar_is_per_model_and_device_suffixes_share_it() { assert!(search::EvidenceBar::for_model("some/other-model").is_none()); } -/// Provenance rides beside the fused order without touching it: the same hits in -/// the same order, each now naming the lists that ranked it. A hit BM25 never saw -/// carries `bm25_rank: None` — the per-hit shape of D2's defect. +/// Provenance names the lists that ranked each hit without changing the fused order. #[test] fn fusion_carries_each_hit_s_provenance() { let tmp = tempfile::TempDir::new().unwrap(); @@ -766,14 +667,12 @@ fn fusion_carries_each_hit_s_provenance() { hit.provenance.bm25_rank.is_some() || hit.provenance.vector_rank.is_some(), "a fused hit came from at least one list" ); - // The dense half scans every stored vector, so every chunk has a distance - // exactly when it has a dense rank. + // The dense half scans every vector, so distance and dense rank go together. assert_eq!( hit.provenance.distance.is_some(), hit.provenance.vector_rank.is_some() ); } - // The order is the fused order, unchanged by carrying the provenance. assert_eq!( retrieval .hits @@ -789,8 +688,6 @@ fn fusion_carries_each_hit_s_provenance() { ); } -/// The keyword-only fallback was already honest about zero, and says so: no dense -/// half means no `best_cos` to overrule the lexical half's silence. #[test] fn the_keyword_only_fallback_reports_no_dense_evidence() { let tmp = tempfile::TempDir::new().unwrap(); @@ -804,9 +701,7 @@ fn the_keyword_only_fallback_reports_no_dense_evidence() { .all(|h| h.provenance.vector_rank.is_none())); } -/// The façade's evidence read serves the same rows `search` does, in the same -/// order — a verdict is something a surface acts on, never something that -/// quietly reorders or removes a result (D1: reachability is untouchable). +/// A verdict never reorders or removes a result (D1). #[test] fn search_evidence_serves_exactly_what_search_serves() { let tmp = tempfile::TempDir::new().unwrap(); @@ -821,16 +716,14 @@ fn search_evidence_serves_exactly_what_search_serves() { .collect::>(), plain.iter().map(|r| r.path.clone()).collect::>(), ); - // A fake-embedded space has no calibrated bar, so no verdict is offered. + // The fake embedder has no calibrated bar. assert_eq!(view.vouched, None); assert!(view.chunk_total > 0); assert!(view.terms.iter().any(|t| t.term == "memory")); } -/// `limit` caps the rows and nothing else: a zero-limit evidence read still gets -/// the vault's full reading, because the question is about the query rather than -/// about how many results were wanted. (Contrast `search`, which returns before -/// embedding at all — there, nothing is being asked.) +/// `limit` caps rows only: the evidence is about the query. (Contrast `search`, which +/// returns before embedding.) #[test] fn a_zero_limit_evidence_read_still_reads_the_evidence() { let tmp = tempfile::TempDir::new().unwrap(); @@ -845,17 +738,13 @@ fn a_zero_limit_evidence_read_still_reads_the_evidence() { assert!(view.terms.iter().any(|t| t.term == "memory")); } -/// The follow-up-search subtraction: excluding an already-served note drops it and -/// its slot backfills from the same ranking, the remaining rows keeping their -/// relative order — a re-query in an agent loop surfaces the *next* notes, never -/// the same head again. +/// An excluded note's slot backfills from the same ranking, order untouched, so an agent's +/// re-query surfaces the next notes. #[test] fn excluding_a_served_note_backfills_from_the_same_ranking() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _) = reindexed_vault(tmp.path()); - // Both golden notes carry "memory", so the full read serves two rows and there - // is a next-ranked note for the exclusion to backfill with. let full = vault.search_evidence("memory", 5).unwrap(); let served: Vec = full.results.iter().map(|r| r.result.path.clone()).collect(); assert!( @@ -882,9 +771,8 @@ fn excluding_a_served_note_backfills_from_the_same_ranking() { ); } -/// `--exclude` subtracts rows, never evidence: the verdict and its signals are facts -/// about the query and the vault (ADR-0015), identical whatever the caller has -/// already read — and a path the vault never held excludes nothing at all. +/// `--exclude` subtracts rows, never evidence: the verdict is about the query and the +/// vault (ADR-0015). #[test] fn exclusion_subtracts_rows_never_the_evidence() { let tmp = tempfile::TempDir::new().unwrap(); @@ -897,8 +785,7 @@ fn exclusion_subtracts_rows_never_the_evidence() { assert_eq!(excluded.vouched, full.vouched); assert_eq!(excluded.chunk_total, full.chunk_total); assert_eq!(excluded.terms, full.terms); - // Even when the excluded note holds the vault's nearest chunk, the dense half's - // absolute claim is unchanged — nearest is a fact about the vault. + // Even if the excluded note holds the nearest chunk. assert_eq!(excluded.best_cos, full.best_cos); let unknown = vault @@ -907,10 +794,8 @@ fn exclusion_subtracts_rows_never_the_evidence() { assert_eq!(unknown, full, "an unknown path excludes nothing"); } -/// Terms are deduped by the identity **FTS5** uses, not by spelling (PR #205 -/// review). `chunks_fts` is tokenized, so `Memory` / `memory` / `memories` are -/// one token and match the same chunks; counting them separately would let a -/// query weigh one piece of evidence three times. +/// Terms dedup by FTS5 token, not spelling (PR #205): `Memory`, `memory` and `memories` +/// match the same chunks. #[test] fn repeated_terms_are_deduped_by_fts_token_not_spelling() { let tmp = tempfile::TempDir::new().unwrap(); @@ -923,24 +808,16 @@ fn repeated_terms_are_deduped_by_fts_token_not_spelling() { "one token, so one term: {:?}", ev.terms.iter().map(|t| &t.term).collect::>() ); - // The *first spelling* survives, not the index-internal stem ("memori"), - // which no reader typed. + // The first spelling survives, not the stem ("memori"). assert_eq!(ev.terms[0].term, "Memory"); - // Words the tokenizer keeps apart stay apart — including two it has never - // seen, which share a `df` of 0 but are not the same evidence. + // Two unseen words share `df == 0` but are not the same evidence. let distinct = search::lexical_evidence(&conn, "vrelqip zonktar memory").unwrap(); assert_eq!(distinct.terms.len(), 3); } -/// The dedup is not cosmetic: spelling-based dedup double-counts a present term -/// against an absent one, and at the shipped bar that flips the verdict. -/// -/// The arithmetic, at `chunk_total = 100` with a term in 44 chunks: two copies -/// of it against one absent word read `2·0.808 / (2·0.808 + 4.615) = 0.259`, -/// one copy reads `0.808 / (0.808 + 4.615) = 0.149` — across the shipped 0.20 -/// coverage bar. Asserted on the arithmetic rather than a fixture so the case -/// stays legible when the corpus moves. +/// Double-counting a present term flips the verdict at the shipped bar: 0.259 with two +/// copies vs 0.149 with one, across 0.20. #[test] fn double_counting_a_present_term_would_cross_the_shipped_bar() { let bar = search::EvidenceBar::for_model("BAAI/bge-base-en-v1.5").unwrap(); diff --git a/crates/b2-core/tests/substrate.rs b/crates/b2-core/tests/substrate.rs index bb2dc1d..415fe41 100644 --- a/crates/b2-core/tests/substrate.rs +++ b/crates/b2-core/tests/substrate.rs @@ -1,26 +1,13 @@ -//! DB skeleton and the substrate bet — green-scenario assertions for the index's step 0: -//! - FTS5 is compiled in (the `bundled` SQLite). Vectors need no substrate proof since -//! they became plain BLOB tables scored in-process (ADR-0006). -//! - open->reopen is stable; `WAL` + `foreign_keys=ON` hold; the #38 scan pragmas are -//! applied; `schema_version` seeded. -//! - the **first** open of a vault's index survives contention (#111): the one pragma -//! `busy_timeout` cannot cover is retried until it lands. -//! - `open` is concurrency-safe *through* its migration (#114, invariant C1): a -//! stale-schema rebuild is atomic and serialized, an incomplete schema is rebuilt rather -//! than trusted, and a reader is never refused. (C1's other drop-and-rebuild, the vector -//! tables, is covered in `embed.rs`.) +//! The index's SQLite substrate: FTS5 in the bundled build, the locked pragmas, a first +//! open that survives contention (GH #111), and a migration that is concurrency-safe and +//! never refuses a reader (GH #114, C1; the vector tables' side is in `embed.rs`). use b2_core::{open, SCHEMA_VERSION}; use std::sync::{mpsc, Arc, Barrier}; use std::time::Duration; -/// The tables `db::migrate` must leave behind — restated here because an integration -/// test sees only the public API, never the engine's own `SCHEMA_TABLES`. -/// -/// The engine's list is pinned to the DDL by a unit test in `db.rs`; this copy is not, -/// and doesn't need to be. A table added there and forgotten here only makes the -/// assertions in *this* file weaker, never wrong — the completeness check that actually -/// guards the index reads the pinned list, not this one. +/// The tables `db::migrate` must leave behind, restated because integration tests can't +/// see the engine's list. A table forgotten here only weakens this file's assertions. const SCHEMA_TABLES: [&str; 6] = [ "meta", "notes", @@ -45,14 +32,12 @@ fn assert_schema_complete(db_path: &std::path::Path, context: &str) { } } -/// The load-bearing bet: BM25 full-text search in the statically-linked bundled -/// SQLite, no runtime `load_extension`. +/// BM25 full-text search in the bundled SQLite, no runtime `load_extension`. #[test] fn fts5_works_in_the_bundled_connection() { let tmp = tempfile::TempDir::new().unwrap(); let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); - // FTS5 present, BM25 ranking works. conn.execute_batch( "CREATE VIRTUAL TABLE docs_fts USING fts5(text); INSERT INTO docs_fts(rowid, text) VALUES (1, 'spaced repetition and human memory'); @@ -69,8 +54,6 @@ fn fts5_works_in_the_bundled_connection() { assert_eq!(hit, 1, "BM25 should rank the memory note first"); } -/// The locked pragmas and the `meta` bookkeeping survive a close/reopen, and the -/// `schema_version` gate is seeded exactly once (idempotent migration). #[test] fn pragmas_and_schema_version_persist_across_reopen() { let tmp = tempfile::TempDir::new().unwrap(); @@ -86,8 +69,7 @@ fn pragmas_and_schema_version_persist_across_reopen() { .query_row("PRAGMA foreign_keys", [], |r| r.get(0)) .unwrap(); assert_eq!(foreign_keys, 1, "foreign_keys must be ON"); - // The #38 read-path pragmas: whole-space vector scans must stream through - // the OS page cache (mmap), not a pread-per-page under the 2 MB default. + // GH #38: vector scans stream through mmap, not pread-per-page. let mmap_size: i64 = conn .query_row("PRAGMA mmap_size", [], |r| r.get(0)) .unwrap(); @@ -96,9 +78,8 @@ fn pragmas_and_schema_version_persist_across_reopen() { .query_row("PRAGMA cache_size", [], |r| r.get(0)) .unwrap(); assert_eq!(cache_size, -32768, "cache_size must be raised (KiB units)"); - } // connection dropped → file closed + } - // Reopen: schema_version is stable and not duplicated. let conn = open(&db_path).unwrap(); let version: String = conn .query_row( @@ -119,24 +100,15 @@ fn pragmas_and_schema_version_persist_across_reopen() { assert_eq!(rows, 1, "migration must be idempotent across reopen"); } -/// The **first** open of a vault's index is the one that can lose a lock race, and it must -/// wait it out rather than fail (ADR-0021). -/// -/// Made deterministic by holding the contended lock outright rather than racing for it. -/// Unfixed, `open` gives up ~200 µs in, long before the holder lets go. -/// -/// **`IMMEDIATE`, not `EXCLUSIVE`** — the distinction is the whole test. `IMMEDIATE` holds -/// `RESERVED`: readers still get in, writers don't, which is precisely the lock the WAL flip -/// trips over. Swap in `EXCLUSIVE` and the flip blocks on the *read* half instead, which the -/// busy handler does cover, so it waits happily with or without the fix. -/// busy handler does cover, so it waits happily with or without the fix. +/// The first open of an index can lose a lock race and must wait it out (ADR-0021). Made +/// deterministic by holding the lock outright. `IMMEDIATE`, not `EXCLUSIVE`: `EXCLUSIVE` +/// blocks the read half, which the busy handler covers, so it would pass without the fix. #[test] fn first_open_waits_out_a_held_lock() { let tmp = tempfile::TempDir::new().unwrap(); let db_path = tmp.path().join("b2.sqlite"); - // A real (rollback-journal) database with its write lock held — what a racing - // first `open` finds when another process got to the mode flip first. + // What a racing first `open` finds when another process reached the mode flip first. let holder = rusqlite::Connection::open(&db_path).unwrap(); holder .execute_batch( @@ -144,8 +116,7 @@ fn first_open_waits_out_a_held_lock() { ) .unwrap(); - // Released only once `open` is under way, so the wait window can't be spent on - // thread startup and let the test pass vacuously. + // Released only once `open` is under way, so the test can't pass vacuously. let (started_tx, started_rx) = mpsc::channel(); let releaser = std::thread::spawn(move || { started_rx.recv().unwrap(); @@ -157,35 +128,22 @@ fn first_open_waits_out_a_held_lock() { let conn = open(&db_path).expect("a contended first open must wait, not fail"); releaser.join().unwrap(); - // And the wait bought the real thing: the mode flip applied, not skipped. + // The mode flip applied, not skipped. let journal_mode: String = conn .query_row("PRAGMA journal_mode", [], |r| r.get(0)) .unwrap(); assert_eq!(journal_mode.to_lowercase(), "wal", "WAL must be engaged"); } -/// Eight openers reaching a brand-new index at once all come back with a connection — the -/// user-visible shape of #111. -/// -/// **This is a coexistence smoke test, not the #111 gate.** Say so plainly, because the name -/// would otherwise promise a regression it does not catch: the flip's race window is ~200 µs -/// wide, so eight barrier-released threads land inside it only sometimes — measured at **3 -/// failures in 25 runs** against the unfixed `open`, i.e. green ~88% of the time on the very -/// bug it appears to name. [`first_open_waits_out_a_held_lock`] is the gate. What this test -/// *does* buy is the property no deterministic single-lock test can state: that N concurrent -/// openers finish at all — no deadlock, no starved thread, no error escaping the retry. -/// -/// Threads rather than processes because the contention is SQLite's: locking is -/// per-*connection*, so same-process openers race the flip exactly as separate invocations -/// do. The barrier is what gives it any chance of biting. +/// A coexistence smoke test, not the GH #111 gate (it caught the unfixed bug only 3 times in +/// 25; [`first_open_waits_out_a_held_lock`] is the gate). It proves N concurrent openers all +/// finish. Threads suffice: SQLite locks per connection. #[test] fn concurrent_openers_of_a_fresh_index_coexist() { let tmp = tempfile::TempDir::new().unwrap(); let db_path = tmp.path().join("b2.sqlite"); - // One binding for both the barrier's count and the thread count: if those two ever - // drift, the barrier simply never releases and the test *hangs* — a CI timeout with - // no failing assertion to read, which is a far worse way to learn about a typo. + // One binding for both counts: if they drift, the barrier never releases and CI hangs. const OPENERS: usize = 8; let start = Arc::new(Barrier::new(OPENERS)); let openers: Vec<_> = (0..OPENERS) @@ -207,9 +165,8 @@ fn concurrent_openers_of_a_fresh_index_coexist() { } } -/// Build an index at the *previous* schema version, so every opener of it takes the -/// drop-and-rebuild branch of the migration — the state a vault is in on the first `b2` -/// run after an upgrade that bumps `SCHEMA_VERSION`. +/// An index at the previous schema version, as after an upgrade: every opener takes the +/// drop-and-rebuild branch. fn stale_index(db_path: &std::path::Path) { let conn = open(db_path).unwrap(); conn.execute( @@ -219,20 +176,9 @@ fn stale_index(db_path: &std::path::Path) { .unwrap(); } -/// Eight openers reaching a **stale-schema** index at once leave one complete schema behind, -/// and none of them fails (ADR-0021, invariant C1). -/// -/// **Both assertions are the test, and the second is the one that matters.** Failing opens -/// are the loud half. The quiet half is an index left missing tables while *every* opener -/// returned `Ok`, surfacing later as a broken `search`, arbitrarily far from the cause. -/// Measured against the unfixed engine at 20 rounds x 8 openers: 2–12 failed opens per run -/// and up to 8 missing-table observations, caught in **7 of 8** runs. -/// -/// So: a strong probe, not a certainty — the same caveat its sibling above carries. The -/// deterministic gates are the two tests below; this is the one that reproduces the bug as -/// reported. Rounds cap at 20 because detection flattens out past that while the wall-clock -/// does not. -/// does not. +/// Concurrent openers of a stale index leave one complete schema and none fails (ADR-0021, +/// C1). The quiet failure is missing tables behind every `Ok`. A strong probe (7 of 8 runs +/// caught the unfixed bug), not a certainty; the deterministic gates are below. #[test] fn concurrent_opens_of_a_stale_index_leave_a_complete_schema() { const ROUNDS: usize = 20; @@ -261,21 +207,13 @@ fn concurrent_opens_of_a_stale_index_leave_a_complete_schema() { .unwrap_or_else(|e| panic!("round {round}: a concurrent open failed: {e}")); } - // Every opener returned Ok, so each believes the index is migrated. Is it? assert_schema_complete(&db_path, &format!("round {round}")); } } -/// An index whose stamp says "current" but whose tables say otherwise is **rebuilt**, not -/// trusted (#114, invariant C1) — the wreckage the bug leaves in the field, written by a `b2` -/// old enough to have raced itself. The fix makes new ones impossible; this is the other -/// half, since existing ones have to heal. -/// -/// **Dropping the surviving rows is the assertion with teeth**, and the reason the repair is -/// a full rebuild rather than a patch: recreating just the missing tables would leave `notes` -/// rows claiming to be indexed while their chunks are gone, and an incremental reindex skips -/// a note whose `body_hash` still matches — so the recreated tables would stay empty forever, -/// quietly breaking S3. +/// An index stamped current but missing tables is rebuilt, not trusted (GH #114, C1). The +/// rebuild must drop surviving `notes` rows: an incremental reindex skips a matching +/// `body_hash`, so patched tables would stay empty forever (S3). #[test] fn an_index_stamped_current_but_missing_a_table_is_rebuilt() { let tmp = tempfile::TempDir::new().unwrap(); @@ -283,14 +221,13 @@ fn an_index_stamped_current_but_missing_a_table_is_rebuilt() { { let conn = open(&db_path).unwrap(); - // A note the index believes is projected — the row that must not outlive the repair. + // A row that must not outlive the repair. conn.execute( "INSERT INTO notes(path, body_hash, indexed_at) VALUES ('kept.md', 'hash', '2026-07-26T00:00:00Z')", [], ) .unwrap(); - // What a lost race left behind: the chunk tables gone, the stamp still current. conn.execute_batch("DROP TABLE chunks_fts; DROP TABLE chunks;") .unwrap(); } @@ -308,14 +245,8 @@ fn an_index_stamped_current_but_missing_a_table_is_rebuilt() { ); } -/// An index whose tables are all present but whose **stamp is gone** is rebuilt from empty -/// too — the other way `schema_is_current` can say no (#114). -/// -/// The sibling above loses a table and keeps the stamp; this one is the shape a guard on "was -/// there a prior stamp?" waves through: nothing to compare, so nothing dropped, and the -/// `CREATE … IF NOT EXISTS` batch settles over surviving tables of an unknown shape and -/// re-stamps them current. Whatever wrote that state, its rows are exactly the ones an -/// incremental reindex would decline to refresh (S3), so the rebuild is unconditional. +/// An index with all tables but no stamp is rebuilt from empty too (GH #114): tables of +/// unknown shape must not be re-stamped current (S3). #[test] fn an_index_with_its_schema_stamp_missing_is_rebuilt() { let tmp = tempfile::TempDir::new().unwrap(); @@ -345,18 +276,8 @@ fn an_index_with_its_schema_stamp_missing_is_rebuilt() { ); } -/// Opening an index at the current schema is a **read**, so a writer holding the index -/// cannot refuse it (#114, invariant C1). -/// -/// The migration used to re-run its ~30 `IF NOT EXISTS` statements and re-stamp `meta` on -/// *every* open — a write, on the one path that must never need one. Against a held write -/// lock that stamp waited out the full 5 s `busy_timeout` and then failed the open outright: -/// `b2 search` refused, in a vault whose only sin was having a reindex running. -/// -/// The writer here is a reindex mid-batch as a second process sees it. Its lock is proven -/// held rather than assumed — a `busy_timeout = 0` probe must bounce off it — because a -/// `BEGIN IMMEDIATE` that quietly took nothing would make this test pass against the very -/// code it exists to catch. +/// Opening a current index is a read, so a writer can't refuse it (GH #114, C1). The +/// writer's lock is proven held by a probe, or the test would pass vacuously. #[test] fn an_open_of_a_current_index_is_not_refused_by_a_writer() { let tmp = tempfile::TempDir::new().unwrap(); @@ -366,7 +287,6 @@ fn an_open_of_a_current_index_is_not_refused_by_a_writer() { let writer = rusqlite::Connection::open(&db_path).unwrap(); writer.execute_batch("BEGIN IMMEDIATE").unwrap(); - // Non-vacuity: the write lock is genuinely held right now. let probe = rusqlite::Connection::open(&db_path).unwrap(); probe.execute_batch("PRAGMA busy_timeout = 0").unwrap(); assert!( @@ -376,7 +296,6 @@ fn an_open_of_a_current_index_is_not_refused_by_a_writer() { let conn = open(&db_path).expect("a reader must never be refused by a writer (C1)"); - // And it is a working connection over the real schema, not a hollow one. let notes: i64 = conn .query_row("SELECT count(*) FROM notes", [], |r| r.get(0)) .unwrap(); @@ -385,23 +304,9 @@ fn an_open_of_a_current_index_is_not_refused_by_a_writer() { writer.execute_batch("ROLLBACK").unwrap(); } -/// An index stamped **newer** than this binary understands is refused, and its rows are -/// left untouched — the clobber the drop-and-rebuild branch used to perform on a vault -/// whose desktop app had already moved on. -/// -/// The migration's one question was "is the stamp equal to mine?", so a *newer* stamp took -/// exactly the same branch as a *stale* one: drop everything and rebuild empty. An older -/// `b2` left on `PATH` — a stale `cargo install`, an unupgraded shell — then destroyed a -/// complete, current index on a **read-only** command, and the only cost visible to the -/// user was a fresh embedding run over the whole vault. -/// -/// Refusing is the only honest answer: the schema is disposable but it is not *ours* to -/// dispose of, and this binary cannot read a shape it predates. Direction is what the -/// stamp buys — a stamp is written only inside `apply_schema`'s transaction alongside the -/// tables it vouches for, so a stamp above ours means a newer `b2` committed a complete -/// index here. The structural check deliberately does not gate this: a future schema is -/// free to rename or drop any table in *our* list, so "our tables are missing" is not -/// evidence of damage in an index we already know we cannot read. +/// An index stamped newer than this binary is refused and left untouched, so an old `b2` on +/// `PATH` can't destroy it. The structural check doesn't gate this: a future schema may drop +/// any table in our list. #[test] fn an_index_from_a_newer_b2_is_refused_not_rebuilt() { let tmp = tempfile::TempDir::new().unwrap(); @@ -432,7 +337,7 @@ fn an_index_from_a_newer_b2_is_refused_not_rebuilt() { "the refusal must name both versions so the adapters can say which b2 to run: {err}" ); - // The assertion with teeth: the refusal cost the index nothing. + // The refusal cost the index nothing. let conn = rusqlite::Connection::open(&db_path).unwrap(); let surviving: i64 = conn .query_row("SELECT count(*) FROM notes", [], |r| r.get(0)) diff --git a/crates/b2-core/tests/vault.rs b/crates/b2-core/tests/vault.rs index 64fdfbb..ff04cbf 100644 --- a/crates/b2-core/tests/vault.rs +++ b/crates/b2-core/tests/vault.rs @@ -1,9 +1,5 @@ -//! The `Vault` façade — the one typed core API the CLI and tests are clients of -//! (invariants.md). This slice's contract: -//! `open` / `reindex` / `neighbors` / `search`, resolving a note by its -//! vault-relative path — with or without the `.md` — against the golden-vault -//! fixture. Fully deterministic (FakeEmbedder), so it proves the plumbing, not -//! model quality. +//! The `Vault` façade's core reads: `open`, `reindex`, `neighbors` and `search`, resolving +//! a note by its path with or without the `.md`. mod common; @@ -38,13 +34,12 @@ fn reindex_reports_counts_and_is_idempotent() { assert_eq!(report.indexed, 2, "golden vault has two notes"); assert_eq!(report.embedded, 2, "both are fresh to the index"); - // a second reindex still indexes both, and embeds neither: the bodies are - // unchanged, so their chunks hash to vectors already stored (M4). + // Unchanged chunks hash to vectors already stored (M4). let again = vault.reindex().unwrap(); assert_eq!(again.indexed, 2); assert_eq!(again.embedded, 0); - // W1, asserted rather than assumed: indexing a vault twice writes nothing to it. + // W1: indexing writes nothing to the vault. for (path, was) in ["concepts/memory.md", "notes/spaced-repetition.md"] .iter() .zip(&before) @@ -67,15 +62,13 @@ fn neighbors_of_memory_are_inbound_resolved_to_paths_and_titles() { labels.sort_unstable(); assert_eq!(labels, vec!["referenced-by", "supported-by"]); - // every neighbor is the SRS note, inbound, resolved to its path + title (the - // filename, data-model.md §1). + // Title is the filename (data-model.md §1). assert!(ns.iter().all(|n| n.path == SRS_PATH)); assert!(ns.iter().all(|n| n.direction == "inbound")); assert!(ns.iter().all(|n| n.path == "notes/spaced-repetition.md")); assert!(ns .iter() .all(|n| n.title.as_deref() == Some("spaced-repetition"))); - // the typed `supports` edge carries its explanation through. assert!(ns.iter().any(|n| n.relation == "supports" && n.explanation.as_deref() == Some("applies the forgetting curve"))); } @@ -85,14 +78,13 @@ fn neighbors_of_srs_are_outbound_and_ref_forms_agree() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // Both ref forms — by path and by path-without-.md — resolve to the same set. let by_path = vault.neighbors("notes/spaced-repetition.md").unwrap(); let by_stem = vault.neighbors("notes/spaced-repetition").unwrap(); for ns in [&by_path, &by_stem] { let mut labels: Vec<&str> = ns.iter().map(|n| n.label.as_str()).collect(); labels.sort_unstable(); - // outbound labels are the verbs themselves. + // Outbound labels are the verbs themselves. assert_eq!(labels, vec!["references", "supports"]); assert!(ns.iter().all(|n| n.path == MEMORY_PATH)); assert!(ns.iter().all(|n| n.direction == "outbound")); @@ -102,9 +94,8 @@ fn neighbors_of_srs_are_outbound_and_ref_forms_agree() { assert_eq!(by_path.len(), by_stem.len()); } -/// Every façade op that resolves a note ref rejects an unknown one the same way, -/// echoing the ref back verbatim — the single refusal the adapters map to their -/// "not found" message. Asserted once here rather than per-op across files. +/// Every resolving op refuses an unknown ref the same way, echoing it back: the one +/// refusal adapters map to "not found". #[test] fn unknown_ref_is_note_not_found_on_every_resolving_op() { let tmp = tempfile::TempDir::new().unwrap(); @@ -133,8 +124,7 @@ fn search_finds_the_note_with_a_snippet_and_is_note_level() { let hits = vault.search("forgetting", 10).unwrap(); assert!(!hits.is_empty()); - // 'forgetting' lives only in spaced-repetition — it must surface, resolved to - // its note with a non-empty snippet showing the matched term. + // 'forgetting' lives only in spaced-repetition. let srs = hits .iter() .find(|h| h.path == SRS_PATH) @@ -144,7 +134,6 @@ fn search_finds_the_note_with_a_snippet_and_is_note_level() { assert!(srs.snippet.contains("forgetting")); assert!(srs.score > 0.0); - // results are note-level: no note appears twice. let mut ids: Vec<&str> = hits.iter().map(|h| h.path.as_str()).collect(); ids.sort_unstable(); let deduped = { @@ -155,9 +144,7 @@ fn search_finds_the_note_with_a_snippet_and_is_note_level() { assert_eq!(ids, deduped, "search results must be deduped by note"); } -/// Index-first honesty: before the first reindex the projection is empty, so the -/// read surfaces answer *empty*, never an error — the adapters render "nothing -/// indexed yet", not a failure. +/// Before the first reindex, reads answer empty, never an error. #[test] fn reads_before_reindex_are_empty_not_errors() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-core/tests/vector_pool_scale.rs b/crates/b2-core/tests/vector_pool_scale.rs index 4b5be9f..71c14ef 100644 --- a/crates/b2-core/tests/vector_pool_scale.rs +++ b/crates/b2-core/tests/vector_pool_scale.rs @@ -1,18 +1,6 @@ -//! Whole-space retrieval stays **exhaustive**: every path that scans the vector space -//! returns complete, un-truncated results, with no silent cap. -//! -//! *History.* This file was born to lock the fix for `sqlite-vec`'s `vec0` `MATCH … LIMIT k` -//! ceiling, by building a vault of > 4096 chunks. That store is gone (ADR-0006): vectors are -//! scanned in-process, and the callers either take **every** row or bound it with a plain -//! `Vec::truncate` — structurally incapable of the old ceiling. The literal `4096` is now a -//! retired dependency's magic number no live line knows about, and reaching 4096 *real* -//! chunks under the qmd chunker costs the fast suite ~20s (GH #46) to reprove a boundary that -//! cannot recur without reintroducing `sqlite-vec`. -//! -//! So this asserts the property that actually matters — **exhaustiveness / no silent -//! truncation** — directly on the cap-bearing primitive, and proves discovery and hybrid -//! search return complete results on a modest, fast vault. Scope: the -//! deterministic fake embedder, so this proves plumbing, not model quality. +//! Whole-space retrieval stays exhaustive: every path that scans the vector space returns +//! complete results, with no silent cap. Asserted on the cap-bearing primitive and on a +//! modest vault rather than a 4096-chunk one (ADR-0006, GH #46). mod common; @@ -24,14 +12,9 @@ use rusqlite::Connection; use std::fs; use std::path::Path; -/// Build a vault of `notes` unlinked notes, each body `paras` blank-line-separated -/// paragraphs, and ingest it. Returns the connection and the notes' paths in creation order. -/// -/// **Chunking is size-targeted, not one-per-paragraph:** the qmd chunker coalesces small -/// paragraphs toward its ~450-token target, so these ~50-char paragraphs pack ~30 to a chunk -/// and a 90-paragraph note projects to ~3. Callers needing a known chunk total read it back -/// with [`chunk_count`] rather than assuming `notes x paras`. No links, so every note is a -/// discovery candidate for every other. +/// Ingest `notes` unlinked notes of `paras` paragraphs each; returns the connection and +/// paths. Chunking is size-targeted (~30 paragraphs a chunk), so read totals back with +/// [`chunk_count`]. fn big_vault(dir: &Path, notes: usize, paras: usize) -> (Connection, Vec) { let vault = dir.join("vault"); fs::create_dir_all(&vault).unwrap(); @@ -58,14 +41,11 @@ fn chunk_count(conn: &Connection) -> i64 { .unwrap() } -/// The retired `vec0` store errored on `k > 4096`; the in-process scan cannot. Proven -/// **directly on the primitive that would carry any such cap**, cheaply — with no -/// oversized fixture and no magic number: `vector_search(k)` returns exactly `min(k, N)` -/// for `k` below, at, and far past `N` (where the old store crashed). +/// `vector_search(k)` returns exactly `min(k, N)` for `k` below, at and far past `N`. #[test] fn vector_search_is_exhaustive_and_truncates_only_to_k() { let tmp = tempfile::TempDir::new().unwrap(); - let (conn, _) = big_vault(tmp.path(), 20, 90); // a real fake-embedded space, ~60 chunks + let (conn, _) = big_vault(tmp.path(), 20, 90); // ~60 chunks let n = chunk_count(&conn) as usize; assert!( n > 1, @@ -75,14 +55,11 @@ fn vector_search_is_exhaustive_and_truncates_only_to_k() { .embed_query("shared topic alpha") .unwrap(); - // `k` below N is honoured exactly… assert_eq!( db::vector_search(&conn, &probe, n / 2).unwrap().len(), n / 2 ); - // …at N returns the whole space… assert_eq!(db::vector_search(&conn, &probe, n).unwrap().len(), n); - // …and far past N (where `vec0` errored) truncates to N, never crashes or caps. assert_eq!( db::vector_search(&conn, &probe, n + 5000).unwrap().len(), n, @@ -90,23 +67,16 @@ fn vector_search_is_exhaustive_and_truncates_only_to_k() { ); } -/// `discover::candidates` surfaces the full requested set on a real multi-note vault, -/// uncapped and anchor-excluded — the whole-space discovery scan is not silently -/// truncated below the notes it should return. #[test] fn similar_returns_the_full_candidate_set_without_a_silent_cap() { - // 50 unlinked notes → ~150 chunks: a genuine multi-note, multi-chunk scan (fast), - // not a toy vault. With no links every other note is a candidate for the anchor. + // ~150 chunks; with no links every other note is a candidate. let tmp = tempfile::TempDir::new().unwrap(); let (conn, ids) = big_vault(tmp.path(), 50, 90); - // A limit below the candidate pool must come back *full* — no silent under-cap. - // (49 candidates sit under the 200-note shortlist floor, so the two-stage scan is - // exact, exactly as the old whole-space scan was.) + // 49 candidates sit under the 200-note shortlist floor, so the scan is exact. let forty = discover::candidates(&conn, &ids[0], 40, false).unwrap(); assert_eq!(forty.len(), 40, "the scan honours the full requested limit"); - // A limit past the pool returns *every* unlinked note, and never the anchor. let all = discover::candidates(&conn, &ids[0], 1000, false).unwrap(); assert_eq!( all.len(), @@ -119,10 +89,7 @@ fn similar_returns_the_full_candidate_set_without_a_silent_cap() { ); } -/// A large `--limit` blows the fused pool (`limit × 5`) well past the old `vec0` cap; -/// this crashed on any embedded vault, independent of size. A modest vault proves the -/// fix: the scan honours the full limit (here the whole vault, < limit) rather than -/// truncating. +/// A large `--limit` widens the fused pool (`limit × 5`); the scan must honour it. #[test] fn hybrid_search_honours_an_oversized_limit_without_truncating() { let tmp = tempfile::TempDir::new().unwrap(); @@ -131,8 +98,7 @@ fn hybrid_search_honours_an_oversized_limit_without_truncating() { let hits = search::hybrid_search(&conn, &FakeEmbedder::new(64), "shared topic", 1000) .unwrap() .hits; - // Every chunk matches the query, so an un-truncated pool returns them all - // (hybrid_search is chunk-level; note dedup happens above it, in the façade). + // Every chunk matches, and hybrid_search is chunk-level (dedup is the façade's). assert_eq!( hits.len() as i64, chunk_count(&conn), diff --git a/crates/b2-core/tests/why.rs b/crates/b2-core/tests/why.rs index ac29169..424ea26 100644 --- a/crates/b2-core/tests/why.rs +++ b/crates/b2-core/tests/why.rs @@ -1,12 +1,6 @@ -//! "Why was this suggested?" — `Vault::why_similar`, the chat answer behind a click on a -//! *Similar & unlinked* card. A **tool-using** chat turn: the model is offered B2's -//! read-only tools (`b2_passage_pairs`, `b2_similar`, `b2_neighbors`, `b2_read`), the core -//! runs what it asks for, and the explanation streams with citations. The pair lookup the -//! row was ranked on is never skipped: B2 makes it when the model does not. -//! -//! Like `tests/ask.rs` these prove the **plumbing** against [`FakeLlm`] and the fake -//! embedder — which passages are handed over, what the facts say, how citations resolve, -//! the degrades — not explanation quality, which is a real-model concern. +//! "Why was this suggested?" (`Vault::why_similar`): a tool-using chat turn over B2's +//! read-only tools, streamed with citations. B2 makes the pair lookup when the model +//! doesn't. Plumbing only, against [`FakeLlm`]; quality is a real-model concern. mod common; @@ -33,8 +27,7 @@ const B: &str = "b.md"; const C: &str = "c.md"; const E: &str = "e.md"; -/// Several short sections, so a fine chunk target cuts each note into several passages -/// and "the nearest pairs" is a real ranking rather than a single forced pair. +/// Several short sections, so each note cuts into several passages to rank. fn sections(topic: &str) -> String { (1..=4) .map(|i| { @@ -67,8 +60,7 @@ fn chain_vault(dir: &Path) -> (Vault, PathBuf) { (vault, root) } -/// Records the request it was handed, then answers as [`FakeLlm`] does — how the suite -/// reads the assembled prompt without reaching into the orchestration. +/// Records each request, then answers as [`FakeLlm`] does. #[derive(Default)] struct Recording { seen: RefCell>, @@ -101,8 +93,7 @@ fn the_best_pair_is_the_passage_the_card_showed() { for c in cands { let pairs = discover::passage_pairs(&conn, A, &c.note_path, WHY_PAIRS).unwrap(); let best = pairs.first().expect("an embedded candidate has a pair"); - // The explanation must be about the same passage the card printed as evidence, - // at the same score — one ranking, not two that could disagree. + // The same passage and score the card showed: one ranking, not two. assert_eq!(best.candidate_chunk_id, c.evidence_chunk_id); assert!((best.score - c.score).abs() < 1e-6, "{best:?} vs {c:?}"); } @@ -124,9 +115,8 @@ fn pairs_are_nearest_first_distinct_per_candidate_passage_and_capped() { assert_eq!(distinct.len(), all.len(), "one pair per candidate passage"); assert!(all.windows(2).all(|w| w[0].score >= w[1].score)); - // Determinism: the same read twice is the same answer. assert_eq!(all, discover::passage_pairs(&conn, A, C, 100).unwrap()); - // Nothing to compare ⇒ nothing, never an error. + // Nothing to compare is empty, never an error. assert!(discover::passage_pairs(&conn, A, C, 0).unwrap().is_empty()); assert!(discover::passage_pairs(&conn, A, "nope.md", 3) .unwrap() @@ -154,15 +144,13 @@ fn the_model_is_offered_b2s_tools_and_what_it_calls_is_run() { offered, BTreeSet::from([TOOL_PASSAGE_PAIRS, TOOL_SIMILAR, TOOL_NEIGHBORS, TOOL_READ]) ); - // Nothing is looked up for the model: the lookups are its own to make. + // The lookups are the model's own to make. assert!(first.exchanges.is_empty() && first.passages.is_empty()); - // The conversation is one user turn naming both notes. assert_eq!(first.turns.len(), 1); assert_eq!(first.turns[0].role, Role::User); assert!(first.turns[0].content.contains(A) && first.turns[0].content.contains(C)); - // FakeLlm's script: call every no-argument tool once (`{}` means this pair), then - // answer. The second request replays those calls with their results. + // FakeLlm calls every tool once with `{}` (this pair), then answers. assert_eq!(seen.len(), 2); let second = &seen[1]; let called: Vec<&str> = second @@ -189,12 +177,12 @@ fn the_model_is_offered_b2s_tools_and_what_it_calls_is_run() { "neighbors names a's link to b" ); - // The passages the tool handed over are the citation ledger — both notes, only them, - // each numbered once — and are NOT repeated in the system message. + // The tool passages are the citation ledger, each numbered once, and are not + // repeated in the system message. let paths: BTreeSet<&str> = second.passages.iter().map(|p| p.path.as_str()).collect(); assert_eq!(paths, BTreeSet::from([A, C])); assert!(second.passages.len() <= 2 * WHY_PAIRS + READ_PASSAGES); - // `b2_read {}` reads the suggested note — the open one is already on screen. + // `b2_read {}` reads the suggested note; the open one is already on screen. assert!(second.exchanges[3].result.starts_with("c.md:")); let texts: BTreeSet<(&str, &str)> = second .passages @@ -204,7 +192,6 @@ fn the_model_is_offered_b2s_tools_and_what_it_calls_is_run() { assert_eq!(texts.len(), second.passages.len()); assert!(!second.system_message().contains("Passages:")); - // The view says which tools ran, and that the model chose every one of them. let used: Vec<(&str, bool)> = view .tools .iter() @@ -234,8 +221,7 @@ fn a_model_that_skips_the_pair_lookup_has_it_made_for_it() { .why_similar(&llm, A, C, 10, &mut keep_streaming()) .unwrap(); - // The row was ranked on its matched pairs, so no explanation is written without - // them: B2 appends the call the model left out, marked as its own. + // B2 appends the pair lookup the model left out, marked as seeded. let seen = llm.seen.borrow(); let called: Vec<&str> = seen[1] .exchanges @@ -280,8 +266,7 @@ fn a_model_that_ignores_its_tools_is_handed_the_evidence_and_its_first_try_is_ne assert!(view.tools.len() == 1 && view.tools[0].seeded); } -/// A provider that plays a fixed script of rounds: each round is either tool calls or -/// the final answer's tokens. Records every request, like [`Recording`]. +/// Plays a fixed script of rounds (tool calls or answer tokens), recording each request. struct Scripted { rounds: RefCell>, seen: RefCell>, @@ -352,7 +337,7 @@ impl LlmProvider for Scripted { fn a_tool_the_model_calls_is_run_and_its_passages_become_citable() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = chain_vault(tmp.path()); - // The model reads the linked note b.md — neither side of the pair — then cites it. + // b.md is neither side of the pair. let llm = Scripted::new(vec![ Round::Calls(vec![ (TOOL_READ, r#"{"note":"b.md"}"#), @@ -370,7 +355,7 @@ fn a_tool_the_model_calls_is_run_and_its_passages_become_citable() { let read = &seen[1].exchanges[0]; assert_eq!(read.call.name, TOOL_READ); assert!(read.result.contains("[1] b.md"), "{}", read.result); - // One ledger per turn: the pair lookup's numbering continues past what `read` took. + // One ledger per turn: numbering continues past what `read` took. let from_b = seen[1].passages.iter().take_while(|p| p.path == B).count(); assert!(from_b >= 1); let pairs = &seen[1].exchanges[1].result; @@ -389,8 +374,7 @@ fn a_tool_the_model_calls_is_run_and_its_passages_become_citable() { fn a_bad_tool_call_is_answered_with_an_error_result_not_a_failed_turn() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = chain_vault(tmp.path()); - // Model output is untrusted: an unknown tool, arguments that aren't JSON, a note that - // doesn't exist, arguments that are JSON but not an object. Each gets a result the model can read. + // Model output is untrusted: each bad call gets a result the model can read. let llm = Scripted::new(vec![ Round::Calls(vec![ ("b2_delete_everything", "{}"), @@ -418,8 +402,7 @@ fn a_bad_tool_call_is_answered_with_an_error_result_not_a_failed_turn() { ); assert!(results[0].contains("unknown tool")); assert!(results[2].contains("nope.md")); - // Nothing a failed call returned became citable: the ledger holds only what B2's own - // pair lookup (made because the model never got one) handed over. + // Nothing a failed call returned became citable; only B2's seeded pair lookup. assert_eq!(seen[1].exchanges[4].call.name, TOOL_PASSAGE_PAIRS); assert!(seen[1].passages.iter().all(|p| p.path == A || p.path == C)); } @@ -428,7 +411,6 @@ fn a_bad_tool_call_is_answered_with_an_error_result_not_a_failed_turn() { fn the_loop_is_bounded_and_the_last_round_must_answer() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = chain_vault(tmp.path()); - // A model that would call tools forever. let mut rounds: Vec = (1..MAX_TOOL_ROUNDS) .map(|_| Round::Calls(vec![(TOOL_SIMILAR, "{}")])) .collect(); @@ -451,8 +433,7 @@ fn the_loop_is_bounded_and_the_last_round_must_answer() { ); } -/// A provider for a model with no tool support: any request that offers tools is -/// refused (Ollama's `400 … does not support tools`), anything else is answered. +/// A model with no tool support: a request offering tools is refused, as Ollama does. #[derive(Default)] struct NoToolSupport { seen: RefCell>, @@ -491,7 +472,6 @@ fn a_model_without_tool_support_is_handed_the_evidence_instead() { assert_eq!(handoff.kind, RequestKind::Chat); assert!(handoff.system.starts_with(WHY_SYSTEM_PROMPT)); assert!(handoff.tools.is_empty() && handoff.exchanges.is_empty()); - // B2's own pair lookup, handed over in the system message. assert!(!handoff.passages.is_empty()); assert!(handoff.passages.iter().all(|p| p.path == A || p.path == C)); assert!(handoff.system.contains("[1] and [2]")); @@ -499,7 +479,6 @@ fn a_model_without_tool_support_is_handed_the_evidence_instead() { assert!(view.answer.starts_with("Grounded in [1]")); assert!(!view.citations.is_empty()); - // Only B2's own lookup ran, and the view says so. assert_eq!(view.tools.len(), 1); assert!(view.tools[0].seeded); } @@ -509,22 +488,20 @@ fn the_facts_report_what_b2s_tools_found_for_the_pair() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = chain_vault(tmp.path()); - // The handoff is where B2 states the graph facts itself (a tool-using turn reads - // them with `b2_neighbors`), so read them off a model with no tool support. + // Only the handoff states the graph facts itself, so read them off a no-tools model. let system_for = |candidate: &str| { let llm = NoToolSupport::default(); vault .why_similar(&llm, A, candidate, 10, &mut keep_streaming()) .unwrap(); let seen = llm.seen.borrow(); - // The rank is the card's, and both kinds of turn are told it. + // Both kinds of turn are told the card's rank. if let Some(line) = seen[1].system.lines().find(|l| l.contains("ranked #")) { assert!(seen[0].system.contains(line), "{line}"); } seen[1].system.clone() }; - // The card's own position: the rank `similar` served it at, out of what it served. let served = vault.similar(A, 10).unwrap(); let rank_of = |p: &str| served.iter().position(|s| s.path == p).unwrap() + 1; @@ -533,13 +510,12 @@ fn the_facts_report_what_b2s_tools_found_for_the_pair() { assert!(c.contains("no direct link")); assert!(c.contains("share no linked neighbours")); - // e is two hops away through b — the shared neighbour is named. + // e is two hops away through b. let e = system_for(E); assert!(e.contains("no direct link")); assert!(e.contains("Both link to or from: b.md"), "{e}"); - // b is already linked: said plainly, and no rank is claimed for a note discovery - // would not list. + // No rank is claimed for a note discovery would not list. let b = system_for(B); assert!(b.contains("already directly linked"), "{b}"); assert!(!b.contains("ranked #")); @@ -608,10 +584,9 @@ fn an_unembedded_vault_is_explained_from_what_can_be_read_never_failed() { write_note(&root, A, "alpha"); write_note(&root, C, "gamma"); let vault = Vault::open(&root).unwrap(); - vault.project(false).unwrap(); // projected, never embedded + vault.project(false).unwrap(); - // With tools: the pair lookup says why it has nothing, and reading still works — - // `b2_read` needs chunks, not vectors. + // With tools, `b2_read` still works: it needs chunks, not vectors. let llm = Recording::default(); let view = vault .why_similar(&llm, A, C, 10, &mut keep_streaming()) @@ -622,8 +597,7 @@ fn an_unembedded_vault_is_explained_from_what_can_be_read_never_failed() { assert_eq!(view.citations.len(), 1); assert_eq!(view.citations[0].path, C); - // Without tools there is only the pair evidence, and there is none: the facts say - // so and the answer is the no-evidence sentence, not a failure. + // Without tools there is no evidence at all: the no-evidence sentence, not a failure. let llm = NoToolSupport::default(); let view = vault .why_similar(&llm, A, C, 10, &mut keep_streaming()) @@ -688,9 +662,8 @@ fn a_blown_tool_call_cap_is_surfaced_never_papered_over_by_the_handoff() { let (vault, _root) = chain_vault(tmp.path()); let llm = OverTheCap::default(); - // A failed lookup round normally degrades to the handoff, because the usual cause - // is a model with no tool support. A reply past the cap is not that: it is a broken - // or hostile server, and quietly answering anyway would hide it. + // A failed lookup round normally degrades to the handoff, but a reply past the cap + // means a broken or hostile server, which answering anyway would hide. let err = vault .why_similar(&llm, A, C, 10, &mut keep_streaming()) .unwrap_err(); @@ -702,9 +675,7 @@ fn a_blown_tool_call_cap_is_surfaced_never_papered_over_by_the_handoff() { #[test] fn the_why_prompt_keeps_the_grounding_rules_of_chat() { - // The explanation is grounded chat with a narrower subject: it must cite, and it - // must not reach for general knowledge. The no-evidence sentence is the one the - // fake obeys, pinned here exactly as it is for the grounded prompt. + // Grounded chat with a narrower subject: cite, and no general knowledge. assert!(WHY_SYSTEM_PROMPT.contains(NO_EVIDENCE_ANSWER)); assert!(WHY_SYSTEM_PROMPT.contains("[n]")); @@ -726,9 +697,8 @@ fn the_why_prompt_keeps_the_grounding_rules_of_chat() { assert!(req.system.contains("z = 1.23")); assert!(req.system.contains("[1] and [2]")); - // A hub-heavy vault can share dozens of neighbours. The count is always exact; the - // names stop at a handful, because a long list is what a small model recites back - // instead of answering. + // The count is exact but the names stop at a handful: a small model recites a long + // list instead of answering. let crowded = chat::WhyFacts { shared_neighbors: (1..=8).map(|i| format!("n{i}.md")).collect(), ..facts diff --git a/crates/b2-core/tests/write.rs b/crates/b2-core/tests/write.rs index b6846e9..7fa1c66 100644 --- a/crates/b2-core/tests/write.rs +++ b/crates/b2-core/tests/write.rs @@ -1,9 +1,6 @@ -//! `Vault::write` — the editing surface's one write op: a byte-honest body splice -//! guarded by a content-hash revision, followed -//! by a **model-free** re-projection. The invariants under test: frontmatter -//! bytes are invariant under save; the revision chain never self-conflicts while -//! external writes always conflict; the save path needs no model; and a saved note -//! converges to exactly what a full rebuild would produce once an embed pass runs. +//! `Vault::write`: a byte-honest body splice guarded by a content-hash revision, then a +//! model-free re-projection. Frontmatter bytes survive, the revision chain never +//! self-conflicts, external writes always conflict, and an embed pass converges. mod common; @@ -29,12 +26,10 @@ fn write_replaces_body_and_preserves_frontmatter_bytes() { let report = vault.write(SRS_PATH, new_body, ¬e.revision).unwrap(); assert_eq!(report.path, SRS_PATH); - // The frontmatter region is byte-identical; the body is the buffer verbatim. let after = fs::read_to_string(root.join(SRS_PATH)).unwrap(); assert_eq!(&after[..fm_end], &before[..fm_end], "frontmatter untouched"); assert_eq!(&after[fm_end..], new_body, "body is the buffer, verbatim"); - // A fresh read round-trips the new body and the returned revision. let reread = vault.read(SRS_PATH).unwrap(); assert_eq!(reread.body, new_body); assert_eq!(reread.revision, report.revision); @@ -47,7 +42,7 @@ fn write_conflicts_when_the_file_changed_on_disk() { let note = vault.read(SRS_PATH).unwrap(); - // An external editor changes the file after our read… + // An external editor changes the file after our read. let abs = root.join(SRS_PATH); let external = format!( "{}\nAn external append.\n", @@ -55,7 +50,6 @@ fn write_conflicts_when_the_file_changed_on_disk() { ); fs::write(&abs, &external).unwrap(); - // …so a save based on the stale revision is refused, and nothing is written. let err = vault .write(SRS_PATH, "my edit", ¬e.revision) .unwrap_err(); @@ -66,7 +60,7 @@ fn write_conflicts_when_the_file_changed_on_disk() { "a conflicted save must not touch the file" ); - // The "Keep mine" path: a fresh read (current revision) + write succeeds. + // The "Keep mine" path: a fresh read, then write. let fresh = vault.read(SRS_PATH).unwrap(); vault.write(SRS_PATH, "my edit", &fresh.revision).unwrap(); assert_eq!(vault.read(SRS_PATH).unwrap().body, "my edit"); @@ -77,8 +71,7 @@ fn sequential_writes_chain_revisions_without_conflict() { let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); - // Each save is based on the revision the previous save returned — the - // serialized chain (§3 "last save wins — by construction"). + // §3: "last save wins, by construction". let note = vault.read(SRS_PATH).unwrap(); let first = vault .write(SRS_PATH, "draft one\n", ¬e.revision) @@ -97,19 +90,15 @@ fn write_reprojects_keyword_graph_and_clears_stale_vectors() { let conn = index_conn(&root); assert_eq!(count(&conn, "embeddings"), count(&conn, "chunks")); - // The golden SRS note links memory twice: a body reference + a frontmatter - // `supports`. Save a body that keeps ONE link and adds fresh text. + // SRS links memory from the body and from frontmatter; keep one body link. let note = vault.read(SRS_PATH).unwrap(); let new_body = "Rewritten body about zettelkasten workflows.\n\nSee [[concepts/memory]].\n"; vault.write(SRS_PATH, new_body, ¬e.revision).unwrap(); - // Keyword index reflects the new text immediately (model-free)… let hits = vault.search("zettelkasten", 10).unwrap(); assert!(hits.iter().any(|h| h.path == SRS_PATH), "FTS is current"); - // …edges were re-derived: the body contributes its one reference, and the - // frontmatter `supports` survives untouched — a body save never edits the - // frontmatter home (data-model §2). + // A body save never edits the frontmatter home (data-model §2). let outbound: Vec<(String, String)> = { let mut s = conn .prepare( @@ -131,8 +120,7 @@ fn write_reprojects_keyword_graph_and_clears_stale_vectors() { "edges re-projected from the saved body + the untouched frontmatter" ); - // …and the re-chunked note's new chunk text joined the pending set, which an - // embed pass then fills exactly (§7 invariant 5 — convergence). + // §7 invariant 5: the new chunks await embedding, and a pass fills them exactly. let missing = db::chunks_missing_vectors(&conn).unwrap(); assert!(!missing.is_empty(), "saved chunks await embedding"); assert!(missing.iter().all(|c| c.note_path == SRS_PATH)); @@ -143,11 +131,8 @@ fn write_reprojects_keyword_graph_and_clears_stale_vectors() { "every chunk now addresses a stored vector" ); - // The *superseded* text's vector is still on disk — vectors outlive the chunks - // that addressed them since GH #170, which is what makes a move free. It is - // unreachable (every read joins through `chunks`), and the next whole-vault pass - // collects it, so a save leaves the index correct and slightly over-full rather - // than paying a scan on the interactive path. + // The superseded vector stays, unreachable, until a whole-vault pass (GH #170), so a + // save pays no scan on the interactive path. assert!( count(&conn, "embeddings") > count(&conn, "chunks"), "the pre-save text's vector is retained until the next whole-vault pass" @@ -162,8 +147,7 @@ fn write_reprojects_keyword_graph_and_clears_stale_vectors() { #[test] fn write_needs_no_embedding_space() { - // A projected-only vault (no vector tables, no model anywhere): saving works — the - // model-free proof (§7 invariant 4). + // §7 invariant 4: saving needs no model. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); vault.project(false).unwrap(); @@ -182,31 +166,26 @@ fn write_needs_no_embedding_space() { !db::embedding_space_exists(&conn).unwrap(), "a save must not create the embedding space" ); - // The saved text is keyword-searchable straight away (keyword-first, §5 of the split). let hits = vault.search("vectors in sight", 10).unwrap(); assert!(hits.iter().any(|h| h.path == SRS_PATH)); } #[test] fn write_an_empty_body_and_recover() { - // Select-all-delete under autosave is a real input: an empty buffer must save - // (a frontmatter-only file; zero chunks replace the note's old rows) without - // upsetting the index, and the revision chain must continue out of the empty - // state. `chunk_body` documents the empty case; this pins it end to end. + // Select-all-delete under autosave is a real input; the chain must continue after. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); let report = vault.write(SRS_PATH, "", ¬e.revision).unwrap(); - // The file is frontmatter only, verbatim; a fresh read round-trips it. let on_disk = fs::read_to_string(root.join(SRS_PATH)).unwrap(); assert!(on_disk.ends_with("---\n"), "frontmatter only: {on_disk:?}"); let reread = vault.read(SRS_PATH).unwrap(); assert_eq!(reread.body, ""); assert_eq!(reread.revision, report.revision); - // The note projected to zero chunks but is still indexed and searchable-around. + // Zero chunks, but still indexed. let conn = index_conn(&root); let chunks: i64 = conn .query_row( @@ -223,7 +202,6 @@ fn write_an_empty_body_and_recover() { .any(|n| n.path == SRS_PATH)); vault.search("memory", 10).unwrap(); - // …and the chain continues out of the empty state. let next = vault .write(SRS_PATH, "Recovered.\n", &report.revision) .unwrap(); @@ -233,12 +211,8 @@ fn write_an_empty_body_and_recover() { #[test] fn write_returns_the_revision_of_the_final_on_disk_bytes() { - // The contract the save chain hangs on (§4 step 5): whatever the save left on - // disk, the returned revision hashes those FINAL bytes, so the next save never - // self-conflicts. Since GH #170 a save writes nothing but the body splice — the - // `b2id` stamp that used to ride along on the same write is gone (W1) — so the - // "final bytes" are the spliced bytes; the guarantee is unchanged and the chain - // still needs it proven. + // §4 step 5: the returned revision hashes the final on-disk bytes, so the next save + // never self-conflicts. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_vault_copy(&root); @@ -248,7 +222,7 @@ fn write_returns_the_revision_of_the_final_on_disk_bytes() { ) .unwrap(); let vault = Vault::open(&root).unwrap(); - vault.project(false).unwrap(); // projects fresh.md on first sight + vault.project(false).unwrap(); let note = vault.read("fresh").unwrap(); let report = vault @@ -265,6 +239,5 @@ fn write_returns_the_revision_of_the_final_on_disk_bytes() { blake3::hash(on_disk.as_bytes()).to_hex().to_string(), "the returned revision hashes the final on-disk bytes" ); - // …and the chain continues from it without conflict. vault.write("fresh", "Again.\n", &report.revision).unwrap(); } diff --git a/crates/b2-core/tests/write_frontmatter.rs b/crates/b2-core/tests/write_frontmatter.rs index 2b40b30..59c8a15 100644 --- a/crates/b2-core/tests/write_frontmatter.rs +++ b/crates/b2-core/tests/write_frontmatter.rs @@ -1,10 +1,6 @@ -//! `Vault::write_frontmatter` — the drawer's write op, `Vault::write`'s frontmatter sibling -//! (GH #79). Under test: the body (bytes AND boundary) is invariant under a frontmatter save; -//! the one refusal — a `---` line, which would end the block early — holds before any byte -//! reaches disk; the revision guard mirrors `write`'s; malformed-but-human YAML saves fine -//! (warn-don't-block); and the saved block re-projects edges and tags without touching chunks -//! or vectors. B2 owns no line inside this block, so it is wholly the human's — which is what -//! the "every key survives whatever the human writes" test pins. +//! `Vault::write_frontmatter`, the drawer's write op (GH #79): the body is invariant, a +//! `---` line is the one refusal, the revision guard mirrors `write`'s, malformed YAML +//! saves (warn, don't block), and edges and tags re-project without touching vectors. mod common; @@ -29,25 +25,19 @@ fn saves_the_block_verbatim_and_leaves_the_body_untouched() { .unwrap(); assert_eq!(report.path, SRS_PATH); - // On disk: the new block between untouched fences, the body byte-identical. let after = fs::read_to_string(root.join(SRS_PATH)).unwrap(); assert_eq!(after, format!("---\n{new_fm}---\n{body_before}")); - // A fresh read round-trips the block, the body, and the returned revision. let reread = vault.read(SRS_PATH).unwrap(); assert_eq!(reread.frontmatter.as_deref(), Some(new_fm.as_str())); assert_eq!(reread.body, body_before); assert_eq!(reread.revision, report.revision); - // The projection followed: the new tags landed, the note re-reads clean. assert_eq!(reread.tags, vec!["learning", "memory"]); assert!(reread.frontmatter_readable); } -/// The other side of the removed identity guard (GH #170): B2 owns **no** line -/// inside this block, so every edit a human can express saves. The four inputs are -/// exactly the ones that used to be refused — a block with no `b2id`, a different -/// one, a blanked one, a duplicated one — and each is now just YAML, round-tripped -/// verbatim like any other unknown key (W5). +/// B2 owns no line in the block (GH #170), so `b2id` edits are just YAML, kept verbatim +/// like any unknown key (W5). #[test] fn owns_no_line_in_the_block_so_every_human_edit_saves() { let tmp = tempfile::TempDir::new().unwrap(); @@ -68,8 +58,7 @@ fn owns_no_line_in_the_block_so_every_human_edit_saves() { on_disk.starts_with(&format!("---\n{block}---\n")), "saved verbatim: {on_disk:?}" ); - // And the note is still the note: identity is the path, which no edit to - // this block can reach. + // Identity is the path, which no edit to this block can reach. assert_eq!(vault.read(SRS_PATH).unwrap().path, SRS_PATH); } } @@ -81,8 +70,7 @@ fn refuses_a_fence_line_that_would_leak_into_the_body() { let note = vault.read(SRS_PATH).unwrap(); let on_disk_before = fs::read_to_string(root.join(SRS_PATH)).unwrap(); - // A `---` line would close the block early and shift the rest into the body — - // refused, because the body is not this op's to change. + // A `---` line would close the block early and shift the rest into the body. let err = vault .write_frontmatter( SRS_PATH, @@ -103,7 +91,7 @@ fn conflicts_when_the_file_changed_on_disk() { let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); - // An external editor changes the file after our read… + // An external editor changes the file after our read. let abs = root.join(SRS_PATH); let external = format!( "{}\nAn external append.\n", @@ -111,14 +99,13 @@ fn conflicts_when_the_file_changed_on_disk() { ); fs::write(&abs, &external).unwrap(); - // …so a save based on the stale revision is refused, and nothing is written. let err = vault .write_frontmatter(SRS_PATH, "tags: [x]\n", ¬e.revision) .unwrap_err(); assert!(matches!(err, Error::WriteConflict(p) if p == SRS_PATH)); assert_eq!(fs::read_to_string(&abs).unwrap(), external); - // The "Keep mine" path: a fresh read (current revision) + write succeeds. + // The "Keep mine" path: a fresh read, then write. let fresh = vault.read(SRS_PATH).unwrap(); vault .write_frontmatter(SRS_PATH, "tags: [x]\n", &fresh.revision) @@ -127,10 +114,8 @@ fn conflicts_when_the_file_changed_on_disk() { #[test] fn malformed_yaml_saves_and_surfaces_as_unreadable_not_an_error() { - // Warn, don't block (W4/W5): broken YAML in the human's keys is the human's to - // fix — the same edit made in vim would land on disk too. B2 keeps identity - // keeps the bytes verbatim, and flags the block unreadable on every - // subsequent read. + // Warn, don't block (W4/W5): broken YAML is the human's to fix, as in vim. B2 keeps + // the bytes and flags the block unreadable. let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); @@ -150,7 +135,6 @@ fn malformed_yaml_saves_and_surfaces_as_unreadable_not_an_error() { ); assert!(reread.tags.is_empty(), "unreadable YAML projects no fields"); - // And the fix heals it through the same op: readable again, fields back. let fixed = "tags: [a]\n".to_string(); vault .write_frontmatter(SRS_PATH, &fixed, &reread.revision) @@ -168,8 +152,7 @@ fn reprojects_edges_from_the_new_block_without_touching_vectors() { let embeddings_before = count(&conn, "embeddings"); assert_eq!(embeddings_before, count(&conn, "chunks")); - // Retype the golden `supports` relation to `contradicts` by editing the block — - // the hand-authoring path the drawer makes in-app (legitimate per GH #79). + // Retype `supports` to `contradicts` by hand (GH #79). let note = vault.read(SRS_PATH).unwrap(); let new_fm = "b2_relations:\n - \"contradicts [[concepts/memory]] — retyped by hand\"\n".to_string(); @@ -177,7 +160,6 @@ fn reprojects_edges_from_the_new_block_without_touching_vectors() { .write_frontmatter(SRS_PATH, &new_fm, ¬e.revision) .unwrap(); - // The typed edge re-derived from the new block… let types: Vec = { let mut s = conn .prepare( @@ -192,8 +174,7 @@ fn reprojects_edges_from_the_new_block_without_touching_vectors() { }; assert_eq!(types, vec!["contradicts".to_string()]); - // …and the unchanged body kept every chunk vector: a frontmatter save never - // re-embeds (the re-chunk keys on the body hash). + // The re-chunk keys on the body hash, so nothing re-embeds. assert_eq!(count(&conn, "embeddings"), embeddings_before); assert!(db_pending_is_empty(&conn)); } @@ -206,8 +187,7 @@ fn db_pending_is_empty(conn: &Connection) -> bool { #[test] fn needs_no_embedding_space() { - // A projected-only vault (no vector tables, no model anywhere): the drawer - // save works — the same model-free posture as `Vault::write`. + // Model-free, like `Vault::write`. let tmp = tempfile::TempDir::new().unwrap(); let (vault, root) = opened_vault(tmp.path()); vault.project(false).unwrap(); @@ -227,8 +207,7 @@ fn needs_no_embedding_space() { #[test] fn sequential_saves_chain_revisions_and_mix_with_body_saves() { - // One whole-file revision guards both write sites: a frontmatter save chains - // off a body save's revision and vice versa, never self-conflicting. + // One whole-file revision guards both write sites. let tmp = tempfile::TempDir::new().unwrap(); let (vault, _root) = reindexed_vault(tmp.path()); diff --git a/crates/b2-desktop/Cargo.toml b/crates/b2-desktop/Cargo.toml index 970d217..8affba8 100644 --- a/crates/b2-desktop/Cargo.toml +++ b/crates/b2-desktop/Cargo.toml @@ -5,10 +5,8 @@ edition = "2021" rust-version = "1.96" description = "B2 desktop app — the Tauri host, a dumb adapter over the b2-core Vault façade (the GUI sibling of b2-cli)" -# The dependency arrow points *one way*: this crate depends on b2-core (+ b2-embed); -# the core never learns about Tauri or the UI (crates/b2-desktop/CLAUDE.md). So the -# fast core suite (`cargo test -p b2-core`) never compiles Tauri/webview deps — the -# same discipline that keeps b2-embed's candle deps out of the core. +# Depends on b2-core; the core never learns about Tauri or the UI +# (crates/b2-desktop/CLAUDE.md), so `cargo test -p b2-core` never compiles webview deps. [[bin]] name = "b2-desktop" @@ -20,72 +18,48 @@ tauri-build = { version = "2", features = [] } [dependencies] b2-core = { path = "../b2-core" } b2-embed = { path = "../b2-embed" } -# The chat provider + the Ollama-native onboarding corner (GH #155), wired here exactly -# as b2-embed is: the host picks the implementation and injects it, the façade does the -# work. Sync end-to-end, so it brings no runtime with it. +# The chat provider and Ollama onboarding (GH #155), injected like b2-embed. Sync, no runtime. b2-llm = { path = "../b2-llm" } tauri = { version = "2", features = [] } -# Native folder picker for the in-app vault switcher. Used host-side only (the -# `choose_vault` command calls it in Rust); the webview is granted no dialog -# permission, so it can't open a dialog itself (least-privilege, capabilities/default.json). +# Folder picker for the vault switcher, called host-side only; the webview gets no dialog +# permission (capabilities/default.json). tauri-plugin-dialog = "2" -# "Open in system default" on the resource fallback card (file-type slice 1, spec §6), -# and the same handoff for a web link inside a note (`open_external`): an OS handoff, -# never in-webview execution — a link followed in the webview would replace the whole -# app. Same least-privilege posture as the dialog: the host-side commands call this in -# Rust after validating what they were handed (a path against the inventory, a URL -# against the scheme allow-list); the webview is granted no opener permission. +# "Open in system default" for resources and web links: an OS handoff, since a link +# followed in the webview would replace the app. Called host-side after validating the +# path or URL; the webview gets no opener permission. tauri-plugin-opener = "2" -# Plain-text clipboard read for the editor's ⌘⇧V (paste as plain text, ui/src/paste.ts). -# Host-side for the same least-privilege reason as the two above — and because it has to -# be: WebKit runs no editing command for a raw ⌘⇧V (the key event reaches the page and -# nothing pastes), and it gates a programmatic `navigator.clipboard` read behind a native -# confirmation. The webview is granted no clipboard permission; only `clipboard_text` reads. +# Plain-text clipboard read for ⌘⇧V (ui/src/paste.ts). Host-side because WebKit runs no +# paste for a raw ⌘⇧V and gates `navigator.clipboard` behind a native prompt. The webview +# gets no clipboard permission. tauri-plugin-clipboard-manager = "2" serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.18" -# Transport only, for `import_file`: a file dropped on the tree reaches the webview as -# *content* (the OS hands WebKit bytes, never a path — ui/src/main.ts), and Tauri's JSON -# IPC has no cheap byte array, so the drop payload crosses as base64 and is decoded here. -# Already in the lock via tauri's own tree, so it costs no extra build. +# `import_file`: a dropped file reaches the webview as bytes, not a path, and crosses the +# JSON IPC as base64. Already in the tree via tauri. base64 = "0.22" -# Platform data dir for the "last opened vault" state file, so the app reopens the -# vault that was open when it was last closed. Same crate/convention b2-embed uses for -# its XDG model cache (`dirs::data_dir()/b2/…`), so both live under one vendor dir. +# Platform data dir for the "last opened vault" state file; same `data_dir()/b2/` as +# b2-embed's model cache. dirs = "6" -# Native filesystem watch for auto-reload on external edits (#14 / crates/b2-desktop/CLAUDE.md). -# Host-owned infrastructure: the watcher coalesces raw OS events into one debounced -# `vault-changed` pulse to the webview, which reconciles via existing façade ops — the -# webview itself is granted no filesystem permission (least-privilege, capabilities/). +# Filesystem watch for auto-reload on external edits (#14): coalesced into one debounced +# `vault-changed` event. The webview gets no filesystem permission. notify = "8" -# Opt-in structured-log sink (B2_LOG/B2_DEBUG/B2_LOG_FILE), the GUI mirror of the CLI's -# — same JSONL shape (see src/logging.rs). `tracing-appender` adds the non-blocking -# writer this long-lived, multi-threaded host needs (the CLI's Mutex is fine for a -# one-shot process; here it would block GUI/reindex threads on log I/O). +# Opt-in JSONL log sink, the CLI's twin (src/logging.rs). `tracing-appender` gives the +# non-blocking writer a long-lived, multi-threaded host needs. tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } tracing-appender = "0.2" serde_json = "1.0.150" -# The macOS Keychain, where a cloud bearer token is remembered between launches (GH #176, -# src/keychain.rs). Three notes on the choice. It is *this* crate rather than `keyring` -# because B2 ships macOS-only, so the platform's own binding is the whole requirement and a -# cross-platform store abstraction would be a dependency tree bought for one target. It is a -# library rather than shelling out to `/usr/bin/security`, whose `add-generic-password -w -# ` would put the token in that process's argv and so in `ps` — the exact argument #154 -# makes against a key in a CLI flag. And it is target-gated: everywhere else `keychain.rs` -# compiles a stub that remembers nothing, which is the same degraded path a Keychain that -# refuses already takes. +# The macOS Keychain, for the cloud API key (GH #176, src/keychain.rs). A library, not +# `/usr/bin/security`, whose `-w ` would put the token in argv and `ps`. Elsewhere +# `keychain.rs` compiles a stub that remembers nothing. [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3.7" -# Research build (GH #40, macOS-only): forward the Metal GPU embedder feature to b2-embed so -# `cargo tauri dev --features metal` (or `make app`, which auto-selects it on Apple Silicon) -# embeds on the GPU. Default is CPU. +# Metal GPU embedder (GH #40, macOS-only); `make app` selects it on Apple Silicon. [features] metal = ["b2-embed/metal"] [dev-dependencies] tempfile = "3.27.0" -# Test-only: exercise the `CmdError` → webview serialization boundary the way Tauri -# does (already in the tree via b2-core, so no new build cost). +# Tests the `CmdError` serialization the way Tauri does. serde_json = "1.0.150" diff --git a/crates/b2-desktop/src/chat.rs b/crates/b2-desktop/src/chat.rs index 0ff89b3..10f9817 100644 --- a/crates/b2-desktop/src/chat.rs +++ b/crates/b2-desktop/src/chat.rs @@ -1,22 +1,9 @@ -//! Chat wiring — the desktop's half of the second AI seam (ADR-0005), and the sibling of -//! `main.rs`'s embedder wiring. What lives here is *which provider a chat command talks -//! to*, layered the way the vault root is: **flag beats environment beats default**, with -//! the app's Settings standing in for the flags. Resolution itself is -//! `b2_llm::LlmConfig::from_env`'s — one place, so the two adapters cannot drift. +//! Chat wiring (ADR-0005): which provider a chat command talks to, with Settings layered +//! over `b2_llm::LlmConfig::from_env` as a CLI flag would be. Adapter state only, so a chat +//! model swap costs no reindex. //! -//! Three things are deliberate: -//! -//! * **Chat config is adapter state, never vault or index state.** Nothing here reaches -//! the index, which is what makes "change models at any time" true by construction: a -//! chat model swap costs no reindex (contrast ADR-0007). -//! * **The API key is remembered in the Keychain, never in a file** (GH #176; see -//! `keychain.rs` for why). It stays `#[serde(skip)]` on both halves, so it is -//! structurally incapable of reaching `chat.json` and a hand-edited one cannot put a key -//! back. It never crosses to the webview either — the status view carries an -//! [`ApiKeySource`], never the key. -//! * **`B2_LLM_API_KEY` outranks the remembered key**, so a shell can point one launch at -//! another provider, or tell B2 to keep no secret of its own. That rule is the one -//! resolver's; this adapter states its sources and doesn't rank them. +//! The API key lives in the Keychain, never in `chat.json` (GH #176), and never crosses to +//! the webview. `B2_LLM_API_KEY` outranks it; that ranking is the resolver's. use crate::keychain::KeyStore; use crate::state_file; @@ -25,13 +12,11 @@ use b2_llm::{ApiKeySource, LlmConfig}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -/// The state file holding the persisted half of [`ChatPrefs`] (see [`state_file`]). +/// The state file holding the persisted half of [`ChatPrefs`]. const PREFS_FILE: &str = "chat.json"; -/// The desktop's chat preferences: the Settings section's two persisted fields, -/// plus the key in force. `None` means "whatever the environment and the -/// defaults say" — so a user who has never opened the section is exactly the CLI -/// with no flags, and clearing a field returns to that rather than to `""`. +/// The desktop's chat preferences plus the key in force. `None` means "whatever the +/// environment and defaults say", as the CLI with no flags. #[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ChatPrefs { /// The OpenAI-compatible base URL the user typed, if any. @@ -40,30 +25,21 @@ pub struct ChatPrefs { /// The chat model id the user typed, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - /// The tool-call cap the user set, if any (`LlmConfig::max_tool_calls`). `None` is - /// "whatever `B2_LLM_MAX_TOOL_CALLS` and the default say", like the two fields above. + /// The tool-call cap the user set, if any (`LlmConfig::max_tool_calls`). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tool_calls: Option, - /// The bearer token for a cloud endpoint, as it stands for this run. - /// - /// `skip` on both halves, not merely `skip_serializing_if`: this type is what - /// [`write_prefs_to`] writes, so the field must be structurally incapable of reaching - /// that file, and a file that somehow named it must not put one *back*. + /// The bearer token in force for this run. `skip` both ways: it can never reach the + /// file, and a file naming it can't install one. #[serde(skip)] pub api_key: Option, - /// Whether [`api_key`](Self::api_key) is also in the [`KeyStore`] — i.e. whether it - /// survives quit. Meaningless on its own, which is why nothing reads it directly: - /// [`ChatPrefs::api_key_source`] is the answer callers want, and deriving it from the - /// pair keeps "a key with no source" unrepresentable. + /// Whether [`api_key`](Self::api_key) is also in the [`KeyStore`]. Read it through + /// [`ChatPrefs::api_key_source`]. #[serde(skip)] pub key_remembered: bool, } -/// Hand-written for `LlmConfig`'s reason, applied to the type that holds the key in -/// force: **a secret kept in an encrypted store must not leak out of a log line instead.** -/// `Debug` is what an adapter reaches for when something is wrong — a `tracing` field, a -/// panic message — which in this host means stderr and, under `B2_LOG_FILE`, a JSONL file -/// the user may paste into an issue. Only the key's *presence* prints. +/// Hand-written, as `LlmConfig`'s is, so the key can never reach a log; only its presence +/// prints. impl std::fmt::Debug for ChatPrefs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ChatPrefs") @@ -83,10 +59,8 @@ impl std::fmt::Debug for ChatPrefs { } impl ChatPrefs { - /// The configuration these preferences resolve to: the shared env resolution with this - /// host's explicit choices laid over it. The key is the one field that does **not** - /// follow that rule — `with_api_key` yields to `B2_LLM_API_KEY` (GH #176). This adapter - /// passes what it has and where it got it; the ranking is the resolver's. + /// The configuration these preferences resolve to, over the shared env resolution. + /// The key yields to `B2_LLM_API_KEY` instead (GH #176). pub fn config(&self) -> LlmConfig { LlmConfig::from_env() .with_overrides(self.base_url.as_deref(), self.model.as_deref()) @@ -94,9 +68,8 @@ impl ChatPrefs { .with_api_key(self.api_key.as_deref(), self.api_key_source()) } - /// Where this host's own key came from — never [`ApiKeySource::Environment`], - /// which only [`LlmConfig::from_env`] has the standing to claim. Derived - /// rather than stored so the pair it reads can never disagree with it. + /// Where this host's own key came from; never [`ApiKeySource::Environment`], which + /// only [`LlmConfig::from_env`] claims. pub fn api_key_source(&self) -> ApiKeySource { match (&self.api_key, self.key_remembered) { (None, _) => ApiKeySource::None, @@ -106,39 +79,25 @@ impl ChatPrefs { } } -/// Pick + wire the chat provider — `main.rs`'s `open_vault` for the second seam, over the -/// same `b2_llm::provider` rule the CLI uses (`B2_LLM=fake` or the configured endpoint). -/// -/// Unlike the CLI's `open_llm` this does **not** probe: the desktop probes once when the -/// chat surface opens, and a round trip per turn would be a per-question tax on a cloud -/// endpoint. A server that dies between the probe and the question surfaces as the same -/// actionable message from `Error::Llm`. +/// The chat provider, by the same rule as the CLI. Unlike the CLI's `open_llm` it doesn't +/// probe: the desktop probes once when the chat surface opens, not per question. pub fn provider(prefs: &ChatPrefs) -> Box { b2_llm::provider(prefs.config()) } -/// Where the persisted half lives: `/b2/chat.json`, beside the remembered vault -/// ([`state_file`]). `None` only if the platform has no data dir — chat still works, it -/// just forgets the endpoint at quit. +/// Where the persisted half lives, or `None` when the platform has no data dir. pub fn prefs_file() -> Option { state_file::path(PREFS_FILE) } -/// The preferences this launch starts from: the endpoint and model out of the -/// state file, and the API key out of the [`KeyStore`]. Best-effort in every -/// direction — an unreadable or malformed file reads as "nothing configured", and -/// a store with nothing in it (or one that refuses) reads as "no key" — because -/// the fallback is a working local configuration. +/// The preferences this launch starts from: the state file, and the key from the +/// [`KeyStore`]. Best-effort: a bad file is "nothing configured", a refusing store "no key". pub fn read_prefs(keys: &dyn KeyStore) -> ChatPrefs { let file = prefs_file(); read_prefs_from(file.as_deref(), keys) } -/// [`read_prefs`] against an explicit path — the testable core (a tempfile stands in -/// for the real state file and `MemoryStore` for the Keychain, so tests touch neither -/// the user's data dir nor their keychain). `None` is the platform with no data dir: -/// no file to read, and a key that is still remembered, since the two live in -/// different places. +/// [`read_prefs`] against an explicit path. `None` (no data dir) still reads the key. pub fn read_prefs_from(file: Option<&Path>, keys: &dyn KeyStore) -> ChatPrefs { let mut prefs = match file.map(std::fs::read_to_string) { Some(Ok(text)) => match serde_json::from_str::(&text) { @@ -150,38 +109,19 @@ pub fn read_prefs_from(file: Option<&Path>, keys: &dyn KeyStore) -> ChatPrefs { }, _ => ChatPrefs::default(), }; - // Reading is silent and prompts for nothing when no key was ever saved, - // which is every user who has not configured a cloud model — so this costs - // the common launch nothing (`keychain.rs`). + // Silent, no prompt, when no key was ever saved. prefs.api_key = keys.load(); prefs.key_remembered = prefs.api_key.is_some(); prefs } -/// Apply a save's key field to the key in force, writing the [`KeyStore`] to match — the -/// one place the store is written, and the whole of the field's three-state rule. -/// -/// **The key is three-state, and has to be.** The Settings field paints empty even when a -/// key is set, so "I didn't touch it" and "I cleared it" would otherwise be the same -/// input — and collapsing them either signs the user out on every save or makes the key -/// impossible to remove. The second is the dangerous one: with the key un-removable, -/// repointing `base_url` would send the *first* provider's token to the second. So: -/// -/// * `None` — **untouched**. Re-saving the endpoint must not sign you out. -/// * `Some(blank)` — **clear**, the UI's Remove button and the only way back to a keyless -/// configuration; forgotten in the store too, or it would return at the next launch. -/// * `Some(key)` — **set**, and remembered for next time. -/// -/// **Removal is all-or-nothing**, and that asymmetry with the *set* path is the point. A -/// refused save is survivable — the key still works, just for this run. A refused *delete* -/// is not: dropping the key from memory while the store keeps it would show a keyless -/// configuration and hand the credential back at the next launch. So a store that won't -/// let go leaves the key exactly where it was. +/// Apply a save's key field, writing the [`KeyStore`] to match; returns +/// `(key, remembered)`. The field paints empty even when a key is set, so it is +/// three-state: `None` untouched, blank clears (the Remove button, which must be possible +/// or repointing `base_url` would leak the key to a new provider), a value sets. /// -/// The returned pair is `(key, remembered)`. A store that refuses is **not** an error: the -/// key stays in force and the configuration reads [`ApiKeySource::Session`], the behaviour -/// that shipped before #176 — the convenience degrades, never chat. What clearing reaches -/// is B2's own key; a `B2_LLM_API_KEY` still outranks it. +/// A refused save degrades to [`ApiKeySource::Session`]. A refused clear keeps the key as +/// it was, or the next launch would read it back. pub fn apply_key( prev: &ChatPrefs, typed: Option<&str>, @@ -192,20 +132,15 @@ pub fn apply_key( }; match typed.trim() { "" if keys.clear() => (None, false), - // The store refused to let go. Keep the key precisely as it stood, so - // what the panel reports and what the next launch will find stay the - // same fact. + // The store refused to let go: keep the key as it stood. "" => (prev.api_key.clone(), prev.key_remembered), key => (Some(key.to_string()), keys.save(key)), } } -/// Apply a save's tool-call-cap field — [`apply_key`]'s three-state rule, for the same -/// reason: a save that doesn't mention the field (the setup card's one-click model pick, -/// the key's Remove button) must not reset it. `None` is **untouched**, blank is **clear** -/// (back to the environment/default), a value is **set**. Judging the value is -/// `b2_llm::parse_max_tool_calls`'s — the environment variable's own parser — and one it -/// refuses leaves the cap as it stood, which the returned status then shows. +/// Apply a save's tool-call-cap field with [`apply_key`]'s three-state rule, so a save +/// that doesn't mention it can't reset it. A value `b2_llm::parse_max_tool_calls` refuses +/// leaves the cap as it stood. pub fn apply_tool_cap(prev: &ChatPrefs, typed: Option<&str>) -> Option { match typed.map(str::trim) { None => prev.max_tool_calls, @@ -214,18 +149,14 @@ pub fn apply_tool_cap(prev: &ChatPrefs, typed: Option<&str>) -> Option { } } -/// Remember the endpoint and model. **Best-effort host state**, like the last -/// opened vault: a write failure is logged and swallowed, never failing the -/// setting the user just made — the change is already live in memory either way. +/// Remember the endpoint and model. Best-effort: the change is already live in memory. pub fn persist_prefs(prefs: &ChatPrefs) { state_file::update(PREFS_FILE, "remember chat settings", |file| { write_prefs_to(file, prefs) }); } -/// [`persist_prefs`] against an explicit path — the testable core. Creates the -/// parent dir if needed. The API key is `#[serde(skip)]`, so what lands on disk -/// is the endpoint and the model and nothing else. +/// [`persist_prefs`] against an explicit path. The API key is skipped. pub fn write_prefs_to(file: &Path, prefs: &ChatPrefs) -> std::io::Result<()> { let json = serde_json::to_string_pretty(prefs) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; @@ -240,7 +171,7 @@ mod tests { #[test] fn prefs_round_trip_without_the_key_in_the_file() { let tmp = tempfile::TempDir::new().unwrap(); - // The file sits under a not-yet-created subdir — the write must `mkdir -p`. + // The parent dir doesn't exist yet. let file = tmp.path().join("state/b2/chat.json"); let prefs = ChatPrefs { base_url: Some("http://localhost:1234/v1".into()), @@ -256,8 +187,6 @@ mod tests { !on_disk.contains("sk-live-must-not-persist"), "a bearer token must never reach the settings file: {on_disk}" ); - // Read back against an *empty* store: the endpoint and model come out of - // the file, and the key does not, because the file never held it. let back = read_prefs_from(Some(&file), &MemoryStore::empty()); assert_eq!(back.base_url.as_deref(), Some("http://localhost:1234/v1")); assert_eq!(back.model.as_deref(), Some("qwen2.5")); @@ -280,7 +209,6 @@ mod tests { assert_eq!(back.max_tool_calls, Some(128)); assert_eq!(back.config().max_tool_calls, 128); - // Never set: nothing about it reaches the file, and the shared resolution stands. write_prefs_to(&file, &ChatPrefs::default()).unwrap(); assert!(!std::fs::read_to_string(&file) .unwrap() @@ -290,7 +218,6 @@ mod tests { LlmConfig::from_env().max_tool_calls ); - // A hand-edited file cannot lift the ceiling the parser enforces. std::fs::write(&file, r#"{"max_tool_calls": 1000000000}"#).unwrap(); let edited = read_prefs_from(Some(&file), &MemoryStore::empty()); assert_eq!( @@ -299,8 +226,6 @@ mod tests { ); } - /// The field's three-state rule, which is the key's: `None` is *untouched*, blank is - /// *clear*, a value is *set* — and a value the shared parser refuses changes nothing. #[test] fn the_tool_call_cap_field_is_untouched_cleared_or_set() { let held = ChatPrefs { @@ -319,9 +244,6 @@ mod tests { } } - /// The other half of #176: the key *does* come back, from the store rather - /// than the file — and it comes back marked as remembered, which is what the - /// Settings copy reads. #[test] fn the_key_comes_back_from_the_store() { let tmp = tempfile::TempDir::new().unwrap(); @@ -337,9 +259,6 @@ mod tests { assert_eq!(prefs.api_key_source(), ApiKeySource::Stored); } - /// The two stores are independent: no settings file (a platform with no data - /// dir, or a first run) still finds a remembered key, and a broken file does - /// not take the key down with it. #[test] fn a_missing_settings_file_does_not_lose_the_remembered_key() { let no_file = read_prefs_from(None, &MemoryStore::holding("sk-remembered")); @@ -354,13 +273,12 @@ mod tests { assert_eq!(prefs.api_key.as_deref(), Some("sk-remembered")); } - /// The field's three states, against the store they have to keep in step. #[test] fn applying_the_key_field_keeps_the_store_in_step() { let store = MemoryStore::empty(); let none = ChatPrefs::default(); - // Set: in force, and remembered for next launch. + // Set. let (key, remembered) = apply_key(&none, Some("sk-typed"), &store); assert_eq!(key.as_deref(), Some("sk-typed")); assert!(remembered); @@ -373,25 +291,19 @@ mod tests { }; assert_eq!(set.api_key_source(), ApiKeySource::Stored); - // Untouched: nothing moves, in memory or in the store. + // Untouched. let (key, remembered) = apply_key(&set, None, &store); assert_eq!(key.as_deref(), Some("sk-typed")); assert!(remembered); assert_eq!(store.peek().as_deref(), Some("sk-typed")); - // Blank — the Remove button. Gone from *both*, or it would come back at - // the next launch and make the button a lie. + // Blank: gone from both memory and the store. let (key, remembered) = apply_key(&set, Some(" "), &store); assert_eq!(key, None); assert!(!remembered); assert_eq!(store.peek(), None); } - /// The failure this module most owes a test: a store that **won't let go**. Dropping - /// the key from memory while the Keychain keeps it would show a keyless configuration - /// and resurrect the credential at the next launch. So removal is all-or-nothing — and - /// the proof is the second half, where a fresh `read_prefs_from` over the same store - /// finds exactly what the panel was still claiming. #[test] fn a_removal_the_store_refuses_does_not_pretend_to_have_happened() { let store = MemoryStore::refusing_holding("sk-wont-let-go"); @@ -410,8 +322,7 @@ mod tests { assert!(remembered); assert_eq!(store.peek().as_deref(), Some("sk-wont-let-go")); - // The whole point, stated as the next launch: what the panel reports and - // what the store will hand back are the same fact. + // The next launch agrees with what the panel reports. let next_launch = read_prefs_from(None, &store); assert_eq!(next_launch.api_key.as_deref(), Some("sk-wont-let-go")); assert_eq!( @@ -426,9 +337,6 @@ mod tests { ); } - /// The other side of it: a *session-only* key sits in no store, so removing - /// it can't be refused — `clear` on an empty store is already the asked-for - /// state (the real Keychain's `errSecItemNotFound`). #[test] fn a_session_key_clears_even_against_a_refusing_store() { let store = MemoryStore::refusing(); @@ -442,9 +350,6 @@ mod tests { assert!(!remembered); } - /// A Keychain that says no must cost the convenience and nothing else: the - /// key the user just typed is still the key in force, and the configuration - /// is honest that it lasts only for this run (GH #176's fallback path). #[test] fn a_refusing_store_degrades_to_session_only() { let store = MemoryStore::refusing(); @@ -459,19 +364,13 @@ mod tests { ..ChatPrefs::default() }; assert_eq!(prefs.api_key_source(), ApiKeySource::Session); - // And it is the key the next question is asked with. Stated as the - // layering rather than as a literal, so it holds however the developer's - // own environment happens to be set (`from_env` reads the process env). + // Stated as the layering, so it holds whatever the process env holds. assert_eq!( prefs.config(), LlmConfig::from_env().with_api_key(Some("sk-typed"), ApiKeySource::Session) ); } - /// The disk guarantee above has a sibling the derive would have broken: - /// `Debug` is how a secret reaches a *log*, and this host's log can be a file - /// (`B2_LOG_FILE`). `LlmConfig` hand-writes its own impl for exactly this, and - /// the type holding the session key owes the same. #[test] fn debug_never_prints_the_api_key() { let prefs = ChatPrefs { @@ -487,9 +386,7 @@ mod tests { "a bearer token must never reach a log: {rendered}" ); assert!(rendered.contains("redacted"), "{rendered}"); - // Presence still shows — "is a key configured at all" is the question a - // rejected cloud call actually needs answered — and now which key, which - // is the follow-up when the answer is "yes, and it's being rejected". + // Presence and source still show. assert!(rendered.contains("api.example.com"), "{rendered}"); assert!(rendered.contains("Stored"), "{rendered}"); assert!( @@ -498,10 +395,6 @@ mod tests { ); } - /// A file naming `api_key` must not be able to install one: the field is - /// skipped in *both* directions, so a hand-edited (or synced) settings file - /// can't quietly become a credential store. #176 moved where a key *does* - /// live; it did not open this door. #[test] fn a_hand_written_key_in_the_file_is_ignored() { let tmp = tempfile::TempDir::new().unwrap(); @@ -532,13 +425,9 @@ mod tests { ); } - /// Empty preferences resolve to exactly what the CLI resolves with no flags — - /// which is the point of laying them over `LlmConfig::from_env` rather than - /// spelling the defaults here. #[test] fn empty_prefs_resolve_to_the_shared_default() { - // `from_env` reads the process environment; this asserts the *layering*, - // which holds however the environment happens to be set. + // Asserts the layering, which holds whatever the process env holds. assert_eq!(ChatPrefs::default().config(), LlmConfig::from_env()); let pointed = ChatPrefs { base_url: Some("http://localhost:1234/v1".into()), diff --git a/crates/b2-desktop/src/commands.rs b/crates/b2-desktop/src/commands.rs index 6f07d7e..878d427 100644 --- a/crates/b2-desktop/src/commands.rs +++ b/crates/b2-desktop/src/commands.rs @@ -1,17 +1,10 @@ -//! The `#[tauri::command]` handlers — B2's IPC surface, and the frontend's mirror of the -//! [`Vault`](b2_core::vault::Vault) façade (ADR-0012). Each handler is **deserialize -> -//! call one façade method -> serialize**: no branch, no loop, no rule. If a handler ever -//! needs one, that logic belongs behind the façade in `b2-core`. The façade already -//! returns `Serialize` views (the CLI's `--json` types), so the IPC contract is nearly -//! free — no parallel DTO layer. +//! The `#[tauri::command]` handlers, B2's IPC surface over the +//! [`Vault`](b2_core::vault::Vault) façade (ADR-0012). Each is deserialize, call one façade +//! method, serialize; any logic belongs in `b2-core`. Views are the CLI's `--json` types. //! -//! Every data command is `#[tauri::command(async)]` so Tauri runs it **off the main -//! thread** — a slow `search` or `embed` never freezes the window. The bodies stay fully -//! synchronous (ADR-0011); `(async)` is only the "don't block the UI" knob. -//! -//! The thin `*_impl` split lets the command layer be unit-tested against a real vault -//! without a Tauri runtime, and [`read_op`] / [`semantic_op`] are the whole of "open a -//! fresh vault, make one façade call, map its error" — so a command body is one line. +//! `#[tauri::command(async)]` only moves a command off the main thread so the window never +//! freezes; the bodies stay synchronous (ADR-0011). The `*_impl` split lets commands be +//! tested without a Tauri runtime. use crate::chat::ChatPrefs; use crate::error::CmdError; @@ -37,11 +30,8 @@ use tauri::ipc::Channel; use tauri::{Manager, State}; use tauri_plugin_dialog::DialogExt; -/// The active vault's root + whether semantic ranking is live, for the UI header and -/// honest empty states. `semantic` answers "is the real model installed"; -/// `notes_embedded`/`notes_total` answer the *precise* "how much of this vault is embedded" -/// (#26), so the UI can flag search as "keyword-only for now" while a projected vault -/// embeds behind the first tree paint. A model-free count. +/// The active vault's root, whether the real model is installed, and how much of the +/// vault is embedded (#26), so the UI can say search is keyword-only for now. #[derive(Debug, Clone, Serialize)] pub struct VaultInfo { pub root: String, @@ -50,8 +40,7 @@ pub struct VaultInfo { pub notes_total: usize, } -/// One façade call over a fresh **read-path** vault (the fake embedder, no model load) — -/// the whole body of a model-free command. The call's own error is mapped once, here. +/// One façade call over a fresh read-path vault (the fake embedder, no model load). fn read_op( state: &AppState, op: impl FnOnce(&Vault) -> b2_core::Result, @@ -59,8 +48,7 @@ fn read_op( Ok(op(&open_read(state)?)?) } -/// [`read_op`] over the **real** model, for a command that embeds a query or re-projects -/// with the model the index was built with (fail-fast "run `b2 init`" when absent). +/// [`read_op`] over the real model, for a command that embeds. fn semantic_op( state: &AppState, op: impl FnOnce(&Vault) -> b2_core::Result, @@ -73,18 +61,12 @@ pub fn vault_info(state: State<'_, AppState>) -> Result { vault_info_impl(state.inner()) } -/// The in-app vault switcher: open a **native folder picker** and, if the user picks, -/// point the app at it and **remember** it for the next launch. Returns the new -/// [`VaultInfo`], or `None` when the user cancels. -/// -/// Host-owned by design: vault-root resolution is this crate's job and the picker is an OS -/// concern, so there is nothing here to push behind the façade — and running the dialog in -/// Rust keeps the webview dialog-permission-free. The `persist_last_vault` call lives in -/// this untestable wrapper, not in [`set_vault_root_impl`], so the unit-tested state -/// transition never writes to the real user data dir; it is best-effort. +/// The vault switcher: a native folder picker, then point the app at the pick and +/// remember it. `None` when the user cancels. `persist_last_vault` lives here, not in +/// [`set_vault_root_impl`], so tests never write the real data dir. /// -/// `(async)` is *required*: `blocking_pick_folder` waits on the main thread to show the -/// panel, so calling it from the main thread would deadlock. +/// `(async)` is required: `blocking_pick_folder` waits on the main thread, so calling it +/// from there would deadlock. #[tauri::command(async)] pub fn choose_vault( app: tauri::AppHandle, @@ -93,16 +75,13 @@ pub fn choose_vault( let Some(picked) = app.dialog().file().blocking_pick_folder() else { return Ok(None); // user cancelled }; - // On desktop a folder pick is always a real filesystem path (`Url` is a mobile - // content URI); if it somehow isn't, treat it as a cancel rather than error out. + // A `Url` pick is a mobile content URI; treat it as a cancel. let Ok(path) = picked.into_path() else { return Ok(None); }; let info = set_vault_root_impl(state.inner(), &path)?; - // Remember it for the next launch (best-effort). crate::persist_last_vault(&path); - // Re-point filesystem auto-reload at the new vault (#14): the old watch is dropped and a - // fresh one starts, so pulses now reflect the vault the app is on. + // Re-point filesystem auto-reload at the new vault (#14). app.state::().watch(&app, &path); Ok(Some(info)) } @@ -117,34 +96,28 @@ pub fn list_notes(state: State<'_, AppState>) -> Result, CmdErr list_notes_impl(state.inner()) } -/// The file tree's resource half (file-type slice 1, spec §6): every inventoried -/// non-`.md` file. The frontend merges this with `list_notes` into one tree — the -/// per-kind composition the locked design prefers over a union type (research §9b #10). +/// Every inventoried non-`.md` file, merged by the frontend with `list_notes` into the +/// tree (spec §6). #[tauri::command(async)] pub fn list_resources(state: State<'_, AppState>) -> Result, CmdError> { read_op(state.inner(), |v| v.list_resources()) } -/// The file tree's structure half: every folder in the vault, **empty ones -/// included**, read live off the filesystem (never the index) so the tree stays -/// one-to-one with disk. The frontend composes this with `list_notes` + -/// `list_resources` into one tree. +/// Every folder in the vault, empty ones included, read live off the filesystem so the +/// tree matches disk. #[tauri::command(async)] pub fn list_dirs(state: State<'_, AppState>) -> Result, CmdError> { list_dirs_impl(state.inner()) } -/// Create a folder — the file tree's New-folder action (⇧⌘N). A real on-disk -/// create, missing parents included, occupied targets refused (a folder is -/// user-authored vault structure, immediately visible to Finder/CLI/sync), -/// touching no index rows. Model-free and index-free. +/// Create a folder (⇧⌘N), missing parents included; an occupied target is refused. +/// Touches no index rows. #[tauri::command(async)] pub fn create_dir(state: State<'_, AppState>, dir: String) -> Result { create_dir_impl(state.inner(), &dir) } -/// The fallback card's data: a resource's inventory metadata + backlinks. A pure -/// graph/inventory read, model-free like `explain`. +/// The fallback card's data: a resource's inventory metadata and backlinks. #[tauri::command(async)] pub fn explain_resource( state: State<'_, AppState>, @@ -153,54 +126,30 @@ pub fn explain_resource( read_op(state.inner(), |v| v.explain_resource(&path)) } -/// A resource's **bytes**, base64, for the viewer the card shows in place of the -/// *No viewer available* fallback (an image, today). -/// -/// base64 because the IPC is JSON and these are arbitrary bytes — the same encoding -/// [`import_file`] takes in the other direction, and what the webview turns straight -/// into the `data:` URL the CSP already admits (`img-src 'self' data:`). It carries a -/// copy of the file, so the frontend asks only for what it will actually display: the -/// size is on the card's view before this is ever called. -/// -/// The path is re-validated host-side against the inventory (in the façade), because -/// the note that authored the link is untrusted input (ADR-0016) — the caller passes, -/// the host validates, exactly as [`open_resource`] does. +/// A resource's bytes as base64, for the card's viewer (an image, today); the webview +/// makes a `data:` URL of it, which the CSP admits. The façade re-validates the path +/// against the inventory, since the linking note is untrusted (ADR-0016). #[tauri::command(async)] pub fn read_resource(state: State<'_, AppState>, path: String) -> Result { read_op(state.inner(), |v| v.read_resource_bytes(&path)).map(|bytes| BASE64.encode(bytes)) } -/// *Open in system default* on the fallback card — an **OS handoff**, never -/// in-webview execution (spec §6 security posture). Host infrastructure like the -/// folder dialog: the webview holds no opener permission; this command validates -/// the path against the inventory (so only an indexed vault file can be opened) -/// and hands the absolute path to the OS. -/// -/// The vault resolves the path against its own root, so the check and the path handed -/// to the OS are the same vault's even if a switch lands in between. +/// *Open in system default*: an OS handoff, never in-webview execution (spec §6). Only an +/// inventoried vault file can be opened. #[tauri::command(async)] pub fn open_resource(state: State<'_, AppState>, path: String) -> Result<(), CmdError> { - // Inventory-checked by the façade: an unknown path refuses, never opens. let abs = read_op(state.inner(), |v| v.resource_path(&path))?; tauri_plugin_opener::open_path(abs, None::<&str>) .map_err(|e| CmdError::OpenFailed(e.to_string())) } -/// The three URL schemes B2 will hand to the OS, lowercase, each including the -/// punctuation that ends it. The frontend's `externalUrl` (`ui/src/links.ts`) holds the -/// same list because it is what *routes* a click here; this one is what **refuses**, and -/// it is the authority — change them together, the way `WRITE_CONFLICT_MESSAGE` and -/// `VAULT_CHANGED_EVENT` are changed on both sides of the seam. +/// The URL schemes B2 will hand to the OS. `ui/src/links.ts`'s `externalUrl` routes by the +/// same list, but this one is the authority; change them together. const OPENABLE_SCHEMES: [&str; 3] = ["http://", "https://", "mailto:"]; -/// Is this a link the host will open in the user's browser or mail app? -/// -/// A note is **untrusted input** (ADR-0016): its links are authored by whoever wrote the -/// file, and `open` on macOS launches whatever app has registered the scheme — so an -/// unfiltered handoff turns a `.md` into "run the thing this URL names". The allow-list is -/// the whole of the security posture here, and it is deliberately three schemes wide. -/// Byte-wise rather than `&url[..n]`, so a URL beginning mid-UTF-8 can't panic the slice; -/// control characters are refused outright. +/// Is this a link the host will open? A note is untrusted (ADR-0016) and `open` launches +/// whatever app claims the scheme, so this allow-list is the whole security posture. +/// Byte-wise, so a multi-byte prefix can't panic the slice. fn is_openable_link(url: &str) -> bool { if url.chars().any(|c| c.is_ascii_control()) { return false; @@ -212,15 +161,8 @@ fn is_openable_link(url: &str) -> bool { }) } -/// *Open a web link in the system default browser* — [`open_resource`]'s sibling for the -/// links **inside** a note. Following a `https://…` in place would replace the whole app -/// with a web page in a window that has no back button, no address bar and no way home; -/// the webview holds no opener permission, so the frontend routes the click here. Vault- -/// free, so it takes no `State`. -/// -/// The frontend has already decided this href is a web link; this re-checks, because the -/// frontend's copy of the rule is *routing* and the note that authored the href is not -/// trusted (ADR-0016 — the caller passes, the host validates). +/// Open a web link from a note in the system browser; followed in place it would replace +/// the app. Re-checked here because the note is untrusted (ADR-0016). #[tauri::command(async)] pub fn open_external(url: String) -> Result<(), CmdError> { if !is_openable_link(&url) { @@ -230,12 +172,8 @@ pub fn open_external(url: String) -> Result<(), CmdError> { .map_err(|e| CmdError::OpenFailed(e.to_string())) } -/// The clipboard's plain-text flavor — what the editor's ⌘⇧V pastes (paste as plain -/// text, the escape hatch from the rich paste in `ui/src/paste.ts`). Host infrastructure -/// like the folder dialog and [`open_resource`], and host-side *necessarily*: WebKit runs -/// no editing command for a raw ⌘⇧V, and it gates a programmatic `navigator.clipboard` -/// read behind a native confirmation — so the webview cannot do this itself. Touches no -/// vault; an empty or non-text clipboard reads as "". +/// The clipboard's plain text, for ⌘⇧V (`ui/src/paste.ts`). Host-side because WebKit runs +/// no paste for a raw ⌘⇧V and gates `navigator.clipboard` behind a native prompt. #[tauri::command(async)] pub fn clipboard_text(app: tauri::AppHandle) -> Result { use tauri_plugin_clipboard_manager::ClipboardExt; @@ -244,12 +182,9 @@ pub fn clipboard_text(app: tauri::AppHandle) -> Result { .map_err(|e| CmdError::ClipboardFailed(e.to_string())) } -/// Save a note's body — the editing surface's body write ([`write_frontmatter`] is its -/// frontmatter sibling). **Model-free** like `project`, so this opens the fake vault (no -/// model load; saving works with nothing provisioned) and runs **outside** the -/// single-in-flight embed slot. A stale `base_revision` surfaces as the **stable** conflict -/// message the frontend drives its conflict bar from — change it in `error.rs` and -/// `ui/src/api.ts` together. +/// Save a note's body. Model-free and outside the embed slot, so saving never waits on a +/// model. A stale `base_revision` surfaces as the stable conflict message the frontend +/// matches (`ui/src/api.ts`). #[tauri::command(async)] pub fn write_note( state: State<'_, AppState>, @@ -260,10 +195,7 @@ pub fn write_note( write_note_impl(state.inner(), ¬e, &body, &base_revision) } -/// Save a note's frontmatter — the drawer's write op (GH #79), `write_note`'s sibling with -/// the identical posture: model-free (an unchanged body keeps its vectors), outside the -/// embed slot, same `base_revision` conflict contract. The fence refusal lives behind -/// `Vault::write_frontmatter`, not here: B2 owns no line *inside* the block. +/// Save a note's frontmatter (GH #79), with `write_note`'s posture and conflict contract. #[tauri::command(async)] pub fn write_frontmatter( state: State<'_, AppState>, @@ -274,23 +206,16 @@ pub fn write_frontmatter( write_frontmatter_impl(state.inner(), ¬e, &frontmatter, &base_revision) } -/// Create a new, empty note — the file tree's New-note action (and ⌘N). **Model-free** -/// like `write_note`: it writes the file and projects it without touching vectors, so -/// creating works with no model provisioned and runs outside the embed slot (a fake-opened -/// vault must never write into a real-model embedding space). The chunks join the pending -/// set for the next embed pass. Missing parent folders are created; an *empty* folder is -/// [`create_dir`]'s job. +/// Create a new, empty note (⌘N). Model-free like `write_note`: its chunks wait for the +/// next embed pass, since a fake-opened vault must never write vectors. #[tauri::command(async)] pub fn create_note(state: State<'_, AppState>, path: String) -> Result { create_note_impl(state.inner(), &path) } -/// Import a file from **outside** the vault into the folder `dir` (`""` for the root) — -/// the tree's drop target and its Import files… action. `data` is the file's bytes, -/// base64-encoded: a file dropped on a webview arrives as *content*, not a path, and -/// Tauri's JSON IPC carries no byte array cheaply. Decoding is **transport**, not logic — -/// the façade op does the placing, the projecting, and every refusal. **Model-free**, so -/// it runs outside the embed slot and its chunks join the pending set. +/// Import a file from outside the vault into `dir` (`""` for the root): the tree's drop +/// target. `data` is base64, since a dropped file reaches the webview as bytes, not a +/// path. Model-free. #[tauri::command(async)] pub fn import_file( state: State<'_, AppState>, @@ -301,9 +226,7 @@ pub fn import_file( import_file_impl(state.inner(), &dir, &name, &data) } -/// [`import_file`] from a path instead of bytes — what the Import files… picker's -/// selections come back as. Same façade op, same posture; the bytes never round-trip -/// through the webview. +/// [`import_file`] from a path, for the Import files… picker. #[tauri::command(async)] pub fn import_path( state: State<'_, AppState>, @@ -313,11 +236,8 @@ pub fn import_path( import_path_impl(state.inner(), &dir, &source) } -/// The keyboard half of the drop gesture (K1): open a **native multi-select file picker** -/// and return the absolute paths for [`import_path`] to place; an empty list means the user -/// cancelled. Host-owned for `choose_vault`'s reason, and it deliberately imports -/// *nothing*: the choosing is the OS's job, the placing is the façade's. `(async)` is -/// required, as there. +/// The keyboard half of the drop gesture (K1): a native multi-select picker returning +/// paths for [`import_path`]; empty when cancelled. `(async)` as in `choose_vault`. #[tauri::command(async)] pub fn pick_import_files(app: tauri::AppHandle) -> Result, CmdError> { let Some(picked) = app.dialog().file().blocking_pick_files() else { @@ -325,16 +245,14 @@ pub fn pick_import_files(app: tauri::AppHandle) -> Result, CmdError> }; Ok(picked .into_iter() - // On desktop a file pick is always a real filesystem path (`Url` is a mobile - // content URI); anything else is dropped rather than surfaced as an error. + // A `Url` pick is a mobile content URI; drop it. .filter_map(|p| p.into_path().ok()) .map(|p| p.to_string_lossy().into_owned()) .collect()) } -/// Move/rename a note — the tree's Rename / Move… / drag-drop action. Opens the -/// **real** model (like `search`/`link`): rewriting an inbound file's link text -/// changes its body, and the re-projection re-embeds it inline. +/// Move/rename a note. Opens the real model: rewriting an inbound file's links changes +/// its body, which re-embeds inline. #[tauri::command(async)] pub fn move_note( state: State<'_, AppState>, @@ -344,7 +262,7 @@ pub fn move_note( move_note_impl(state.inner(), ¬e, &to) } -/// [`move_note`]'s resource sibling — same posture, same path-keyed identity (L3). +/// [`move_note`] for a resource (L3). #[tauri::command(async)] pub fn move_resource( state: State<'_, AppState>, @@ -354,8 +272,7 @@ pub fn move_resource( move_resource_impl(state.inner(), &path, &to) } -/// Move/rename a whole folder — one rename on disk (unindexed files travel too), -/// inbound links across and within the moved set rewritten, index re-projected. +/// Move/rename a whole folder: one rename on disk, with inbound links rewritten. #[tauri::command(async)] pub fn move_dir( state: State<'_, AppState>, @@ -365,16 +282,13 @@ pub fn move_dir( move_dir_impl(state.inner(), &from, &to) } -/// Delete a note — the tree's Delete action (context menu, ⌘⌫). **Model-free** -/// (the `create_note`/`write_note` posture): no body changes, so nothing -/// re-embeds — inbound links simply dangle, exactly as an external delete plus a -/// reindex would leave them. +/// Delete a note (⌘⌫). Model-free: inbound links dangle, as after an external delete. #[tauri::command(async)] pub fn delete_note(state: State<'_, AppState>, note: String) -> Result { delete_note_impl(state.inner(), ¬e) } -/// [`delete_note`]'s resource sibling — same posture, same path-keyed identity (L3). +/// [`delete_note`] for a resource (L3). #[tauri::command(async)] pub fn delete_resource( state: State<'_, AppState>, @@ -383,8 +297,7 @@ pub fn delete_resource( delete_resource_impl(state.inner(), &path) } -/// Delete a whole folder and everything inside it (one `remove_dir_all` — unindexed -/// files go too). The frontend confirms first; the host just executes. Model-free. +/// Delete a whole folder and everything in it. The frontend confirms first. Model-free. #[tauri::command(async)] pub fn delete_dir(state: State<'_, AppState>, dir: String) -> Result { delete_dir_impl(state.inner(), &dir) @@ -396,14 +309,11 @@ pub fn similar( note: String, limit: usize, ) -> Result, CmdError> { - // The ranked list is what the façade serves (GH #197) — no raw/floored mode - // to route, so the command is the one call it always should have been. read_op(state.inner(), |v| v.similar(¬e, limit)) } -/// **Explain** one Similar card (GH #236): the model-free Compare view's data, the GUI -/// sibling of `b2 similar --explain`. `limit` is the list length the side pane asked -/// `similar` for, so the rank is the card's. +/// The Compare view for one Similar card (GH #236), as `b2 similar --explain`. `limit` is +/// the pane's list length, so the rank is the card's. #[tauri::command(async)] pub fn explain_similar( state: State<'_, AppState>, @@ -416,19 +326,14 @@ pub fn explain_similar( }) } -/// Hybrid search **with its evidence reading** (invariants.md D2, GH #202) — the -/// GUI sibling of `b2 search`, which changed to the same façade op in the same -/// change. The rows are `search`'s, in `search`'s order; what rides beside them -/// is the query-level verdict, and deciding what to *show* on each of its three -/// states is the frontend's (`ui/src/main.ts`). The host returns the view whole: -/// an adapter that withheld rows would be an adapter holding the rule (E3). +/// Hybrid search with its evidence reading (D2, GH #202), as `b2 search`. Returned whole: +/// what to show for each verdict is the frontend's call (E3). #[tauri::command(async)] pub fn search( state: State<'_, AppState>, query: String, limit: usize, ) -> Result { - // Semantic: the query is embedded, so this opens the real model (fail-fast if absent). semantic_op(state.inner(), |v| v.search_evidence(&query, limit)) } @@ -445,34 +350,22 @@ pub fn link( relation: String, explanation: Option, ) -> Result { - // Re-projects the source note → opens the same real model the index was built with. + // Re-projects the source note, so it needs the real model. semantic_op(state.inner(), |v| { v.link(&src, &dst, &relation, explanation.as_deref()) }) } -/// The **projection pass** — the fast, model-free half of a reindex. One façade call over -/// the **fake** vault (no model load on the first-paint path), so the moment it returns the -/// tree repopulates and keyword search answers; `embed` then streams behind it. Nothing to -/// stream, nothing to cancel. -/// -/// Deliberately **outside** the single-in-flight reindex slot: the slot protects the long, -/// vector-writing embed pass, and a `project` racing a vault switch is harmless — it writes -/// the `.b2/` of the root it captured at dispatch, idempotently. +/// The projection pass, the fast model-free half of a reindex: once it returns, the tree +/// and keyword search work, and `embed` follows. Outside the reindex slot: racing a vault +/// switch is harmless, since it idempotently writes the root it captured. #[tauri::command(async)] pub fn project(state: State<'_, AppState>) -> Result { project_impl(state.inner()) } -/// The **embed pass** — fill the missing vectors as an observable, cancellable background -/// action. Tauri runs the `(async)` body on a worker thread, so the window stays live; -/// progress streams to the webview over a typed per-invocation [`Channel`], and the closure -/// returns `ControlFlow::Break` once the shared cancel flag is set — the one cancel -/// checkpoint the core exposes. -/// -/// Still a dumb adapter: "claim the slot -> open one vault -> call one façade op -> -/// serialize", with progress forwarded and a flag consulted. Task spawn/track/cancel and -/// IPC streaming are host infrastructure, not engine logic. +/// The embed pass: fill missing vectors in the background, streaming progress over a +/// [`Channel`] and stopping at the next batch once cancelled. #[tauri::command(async)] pub fn embed( state: State<'_, AppState>, @@ -481,95 +374,63 @@ pub fn embed( embed_impl(state.inner(), &on_event) } -/// Ask the in-flight embed to stop at its next batch boundary. Runs on a *different* -/// worker thread than `embed`, so it observes/sets the shared flag concurrently; the -/// embed closure sees it and breaks cooperatively — no thread-killing, no torn -/// writes. A no-op if nothing is running. +/// Ask the in-flight embed to stop at its next batch boundary. #[tauri::command(async)] pub fn cancel_reindex(state: State<'_, AppState>) { state.reindex.cancel(); } -/// The settings picker's model list: every model B2 offers ([`b2_embed::AVAILABLE_MODELS`]), -/// annotated with which is configured now and which are already downloaded. Global -/// (per-machine) config, not per-vault — like `b2 init`, so it needs no vault open. -/// -/// Thin like the rest: one `EmbedConfig` read → the shared `model_choices` view → -/// serialize. The registry and the current/installed logic live in `b2-embed`, not here. +/// The settings picker's model list, annotated with current and installed. Per-machine, +/// so it needs no vault open. #[tauri::command(async)] pub fn list_models() -> Result, CmdError> { Ok(EmbedConfig::load()?.model_choices()) } -/// Persist the chosen embedding model into the shared `config.toml` (the same file the -/// CLI reads), then return the refreshed list. Selecting a *different* model is a model -/// swap: it takes effect only after the model is provisioned (`b2 init`) and the vault -/// is reindexed — the UI surfaces that; this command just records the choice. Refuses an -/// id outside the registry (`EmbedError::UnknownModel`, mapped generic in `error.rs`). +/// Persist the chosen embedding model to the shared `config.toml` and return the refreshed +/// list. The swap takes effect after provisioning and a reindex. #[tauri::command(async)] pub fn set_model(model: String) -> Result, CmdError> { set_model_impl(&model) } -/// Provision (download + verify) the **currently-selected** model into the shared cache — -/// the in-app equivalent of `b2 init`, so a freshly-picked model installs without dropping -/// to a terminal. Idempotent and network-bound, hence `(async)`. Still thin: it drives -/// [`b2_embed::provision`] — exactly what `b2 init` runs — and reprojects the choices. +/// Provision the currently selected model: the in-app `b2 init`. #[tauri::command(async)] pub fn provision_model() -> Result, CmdError> { let config = EmbedConfig::load()?; - // Full progress detail to the server log (repo policy); the webview gets only the - // generic outcome. The line sink mirrors the CLI's `eprintln!` progress. + // Progress goes to the host log; the webview gets only the outcome. b2_embed::provision(&config, |line| eprintln!("[b2] init: {line}"))?; Ok(config.model_choices()) } -/// The shared cache directory where downloaded model files live (each model in its own -/// `/` subfolder) — shown in Settings so the user knows where the -/// (large) files are saved. Per-machine, config-resolved (`EmbedConfig::cache_dir`). +/// The shared model cache directory, shown in Settings. #[tauri::command(async)] pub fn models_dir() -> Result { Ok(EmbedConfig::load()?.cache_dir.display().to_string()) } -/// The compute device the real embedder runs on for THIS build — `"Metal"` on a -/// `--features metal` build with a working Apple-Silicon GPU, else `"CPU"` (GH #40). Global, -/// infallible embedder-wiring like [`list_models`] (no vault, no engine logic): it forwards -/// [`b2_embed::active_device_label`] straight through for the Settings badge. +/// The embedder's compute device, `"Metal"` or `"CPU"`, for the Settings badge (GH #40). #[tauri::command(async)] pub fn embed_device() -> &'static str { b2_embed::active_device_label() } -/// The per-model embedding-time ledger (`stats.rs`) — what the Settings pane renders so a -/// model swap can be judged on real speed. Infallible: no data / an unreadable ledger is -/// an empty list, never an error (the totals are diagnostic, never load-bearing). +/// The per-model embedding-time ledger (`stats.rs`); empty rather than an error. #[tauri::command(async)] pub fn embed_stats() -> Vec { crate::stats::read_all() } -/// Every chord the app's **menu bar** takes, in menu order (`menu.rs`, #119). The UI folds -/// these into its keyboard registry as reserved chords (ADR-0017), so the reference sheet -/// lists them and the conflict check can finally see the one set it was blind to — AppKit -/// dispatches a menu key equivalent before the webview receives the key at all. Static -/// data, so infallible and vault-free. +/// Every chord the menu bar takes (`menu.rs`, #119), reserved in the UI's keyboard +/// registry (ADR-0017). #[tauri::command] pub fn menu_chords() -> Vec { crate::menu::chords() } -/// Set the window's **page zoom** — the whole rendering scaled the way ⌘+ does in Safari, -/// which is what B2's ⌘= / ⌘- / ⌘0 mean (`ui/src/zoom.ts`). WebKit's `pageZoom`, and the -/// one thing here that is genuinely the host's: a webview cannot zoom itself, and scaling -/// with CSS would grow the text while the pixel-sized chrome around it stayed put. -/// -/// A pass-through by design. The ladder, its walls, the snapping of an off-ladder value -/// and the remembering of the choice are all the UI's, tested there in node — this crate -/// holds no rule about *which* sizes are allowed, exactly as it holds no rule about which -/// column widths are. `factor` is a scale, so it is only ever the positive, bounded number -/// `adoptZoom` produced; the guard here is the type, and anything stranger is a webview -/// the platform refuses to render, which is what the error variant is for. +/// Set the window's page zoom, as ⌘+ does in Safari (`ui/src/zoom.ts`). Host-side because +/// a webview can't zoom itself, and CSS scaling would leave the chrome behind. A +/// pass-through: which sizes are allowed is the UI's rule. #[tauri::command] pub fn set_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), CmdError> { window @@ -577,16 +438,9 @@ pub fn set_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), CmdErro .map_err(|e| CmdError::ZoomFailed(e.to_string())) } -/// **Flow ④ — one grounded answer**: condense -> retrieve -> assemble -> stream -> cite, -/// all of it behind `Vault::ask`. The host's whole contribution is the shape of the -/// *delivery*: Tauri runs the `(async)` body on a worker thread, tokens stream to the -/// webview over a typed per-invocation [`Channel`], and the resolved [`AnswerView`] is the -/// return — the same two facts `b2 ask --json` emits as a JSONL event stream. -/// -/// `history` is the **caller's**: session-only, held in the pane's own state and handed -/// back turn by turn. Nothing about a chat is stored anywhere. Opens the **real-model** -/// vault, since retrieval embeds the question — and degrades to BM25-only on an unembedded -/// vault, so chat keeps working. +/// Flow ④, one grounded answer behind `Vault::ask`: tokens stream over a [`Channel`] and +/// the [`AnswerView`] is the return. `history` is the pane's; nothing about a chat is +/// stored. Opens the real model, since retrieval embeds the question. #[tauri::command(async)] pub fn ask( state: State<'_, AppState>, @@ -595,28 +449,19 @@ pub fn ask( on_event: Channel, ) -> Result { let state = state.inner(); - // Pick the provider (`B2_LLM=fake` or the configured endpoint) and open the vault - // before claiming the answer slot: both are the wiring `ask_impl` is handed, which is - // what makes the streaming half — the guard, the sink, the cancel checkpoint — - // testable against `FakeLlm` and a fake-embedder vault with no Tauri runtime and no - // environment to set. + // Wired here and handed to `ask_impl`, so the streaming half is testable against + // `FakeLlm` without a Tauri runtime. let llm = crate::chat::provider(&state.chat_prefs()); let vault = open_semantic(state)?; ask_impl(state, &vault, llm.as_ref(), &question, &history, &|token| { - // A send error means the window navigated or closed; the stream is then - // pointless but not broken — the cancel flag is what actually stops it. + // A send error means the window closed; the cancel flag is what stops the stream. let _ = on_event.send(token.to_string()); }) } -/// **Why was this suggested?** — the chat answer behind a click on a *Similar & unlinked* -/// card: `Vault::why_similar` gathers B2's own discovery evidence for the pair and streams -/// a grounded, cited explanation. Delivered exactly as [`ask`] is — same channel, same -/// single answer slot, same `cancel_ask` — because to the pane it *is* a chat turn. -/// -/// `limit` is the list length the pane showed, so the rank the explanation quotes is the -/// card's own. Opens the **model-free** vault, as [`similar`] does: every read here is over -/// stored vectors, and nothing embeds a query. +/// **Why?** for a Similar card: a grounded, cited explanation from `Vault::why_similar`, +/// delivered as [`ask`] is (same slot, same `cancel_ask`). `limit` is the pane's list +/// length. Model-free: every read is over stored vectors. #[tauri::command(async)] pub fn why_similar( state: State<'_, AppState>, @@ -641,37 +486,23 @@ pub fn why_similar( ) } -/// Ask the streaming answer to stop at its next token — the chat pane's Esc. Runs on a -/// *different* worker thread than `ask`, so it sets the shared flag while that one reads -/// it; the token callback sees it and breaks cooperatively. The partial text is not -/// discarded: it comes back as an [`AnswerView`] marked `cancelled`, which is what lets the -/// pane render a stopped answer honestly rather than as a failure. A no-op if nothing is -/// streaming. +/// Ask the streaming answer to stop at its next token (Esc). The partial text comes back +/// as an [`AnswerView`] marked `cancelled`, not a failure. #[tauri::command(async)] pub fn cancel_ask(state: State<'_, AppState>) { state.ask.cancel(); } -/// What the chat surface needs to draw itself before a question is asked: the endpoint and -/// model in force, whether that is the Local or the Cloud configuration, and — when the -/// runtime is Ollama — the native inventory behind the setup card (the deliberately -/// Ollama-native onboarding corner: is the daemon up, what is installed, what would you -/// pull on a machine this size). -/// -/// Thin like `list_models`: provider wiring, not a vault op, so the one call is into -/// `b2-llm` and the rest is serialization. Infallible **by design** — "the daemon isn't -/// running" is the answer the card is asking for, not an error — and it never carries the -/// API key, only that one is configured. +/// What the chat surface needs before a question: the endpoint and model in force, Local +/// or Cloud, and for Ollama the setup card's inventory. Infallible by design: "the daemon +/// isn't running" is an answer, not an error. #[tauri::command(async)] pub fn chat_setup(state: State<'_, AppState>) -> ChatSetup { chat_setup_impl(state.inner()) } -/// Save the chat configuration and re-probe it — the Settings section's one write. -/// **Adapter state, never vault or index state**: the endpoint and model persist beside the -/// remembered vault, so a chat model swap costs no reindex (contrast ADR-0007). The key -/// goes to the platform's encrypted store instead (GH #176); `None` leaves whatever is in -/// force, so re-saving the endpoint doesn't silently clear a key the user just typed. +/// Save the chat configuration and re-probe it. The key goes to the Keychain (GH #176); +/// `None` leaves the key in force untouched. #[tauri::command(async)] pub fn set_chat_config( state: State<'_, AppState>, @@ -681,10 +512,8 @@ pub fn set_chat_config( max_tool_calls: Option, ) -> ChatSetup { { - // One save at a time. `(async)` means Tauri runs these off the main thread and - // does not serialize them, so two overlapping saves could interleave and leave - // `chat.json` holding the *older* endpoint while memory holds the newer. Scoped to - // the mutating part only — the probe below is a network round trip. + // One save at a time (Tauri doesn't serialize `(async)` commands). Not held over + // the probe below, a network round trip. let _saving = state.begin_chat_save(); let prefs = set_chat_config_impl( state.inner(), @@ -694,21 +523,15 @@ pub fn set_chat_config( max_tool_calls, &crate::keychain::Keychain, ); - // Persisting lives in the command wrapper, not in the state transition, so the - // unit-tested core never writes to the real user data dir (`choose_vault`'s split). - // The Keychain is the exception and has to be: whether the store took the key - // decides how long it lasts, and the state transition is what records that — so the - // store crosses in as a parameter, and tests pass an in-memory one. + // Persisted here so tests never write the real data dir. The Keychain is passed + // into the core instead, since whether it took the key is state the core records. crate::chat::persist_prefs(&prefs); } chat_setup_impl(state.inner()) } -/// The testable core of `set_chat_config`: normalize the three fields into [`ChatPrefs`] -/// and install them. Blank is "unset" for the endpoint and the model, so an emptied field -/// returns to the environment/default rather than configuring an unusable endpoint. The key -/// does not go through `clean`, and must not: blank is a *distinct* input there, and -/// `chat::apply_key` owns that three-state rule. +/// The testable core of `set_chat_config`. Blank is "unset" for the endpoint and model. +/// The key skips `clean`: blank means Remove there (`chat::apply_key`). fn set_chat_config_impl( state: &AppState, base_url: Option, @@ -734,9 +557,7 @@ fn set_chat_config_impl( prefs } -/// The testable core of `chat_setup`: the fake provider's own status when -/// `B2_LLM=fake` is in force (never overstate what answered — the CLI prints the -/// same note), else one probe of the configured endpoint. +/// The testable core of `chat_setup`: the fake's own status, or one probe. fn chat_setup_impl(state: &AppState) -> ChatSetup { let config = state.chat_prefs().config(); if b2_llm::fake_requested() { @@ -746,10 +567,7 @@ fn chat_setup_impl(state: &AppState) -> ChatSetup { } } -/// The testable core of `ask`, split from the Tauri `Channel` wrapper so the whole -/// streaming path — the single-in-flight guard, the token sink, the cancel checkpoint — -/// is exercised without a Tauri runtime. `sink` is the framing the host owns: every token -/// the seam delivers, in order, as it arrives. +/// The testable core of `ask`. `sink` receives every token, in order, as it arrives. fn ask_impl( state: &AppState, vault: &Vault, @@ -763,8 +581,7 @@ fn ask_impl( }) } -/// The testable core of `why_similar` — [`ask_impl`]'s sibling over the other streaming -/// façade op, sharing its delivery whole. +/// The testable core of `why_similar`. fn why_similar_impl( state: &AppState, vault: &Vault, @@ -779,17 +596,15 @@ fn why_similar_impl( }) } -/// The delivery every streamed answer shares: claim the answer slot and run `call` — the -/// one façade op — with a token callback that feeds `sink` and reads the slot's cancel at -/// every token. +/// Claim the answer slot and run `call` with a token callback that feeds `sink` and +/// checks for cancel at every token. fn stream_answer( state: &AppState, sink: &dyn Fn(&str), call: impl FnOnce(&mut dyn FnMut(&str) -> ControlFlow<()>) -> b2_core::Result, ) -> Result { - // Single-in-flight: two answers at once would share one cancel flag, so the second - // one's claim would quietly un-cancel the first. The pane already refuses a second - // turn while one is streaming, so this is the belt-and-suspenders half. + // Two answers at once would share one cancel flag, and the second claim would + // un-cancel the first. let _held = state.ask.try_claim().ok_or(CmdError::AskInFlight)?; Ok(call(&mut |token| { sink(token); @@ -797,41 +612,31 @@ fn stream_answer( })?) } -/// The testable core of `project`: one façade call over the fake vault (projection -/// is model-free by construction — it never touches the embedding space). +/// The testable core of `project`, over the fake vault. fn project_impl(state: &AppState) -> Result { read_op(state, |v| v.project(false)) } -/// The testable core of `embed`, split from the Tauri `State` wrapper. Claims the -/// reindex slot, opens the real-model vault, and streams progress while consulting the -/// slot's cancel at each batch. +/// The testable core of `embed`. fn embed_impl( state: &AppState, on_event: &Channel, ) -> Result { - // Single-in-flight: refuse a second embed rather than race two writers on one DB. - // The UI also disables the button, so this is rarely hit. + // Refuse a second embed rather than race two writers on one DB. let _held = state.reindex.try_claim().ok_or(CmdError::ReindexInFlight)?; - // Fills missing vectors → needs the real model. `model` is the id of the model this - // open actually loaded, and `None` only under `B2_EMBEDDER=fake` (dev/offline) — - // fake-embed time is never attributed to a real model. + // `model` is `None` under `B2_EMBEDDER=fake`, so fake time is never recorded. let (vault, model) = open_vault(state, true)?; - // Time the embed pass itself — the clock starts *after* the model load above, so the - // recorded total is embedding throughput, not one-time setup. `chunks_done` is - // cumulative, so its last value is this run's chunk count. + // The clock starts after the model load. `chunks_done` is cumulative. let start = std::time::Instant::now(); let mut chunks_this_run = 0u64; let report = vault.embed(&mut |p| { chunks_this_run = chunks_this_run.max(p.chunks_done as u64); - // Forward progress to the webview; a send error (the window navigated/closed) - // is not fatal to the index — keep embedding. + // A send error (window closed) doesn't stop the embed. let _ = on_event.send(p); state.reindex.flow() })?; - // Record the run's cost (best-effort). Skip when nothing embedded (an up-to-date - // vault) or under the fake embedder, so the ledger stays clean and correctly attributed. + // Skip an up-to-date vault or the fake embedder. if let Some(model) = model.filter(|_| chunks_this_run > 0) { crate::stats::record(&model, start.elapsed().as_millis() as u64, chunks_this_run); } @@ -842,11 +647,7 @@ fn embed_impl( fn vault_info_impl(state: &AppState) -> Result { let root = state.current_root().ok_or(CmdError::VaultRequired)?; - // Model-free read, on both halves — this sits on the first-paint path (#133). The - // vault opens with the fake purely to count embedding coverage (#26), and - // `semantic` is a file *probe* (`semantic_available`), not a model load: it stays - // "is a model installed", while `notes_embedded/total` is the precise fraction the - // UI flags keyword-only from. + // Model-free on both halves: this is the first-paint path (#133). let status = read_op(state, |v| v.embed_status())?; Ok(VaultInfo { root: root.display().to_string(), @@ -856,10 +657,8 @@ fn vault_info_impl(state: &AppState) -> Result { }) } -/// Set the active vault root and report the resulting [`VaultInfo`] — the testable core of -/// `choose_vault`, split off from the untestable OS dialog. **Cancels any in-flight reindex -/// first**, waiting for it to wind down before repointing the root, so a reindex can never -/// keep writing the vault the app has left. +/// The testable core of `choose_vault`. Cancels and waits out any in-flight reindex first, +/// so it can never keep writing the vault the app has left. fn set_vault_root_impl(state: &AppState, root: &Path) -> Result { state.reindex.cancel_and_wait(); state.set_root(root); @@ -950,14 +749,8 @@ fn delete_dir_impl(state: &AppState, dir: &str) -> Result Result, CmdError> { let previous = EmbedConfig::load().ok().map(|c| c.model); EmbedConfig::set_model(model)?; @@ -969,10 +762,8 @@ fn set_model_impl(model: &str) -> Result, CmdError> { #[cfg(test)] mod tests { - //! Thin command-layer tests: args resolve → the façade is called → a view comes - //! back (crates/b2-desktop/CLAUDE.md — "thinness *is* the test strategy"; the - //! façade's own suite covers behavior). Model-free: read-path commands open with - //! the fake, and setup reindexes with the fake directly, so no model is needed. + //! Thin command-layer tests: args resolve, the façade is called, a view comes back. + //! The façade's own suite covers behavior. Model-free throughout. use super::*; use crate::error::user_message; @@ -981,8 +772,7 @@ mod tests { use std::fs; use std::path::Path; - /// Copy the committed golden vault into `dst` (never mutate the repo fixtures), - /// then reindex it with the fake embedder so the read path has an index to resolve. + /// Copy the golden vault into `root` and reindex it with the fake embedder. fn golden_indexed(root: &Path) { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); copy_dir(&src, root); @@ -1037,8 +827,7 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_indexed(&root); - // An external `mkdir` (Finder, CLI) — no reindex follows, and none is - // needed: structure is read off the filesystem, so the tree can't go stale. + // An external `mkdir` with no reindex after it. fs::create_dir_all(root.join("projects/2026")).unwrap(); let state = AppState::new(Some(root)); @@ -1065,7 +854,6 @@ mod tests { assert_eq!(report.dir, "projects/2026"); assert!(root.join("projects/2026").is_dir()); - // Occupied → the actionable, no-internals refusal. let err = create_dir_impl(&state, "projects/2026").unwrap_err(); assert_eq!( user_message(&err), @@ -1078,7 +866,6 @@ mod tests { let state = AppState::new(None); let err = read_note_impl(&state, "anything").unwrap_err(); assert!(matches!(err, CmdError::VaultRequired)); - // …surfaced to the webview as an actionable, no-internals message. assert_eq!( user_message(&err), "No vault open. Launch B2 with a vault path, or set B2_VAULT_PATH to your vault folder." @@ -1087,15 +874,12 @@ mod tests { #[test] fn set_vault_root_switches_the_active_vault() { - // Start with no vault (the actionable-refusal state)… let state = AppState::new(None); assert!(matches!( list_notes_impl(&state).unwrap_err(), CmdError::VaultRequired )); - // …then point it at a real vault: the switch reports the new root, and every - // later command resolves against it (proves `set_root` takes effect downstream). let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_indexed(&root); @@ -1112,8 +896,6 @@ mod tests { #[test] fn switching_vaults_repoints_subsequent_reads() { - // Two distinct vaults; switching from one to the other must change what reads - // resolve — the whole point of a runtime-swappable root. let tmp = tempfile::TempDir::new().unwrap(); let first = tmp.path().join("first"); golden_indexed(&first); @@ -1126,12 +908,10 @@ mod tests { Vault::open(&second).unwrap().reindex().unwrap(); set_vault_root_impl(&state, &second).unwrap(); - // The first vault's note is gone from the newly-active vault… assert!(matches!( read_note_impl(&state, "concepts/memory").unwrap_err(), CmdError::Core(b2_core::Error::NoteNotFound(_)) )); - // …and the second vault's note resolves. let notes = list_notes_impl(&state).unwrap(); let paths: Vec<&str> = notes.iter().map(|n| n.path.as_str()).collect(); assert_eq!(paths, vec!["solo.md"]); @@ -1141,8 +921,6 @@ mod tests { fn vault_info_reports_embedding_coverage() { let tmp = tempfile::TempDir::new().unwrap(); - // A fully-indexed vault (setup reindexes with the fake) reads as M/M embedded — - // the "N/M embedded" honesty signal (#26) surfaced through the command layer. let full = tmp.path().join("full"); golden_indexed(&full); let state = AppState::new(Some(full)); @@ -1153,8 +931,6 @@ mod tests { "a fully-indexed vault reads as M/M embedded" ); - // A projected-but-unembedded vault reads as 0/M — the command surfaces the precise - // fraction model-free (it never loads the real model to answer vault_info). let projected = tmp.path().join("projected"); let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); copy_dir(&src, &projected); @@ -1170,9 +946,7 @@ mod tests { #[test] fn list_models_returns_the_registry() { - // Global config, no vault needed. Deterministic w.r.t. ambient config only in the - // ways asserted: the picker offers exactly the registry, by id (the current flag - // depends on the machine's config.toml and is covered by b2-embed's own tests). + // The current flag depends on the machine's config.toml, so it isn't asserted. let choices = list_models().unwrap(); assert_eq!(choices.len(), b2_embed::AVAILABLE_MODELS.len()); let ids: Vec<&str> = choices.iter().map(|c| c.id.as_str()).collect(); @@ -1183,16 +957,12 @@ mod tests { #[test] fn embed_device_reports_the_build_device() { - // Thin passthrough to b2_embed::active_device_label (own tests there). In the default - // (no `metal` feature) test build it is always "CPU"; a `--features metal` build would - // report "Metal". Either way it's one of the two labels the badge renders. assert!(matches!(embed_device(), "CPU" | "Metal")); } #[test] fn set_model_rejects_unknown_without_writing() { - // Validation happens before any filesystem write (b2-embed `write_model`), so this - // touches no real config file — it just proves the command refuses and stays generic. + // Validation precedes any write, so no real config file is touched. let err = set_model_impl("definitely/not-a-real-model").unwrap_err(); assert!(matches!( err, @@ -1203,7 +973,6 @@ mod tests { #[test] fn errors_stay_generic_and_leak_no_internals() { - // A missing note is actionable, and never exposes sqlite/io detail. let msg = user_message(&CmdError::Core(b2_core::Error::NoteNotFound( "x/y".to_string(), ))); @@ -1212,15 +981,10 @@ mod tests { } // --- The host's task-lifecycle bits ------------------------------------------- - // - // Thin host-infrastructure tests: the guard + cancel state machine and - // switch-cancels-first, all model-free (no reindex actually runs — the core's own - // suite covers the cancel *behavior*; here we prove the host's control bits). #[test] fn a_second_embed_is_refused_before_touching_the_model() { - // With the slot already held, `embed_impl` must refuse *before* opening the - // real-model vault — so this needs no model and can't hang on one. + // Refused before the real model is opened, so this needs no model. let state = AppState::new(None); let running = state.reindex.try_claim(); // stand in for a running embed assert!(running.is_some()); @@ -1240,7 +1004,6 @@ mod tests { golden_indexed(&root); let state = AppState::new(Some(root)); - // Save based on the read's revision; the returned revision chains the next. let note = read_note_impl(&state, "concepts/memory").unwrap(); let report = write_note_impl( &state, @@ -1263,12 +1026,11 @@ mod tests { golden_indexed(&root); let state = AppState::new(Some(root.clone())); - // Missing parent folders are created on the way, like `b2 add`. + // Missing parent folders are created, like `b2 add`. let report = create_note_impl(&state, "inbox/idea").unwrap(); assert_eq!(report.path, "inbox/idea.md"); assert!(root.join("inbox/idea.md").is_file()); - // Immediately in the tree and readable (projected, no model touched). let notes = list_notes_impl(&state).unwrap(); assert!(notes.iter().any(|n| n.path == "inbox/idea.md")); let note = read_note_impl(&state, "inbox/idea").unwrap(); @@ -1276,9 +1038,7 @@ mod tests { assert_eq!(note.path, report.path); } - /// The drop path end to end at this layer: base64 in → the façade places and - /// projects → the tree lists it. The payload is deliberately **not** UTF-8, since - /// the transport exists to carry a PNG as faithfully as a `.md`. + /// The payload is not UTF-8: the transport must carry a PNG faithfully. #[test] fn import_file_decodes_the_drop_payload_and_projects() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1297,8 +1057,6 @@ mod tests { assert!(listed.iter().any(|r| r.path == "resources/dropped.png")); } - /// A payload the frontend's encoder mangled is the transport's own failure, so it - /// never reaches the façade and never leaks the decoder's detail to the webview. #[test] fn import_file_refuses_a_payload_that_is_not_base64() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1315,7 +1073,6 @@ mod tests { ); } - /// The picker path: a chosen absolute path is copied in, source left alone. #[test] fn import_path_copies_the_picked_file_in() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1334,7 +1091,7 @@ mod tests { assert!(source.is_file(), "the picked file is copied, never moved"); } - /// The import refusals speak of a *file*, not a note: what arrived may be a PDF. + /// Import refusals say "file", not "note": what arrived may be a PDF. #[test] fn import_refusals_stay_generic_and_actionable() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1353,7 +1110,7 @@ mod tests { "A file already exists at 'concepts/memory.md'. Rename it, or drop this one into a different folder." ); - // A "name" that is really a path can't redirect the import out of the folder. + // A "name" that is really a path can't escape the folder. let err = import_file_impl(&state, "notes", "../escaped.png", &BASE64.encode("x")).unwrap_err(); assert!(matches!( @@ -1373,7 +1130,6 @@ mod tests { golden_indexed(&root); let state = AppState::new(Some(root)); - // Clobber refusal names the path and the way out. let err = create_note_impl(&state, "concepts/memory").unwrap_err(); assert!(matches!( err, @@ -1384,7 +1140,6 @@ mod tests { "A note already exists at 'concepts/memory.md'. Choose a different name, or open that note." ); - // An invalid destination is refused with actionable phrasing, no internals. let err = create_note_impl(&state, "../escape").unwrap_err(); assert!(matches!( err, @@ -1403,9 +1158,6 @@ mod tests { golden_indexed(&root); let state = AppState::new(Some(root)); - // Save a new block based on the read's revision; the returned revision chains - // the next save, and a fresh read round-trips the block verbatim with the body - // untouched. let note = read_note_impl(&state, "concepts/memory").unwrap(); let body_before = note.body.clone(); let new_fm = "tags: [edited]\n"; @@ -1420,10 +1172,8 @@ mod tests { assert_eq!(reread.revision, report.revision); } - /// The drawer's one refusal, and the shape of everything it no longer refuses. - /// A `---` line would end the block early and shift bytes into the *body*, which - /// is not this op's to change; every other edit saves, because since GH #170 B2 - /// owns no line inside the block (W3). + /// A `---` line would end the block early and shift bytes into the body; every other + /// edit saves, since B2 owns no line inside the block (W3, GH #170). #[test] fn write_frontmatter_refuses_only_the_fence_and_says_so_without_internals() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1431,7 +1181,6 @@ mod tests { golden_indexed(&root); let state = AppState::new(Some(root)); - // Wholesale replacement — the edit that used to be an identity refusal — saves. let note = read_note_impl(&state, "concepts/memory").unwrap(); let report = write_frontmatter_impl(&state, "concepts/memory", "tags: [x]\n", ¬e.revision) @@ -1464,7 +1213,7 @@ mod tests { let state = AppState::new(Some(root.clone())); let note = read_note_impl(&state, "concepts/memory").unwrap(); - // An external edit lands after the read… + // An external edit lands after the read. let abs = root.join("concepts/memory.md"); fs::write( &abs, @@ -1472,9 +1221,7 @@ mod tests { ) .unwrap(); - // …so the stale save is refused with the STABLE message the frontend - // string-matches to drive its conflict bar (crates/b2-desktop/CLAUDE.md) — keep - // this assertion in lockstep with ui/src/api.ts. + // The frontend string-matches this message to drive its conflict bar. let err = write_note_impl(&state, "concepts/memory", "mine", ¬e.revision).unwrap_err(); assert!(matches!( err, @@ -1483,10 +1230,8 @@ mod tests { let msg = "This note changed on disk since it was opened. Reload the note, then reapply your edit."; assert_eq!(user_message(&err), msg); - // …and the frontend's mirror constant (`WRITE_CONFLICT_MESSAGE`) carries the - // exact same bytes — the recognizer string-matches on it, so a drifted copy - // would silently demote every conflict to a generic error toast. This assert - // automates the "change them together" discipline instead of trusting it. + // A drifted `WRITE_CONFLICT_MESSAGE` would silently demote every conflict to a + // generic error toast. let api_ts = fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ui/src/api.ts"), ) @@ -1559,9 +1304,7 @@ mod tests { #[test] fn write_note_runs_outside_the_reindex_slot() { - // Like `project`, a save is deliberately unguarded by the embed slot - // (crates/b2-desktop/CLAUDE.md): short, model-free, must not queue behind a - // long-running background embed. + // A save must not queue behind a long background embed. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_indexed(&root); @@ -1585,31 +1328,26 @@ mod tests { #[test] fn project_is_model_free_and_runs_outside_the_reindex_slot() { - // A fresh (never-indexed) vault copy — no reindex in the setup, so this also - // proves `project` alone is what makes the tree listable. + // Never indexed, so `project` alone makes the tree listable. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); copy_dir(&src, &root); let state = AppState::new(Some(root)); - // Hold the slot (a stand-in for an in-flight embed): `project` is deliberately - // unguarded (index-engine.md) and must still run. + // A stand-in for an in-flight embed. let running = state.reindex.try_claim(); assert!(running.is_some()); let report = project_impl(&state).unwrap(); assert_eq!(report.indexed, 2); - // The tree is live off projection alone — no model, no vectors. let notes = list_notes_impl(&state).unwrap(); assert_eq!(notes.len(), 2); } #[test] fn project_skips_unreadable_files_and_still_reports() { - // A real vault holds the odd non-UTF-8 file; projecting it must skip that file - // and index the rest, not fail the whole pass (the "reindex fails on a large - // vault" bug). The skip flows through the report to the UI, no logic added here. + // A non-UTF-8 file is skipped and reported, not fatal to the pass. let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); @@ -1624,13 +1362,11 @@ mod tests { ); assert_eq!(report.skipped.len(), 1); assert_eq!(report.skipped[0].path, "bad.md"); - // The tree lists the good notes; the bad file is absent, not fatal. assert_eq!(list_notes_impl(&state).unwrap().len(), 2); } - /// `open_external`'s allow-list — the whole of its security posture, so it is pinned - /// on both sides. The refusals matter more than the acceptances: each one is a scheme - /// that would otherwise let a `.md` name a program for the OS to launch. + /// `open_external`'s allow-list, its whole security posture. Each refusal is a scheme + /// that would let a `.md` name a program for the OS to launch. #[test] fn only_web_links_are_openable() { for ok in [ @@ -1655,9 +1391,6 @@ mod tests { } } - /// The refusal reaches the webview as a generic sentence, with the note-authored URL - /// left server-side — the same "detail is logged, never sent" rule every other arm of - /// `user_message` follows. #[test] fn a_refused_link_says_so_without_echoing_the_url() { let err = CmdError::UnsupportedLink("x-b2-evil://run?secret=hunter2".into()); @@ -1676,14 +1409,11 @@ mod tests { fs::create_dir_all(&root).unwrap(); let state = AppState::new(Some(root.clone())); - // Simulate a reindex holding the slot. let held = state.reindex.try_claim().expect("the slot is free"); assert!(state.reindex.in_flight()); std::thread::scope(|s| { - // A stand-in reindex worker: spin until asked to cancel, then wind down — - // exactly what the real embed-loop closure does at a batch boundary, where - // returning drops the guard. + // A stand-in reindex worker that winds down once cancelled. s.spawn(|| { while !state.reindex.cancelled() { std::thread::sleep(std::time::Duration::from_millis(1)); @@ -1691,27 +1421,21 @@ mod tests { drop(held); }); - // Switching vaults must request cancel AND block until the worker released - // the slot, *before* it repoints the root and reports the new info. let info = set_vault_root_impl(&state, &root).unwrap(); assert_eq!(info.root, root.display().to_string()); - // If the switch returned, the in-flight run has already wound down. assert!(!state.reindex.in_flight()); }); } // --- chat (flow ④) -------------------------------------------------------------- // - // The host owns exactly one thing here: the **framing of the token stream** — every - // token, in order, as it arrives — plus the guard and cancel checkpoint around it. - // Everything the answer *is* belongs to `Vault::ask` and the engine suite. + // The host owns only the token stream's framing, its guard and its cancel checkpoint; + // the answer itself is the engine suite's. /// A vault whose passages are real, so the fake provider has something to cite. fn ask_state(tmp: &tempfile::TempDir) -> (AppState, Vault) { let root = tmp.path().join("vault"); golden_indexed(&root); - // The fake-embedder vault the read path uses everywhere in this suite: `ask`'s - // retrieval degrades to the same hybrid search the rest of the app runs on. let vault = Vault::open(&root).unwrap(); (AppState::new(Some(root)), vault) } @@ -1727,16 +1451,13 @@ mod tests { }) .unwrap(); - // The framing contract: the tokens the webview saw, concatenated, ARE the answer - // the command returned. A pane that renders the stream and then the final view - // must never see the two disagree. + // The streamed tokens, concatenated, are the returned answer. assert_eq!(streamed.borrow().concat(), answer.answer); assert!(!answer.cancelled); assert!( !answer.citations.is_empty(), "the fake cites every passage it was handed: {answer:?}" ); - // The slot is released on the way out, so the next turn can claim it. assert!(state.ask.try_claim().is_some()); } @@ -1746,8 +1467,7 @@ mod tests { let (state, vault) = ask_state(&tmp); let streamed = std::cell::RefCell::new(Vec::::new()); - // The pane's Esc, arriving after the first token — `cancel_ask` sets the same flag - // from another thread; setting it inside the sink is that race, made deterministic. + // Esc after the first token: the `cancel_ask` race, made deterministic. let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], &|t| { streamed.borrow_mut().push(t.to_string()); state.ask.cancel(); @@ -1767,8 +1487,7 @@ mod tests { fn a_second_answer_is_refused_while_one_is_streaming() { let tmp = tempfile::TempDir::new().unwrap(); let (state, vault) = ask_state(&tmp); - // Stand in for an answer already streaming (the real one holds the slot for the - // duration of its call). + // Stand in for an answer already streaming. let streaming = state.ask.try_claim(); assert!(streaming.is_some()); @@ -1784,8 +1503,7 @@ mod tests { fn why_similar_streams_like_an_answer_and_shares_its_slot_and_its_esc() { let tmp = tempfile::TempDir::new().unwrap(); let (state, vault) = ask_state(&tmp); - // Any two notes do: the façade explains a pair whether or not discovery would - // list it, and what it says is the engine suite's business (`tests/why.rs`). + // Any two notes do; what it says is `tests/why.rs`'s business. let notes = vault.list_notes().unwrap(); let (anchor, candidate) = (notes[0].path.as_str(), notes[1].path.as_str()); let streamed = std::cell::RefCell::new(Vec::::new()); @@ -1800,7 +1518,6 @@ mod tests { assert!(c.path == anchor || c.path == candidate, "{c:?}"); } - // The pane's Esc stops it at the next token, exactly as it stops an `ask`. streamed.borrow_mut().clear(); let stopped = why_similar_impl(&state, &vault, &FakeLlm, anchor, candidate, 10, &|t| { streamed.borrow_mut().push(t.to_string()); @@ -1810,7 +1527,6 @@ mod tests { assert!(stopped.cancelled); assert_eq!(streamed.borrow().len(), 1); - // One answer slot for both kinds of turn. let streaming = state.ask.try_claim(); assert!(streaming.is_some()); let err = @@ -1818,20 +1534,15 @@ mod tests { assert!(matches!(err, CmdError::AskInFlight)); } - /// A stale cancel must not kill the *next* answer: claiming the slot clears it once the - /// fresh turn owns it, which is the whole reason the flag is cleared on claim rather - /// than reset by whoever set it. #[test] fn a_stale_cancel_does_not_stop_the_next_answer() { let tmp = tempfile::TempDir::new().unwrap(); let (state, vault) = ask_state(&tmp); - state.ask.cancel(); // …from a turn that already ended + state.ask.cancel(); // from a turn that already ended let answer = ask_impl(&state, &vault, &FakeLlm, "memory", &[], &|_| {}).unwrap(); assert!(!answer.cancelled); } - /// The tool-call cap is saved by the same command, under the key's three-state rule: - /// a save that doesn't mention it (the setup card's model pick) must not reset it. #[test] fn the_tool_call_cap_is_set_kept_and_cleared_by_a_save() { let state = AppState::new(None); @@ -1849,16 +1560,11 @@ mod tests { default, "blank returns to the shared resolution" ); - // And the status the panel is drawn from reports the cap in force. save(Some("32")); let setup = b2_llm::ChatSetup::fake(&state.chat_prefs().config()); assert_eq!(setup.tool_calls.in_force, 32); } - /// Chat settings are adapter state: setting them changes what the next ask resolves, - /// and blank means "unset" (back to the environment/default) rather than an unusable - /// endpoint. The key is kept when a save doesn't mention it — re-saving the endpoint - /// must not silently sign you out of a cloud provider. #[test] fn chat_config_layers_over_the_shared_resolution() { let state = AppState::new(None); @@ -1876,8 +1582,7 @@ mod tests { assert_eq!(prefs.model.as_deref(), Some("qwen2.5")); assert_eq!(prefs.api_key.as_deref(), Some("sk-a-cloud-key")); - // Re-save with the key absent (the field the user didn't retype) and a blanked - // model: the key survives, the model falls back to the shared default. + // Key absent and model blanked: the key survives, the model falls back. set_chat_config_impl( &state, Some("http://localhost:1234/v1".into()), @@ -1892,9 +1597,7 @@ mod tests { assert_eq!(prefs.config().model, b2_llm::LlmConfig::from_env().model); } - /// The #176 behavior at the command boundary: a saved key goes into the store, so - /// the *next launch* — a fresh `AppState` reading from that same store — starts - /// with it already in force. + /// GH #176 at the command boundary. #[test] fn a_saved_key_is_there_at_the_next_launch() { let keys = MemoryStore::empty(); @@ -1913,11 +1616,6 @@ mod tests { assert_eq!(next_launch.api_key_source(), b2_llm::ApiKeySource::Stored); } - /// The key's third state, and the reason it has one: **blank clears**. Without - /// it a key set once could never be removed through Settings — and repointing - /// the endpoint would then send the first provider's bearer token to the - /// second, which is the privacy failure, not just the annoyance. Since #176 the - /// removal has to reach the store too, or the key would simply return. #[test] fn a_blanked_key_clears_it_everywhere() { let state = AppState::new(None); @@ -1935,7 +1633,7 @@ mod tests { Some("sk-a-cloud-key") ); - // Absent: untouched, so the key stands — in memory and in the store. + // Absent: untouched. set_chat_config_impl(&state, None, None, None, None, &keys); assert_eq!( state.chat_prefs().api_key.as_deref(), @@ -1943,9 +1641,7 @@ mod tests { ); assert_eq!(keys.peek().as_deref(), Some("sk-a-cloud-key")); - // Blank (what the UI's Remove sends): gone from both. `config()` then resolves - // the key from the environment alone — `B2_LLM_API_KEY` is the user's own - // configuration, and Settings never had the standing to clear that. + // Blank (the Remove button): gone from both. `B2_LLM_API_KEY` is beyond its reach. set_chat_config_impl(&state, None, None, Some(" ".into()), None, &keys); assert_eq!(state.chat_prefs().api_key, None); assert_eq!( @@ -1955,16 +1651,13 @@ mod tests { ); } - /// The status the setup card renders never carries the key — only where the key in - /// force came from. (Pinned in `b2-llm` too, on the view type itself; this is the - /// command boundary.) + /// Also pinned in `b2-llm` on the view type; this is the command boundary. #[test] fn the_chat_setup_view_never_carries_the_key() { let state = AppState::new(None); set_chat_config_impl( &state, - // `.invalid` is reserved (RFC 2606), so the probe fails immediately and this - // never meets a model server a developer happens to be running. + // `.invalid` is reserved (RFC 2606), so the probe fails at once. Some("http://b2-no-such-host.invalid:11434/v1".into()), Some("llama3.2".into()), Some("sk-live-must-not-cross".into()), @@ -1974,17 +1667,11 @@ mod tests { let setup = chat_setup_impl(&state); let json = serde_json::to_string(&setup).unwrap(); assert!(!json.contains("sk-live-must-not-cross"), "{json}"); - // *That* a key is configured still crosses; which source answered depends on - // whether the developer's own environment has one, so this asserts the part - // that holds either way. + // The source depends on the developer's env, so only assert it isn't "none". assert!(json.contains("\"api_key_source\":"), "{json}"); assert!(!json.contains("\"api_key_source\":\"none\""), "{json}"); } - /// A Keychain that refuses must not break the save. The key the user typed is in - /// force for this run, nothing was stored, and the configuration says `session` so - /// the Settings copy can be honest about how long that lasts (GH #176's fallback - /// path — the convenience degrades, chat does not). #[test] fn a_refused_store_leaves_the_key_in_force_for_the_session() { let state = AppState::new(None); diff --git a/crates/b2-desktop/src/error.rs b/crates/b2-desktop/src/error.rs index 0dbcf48..4ab11d5 100644 --- a/crates/b2-desktop/src/error.rs +++ b/crates/b2-desktop/src/error.rs @@ -1,71 +1,48 @@ -//! The host's error type + the generic, actionable, no-internals-leaked mapping to a -//! user-facing string — the desktop mirror of the CLI's `user_message`. -//! -//! [`CmdError`] **serializes to that string**, so a `#[tauri::command]` returning -//! `Result` hands the webview a safe message and never a sqlite/io/serde -//! internal. `B2_DEBUG` opts into the raw detail, exactly as the CLI does. +//! The host's error type and its generic, actionable user-facing message, the desktop +//! mirror of the CLI's `user_message`. [`CmdError`] serializes to that message, so the +//! webview never sees an internal. `B2_DEBUG` appends the raw detail. use b2_embed::{EmbedConfig, EmbedError}; use serde::{Serialize, Serializer}; -/// The host's error, composing the crates it drives. Kept internal; it is only ever -/// surfaced to the webview through [`user_message`] (via its [`Serialize`] impl). +/// The host's error, composing the crates it drives. Reaches the webview only through +/// [`user_message`]. #[derive(Debug, thiserror::Error)] pub enum CmdError { #[error(transparent)] Core(#[from] b2_core::Error), #[error(transparent)] Embed(#[from] EmbedError), - /// A command ran with no vault configured (no launch arg, no remembered pick, no - /// `$B2_VAULT_PATH`) — refuse rather than guess a directory, and tell the user how to - /// point B2 at one. + /// No vault configured (no launch arg, remembered pick or `$B2_VAULT_PATH`). #[error("no vault specified")] VaultRequired, - /// A `reindex` was requested while one was already running (single-in-flight). - /// The UI disables the button, so this is a belt-and- - /// suspenders refusal that reaches the webview only in a race. + /// A `reindex` while one is running; the UI prevents it, so only in a race. #[error("a reindex is already running")] ReindexInFlight, - /// An `ask` was requested while an answer was already streaming - /// ([`CmdError::ReindexInFlight`]'s chat sibling, GH #155). The pane refuses a - /// second turn while one is streaming, so this too reaches the webview only in a race. + /// An `ask` while an answer is streaming (GH #155); only in a race. #[error("an answer is already streaming")] AskInFlight, - /// The OS refused to open a resource in its default app (`open_resource`, the - /// fallback card's one action). The message is the opener plugin's detail — - /// logged in full server-side, generic to the webview like everything else. + /// The OS refused to open a resource in its default app (`open_resource`). #[error("open in system default failed: {0}")] OpenFailed(String), - /// A note asked B2 to open a link that isn't a web address (`open_external`). Not a - /// failure so much as a refusal: a note is untrusted content (E5), and handing an - /// arbitrary scheme to the OS launches whatever app claims it — so the host opens - /// `http`/`https`/`mailto` and nothing else. The offending URL rides along for the - /// server-side log only; it is note content, so it stays out of the message. + /// A note link that isn't `http`/`https`/`mailto` (`open_external`). Notes are + /// untrusted (E5), and an arbitrary scheme launches whatever app claims it. The URL is + /// for the log only. #[error("refused to open a non-web link: {0}")] UnsupportedLink(String), - /// The OS refused the clipboard read behind ⌘⇧V (`clipboard_text`). Same shape as - /// [`CmdError::OpenFailed`]: the plugin's detail, logged in full server-side, generic - /// to the webview. + /// The OS refused the clipboard read behind ⌘⇧V (`clipboard_text`). #[error("clipboard read failed: {0}")] ClipboardFailed(String), - /// The webview refused a page-zoom change (`set_zoom`, the ⌘= / ⌘- / ⌘0 family). - /// WebKit's `pageZoom` does not fail in practice, so this exists to keep the handler - /// honest rather than to describe something a user is likely to see — the same shape - /// as [`CmdError::OpenFailed`]: the platform's detail, logged in full server-side, - /// generic to the webview. + /// The webview refused a page-zoom change (`set_zoom`); not expected in practice. #[error("could not change the window size: {0}")] ZoomFailed(String), - /// `import_file` was handed a payload that isn't base64 — the drop transport's own - /// failure, before the façade ever sees bytes. Not something the user did: it means - /// the frontend's encoder produced something the host can't read, so the message - /// stays generic and the decoder's detail goes to the server log. + /// `import_file` got a payload that isn't base64: a transport fault, not the user's. #[error("import payload was not valid base64: {0}")] ImportPayload(String), } -/// Translate an internal error into a generic, actionable, user-facing message — -/// never leaking sqlite/io/serde internals. Mirrors the CLI's `user_message` so the -/// two adapters speak the same language; `B2_DEBUG` also appends the raw detail. +/// A generic, actionable message for `err`, mirroring the CLI's `user_message`. +/// `B2_DEBUG` appends the raw detail. pub fn user_message(err: &CmdError) -> String { let msg = match err { CmdError::Core(b2_core::Error::NoteNotFound(r)) => { @@ -93,8 +70,7 @@ pub fn user_message(err: &CmdError) -> String { "The embedding model failed to load. Try downloading it again, or pick a different model in Settings." .to_string() } - // `config.toml` didn't parse, or its `source` names a folder missing a model file: - // the fix is in that one file, so name it. + // The fix is in `config.toml`, so name it. CmdError::Embed(EmbedError::Config(_)) => format!( "B2 couldn't use its embedder settings. Check the [embedder] table in {}, then try again.", EmbedConfig::config_path() @@ -153,8 +129,7 @@ pub fn user_message(err: &CmdError) -> String { .to_string() } CmdError::Core(b2_core::Error::Frontmatter(d)) => { - // The detail is a domain message (never an internal): today either the - // fence refusal from `write_frontmatter` or `b2 link`'s flow-style refusal. + // The detail is a domain message, never an internal. format!("Can't save this frontmatter: {d}.") } CmdError::Core(b2_core::Error::ResourceNotFound(r)) => { @@ -189,16 +164,8 @@ pub fn user_message(err: &CmdError) -> String { CmdError::AskInFlight => { "B2 is still answering. Wait for it to finish, or press Esc to stop it.".to_string() } - // A failed *answer* call (GH #154/#155). The seam collapses the wire's typed - // failure to a message on the way through `b2-core` (by design — the core stays - // free of `b2-llm`'s types), so what's left to say is the CLI's own sentence for - // this case, minus its `(ollama list)` hint: a terminal command is no help to - // someone in a window, and Settings → Chat is where this adapter shows the same - // thing — the installed models, from the daemon itself. - // Not folded into the generic chat failure below: nothing is wrong with the - // server being reachable or the model being installed, so that advice would send - // someone to check the two things that are fine. The cap is an environment - // setting, named here because it is the only fix on this side of the wire. + // Not the generic chat failure below: the server and model are fine, and the cap + // is the only fix on this side of the wire (GH #154/#155). CmdError::Core(b2_core::Error::ToolCallLimit { limit }) => format!( "The chat model asked for more than {limit} tool calls in one reply, so B2 stopped it. Try again or pick another model in Settings → Chat. If this model really needs more, relaunch with {} set higher.", b2_llm::ENV_MAX_TOOL_CALLS @@ -207,16 +174,13 @@ pub fn user_message(err: &CmdError) -> String { "The model server couldn't answer. Check that it's running and that the model is installed, then try again." .to_string() } - // Everything else in the two composed crates is an internal (sqlite/io/serde/…) - // the webview must never see. Spelled out rather than `_` so adding a CmdError - // variant fails to compile here instead of silently degrading to the catch-all. + // Internals the webview must never see. Not `_`, so a new CmdError variant fails + // to compile here. CmdError::Core(_) | CmdError::Embed(EmbedError::Io(_)) => { "Something went wrong. Please check the vault and try again.".to_string() } }; if std::env::var_os("B2_DEBUG").is_some() { - // `Core`/`Embed` are `#[error(transparent)]`, so `err` displays as its source — - // one `to_string` covers every variant. let detail = err.to_string(); format!("{msg}\n(debug: {detail})") } else { @@ -224,22 +188,15 @@ pub fn user_message(err: &CmdError) -> String { } } -/// Log an error's **full internal detail** to stderr — the specifics the webview must -/// never see. Under `tauri dev` it lands in the terminal running the host, so a failing -/// command is diagnosable without a rebuild. Called from the one place every command error -/// crosses to the webview ([`CmdError`]'s `Serialize` impl), so logging stays uniform and -/// out of the dumb handlers. +/// Log an error's full internal detail to stderr, so a failure is diagnosable without +/// `B2_DEBUG`. Called from `Serialize`, the one place every command error crosses. fn log_internal(err: &CmdError) { - // `Core`/`Embed` are `#[error(transparent)]`, so `err` displays as its source — - // one `to_string` covers every variant. + // `Core`/`Embed` are transparent, so this displays the source for every variant. let detail = err.to_string(); eprintln!("[b2] command failed: {detail}"); } -/// Serialize the error **as its user-facing message** — the whole point of the type: -/// the webview receives a generic, actionable string, never an internal. This is also -/// the single boundary where an error reaches the client, so it is where the full -/// detail is logged server-side ([`log_internal`]) before the generic string goes out. +/// Serialize as the user-facing message, logging the full detail first. impl Serialize for CmdError { fn serialize(&self, serializer: S) -> Result { log_internal(self); @@ -251,10 +208,6 @@ impl Serialize for CmdError { mod tests { use super::*; - /// Serializing an error — the one path by which a command failure reaches the - /// webview — yields the **generic** user-facing string, never the internal detail. - /// (It also runs `log_internal`, which writes the full detail to stderr; the - /// harness captures that, so this exercises the server-log boundary too.) #[test] fn a_blown_tool_call_cap_names_the_limit_and_the_setting_that_raises_it() { let msg = user_message(&CmdError::Core(b2_core::Error::ToolCallLimit { limit: 64 })); @@ -268,8 +221,6 @@ mod tests { #[test] fn serializes_to_the_generic_message_and_hides_internals() { - // An unmapped Core error is the exact "Something went wrong" case the reindex - // bug hit — the client sees the generic message, not the io/utf-8 detail. let err = CmdError::Core(b2_core::Error::Io(std::io::Error::new( std::io::ErrorKind::InvalidData, "stream did not contain valid UTF-8", @@ -293,8 +244,6 @@ mod tests { } } - /// A config that won't parse used to read as "check the vault" — a vault it never - /// involved. It names the settings instead, and keeps the parser's detail internal. #[test] fn a_broken_embedder_config_names_the_settings_not_the_vault() { let msg = user_message(&CmdError::Embed(EmbedError::Config( @@ -307,8 +256,6 @@ mod tests { ); } - /// The move family maps to specific, actionable messages (not the catch-all), - /// mirroring the CLI's `user_message` arms. #[test] fn move_errors_map_to_actionable_messages() { let exists = CmdError::Core(b2_core::Error::MoveTargetExists("a/b.md".into())); diff --git a/crates/b2-desktop/src/keychain.rs b/crates/b2-desktop/src/keychain.rs index 2f16670..3169af1 100644 --- a/crates/b2-desktop/src/keychain.rs +++ b/crates/b2-desktop/src/keychain.rs @@ -1,72 +1,35 @@ -//! Where a cloud bearer token is remembered between launches — the macOS Keychain -//! (GH #176). Chat's Cloud configuration needs a bearer token, and for one release this -//! host held it in memory only, which read as a bug to anyone who restarted the app. +//! The cloud API key, remembered between launches in the macOS Keychain (GH #176), not a +//! plaintext file. The Keychain can refuse (locked, prompt declined), so every operation is +//! best-effort and reports what happened: a refused save degrades to session-only. //! -//! Three things decided the shape: -//! -//! * **The Keychain, not a file.** A plaintext JSON beside `chat.json` is readable by every -//! process running as the user, rides into backups and sync, and outlives an uninstall. -//! B2 ships macOS-only, so the platform-specific answer costs nothing in portability. -//! * **The framework, not `/usr/bin/security`.** Shelling out would put the token in the -//! `security` process's argv, and so in `ps` — the exact argument against a key in a CLI -//! flag. -//! * **It must never be able to break chat.** The Keychain can refuse — locked, the prompt -//! declined, no keychain at all — so every operation here is best-effort and *says* what -//! happened rather than raising: a refused save degrades to session-only, with the key -//! still in force and the Settings copy saying so. -//! -//! One item, not one per endpoint: a user repointing `base_url` overwrites the key rather -//! than accumulating a drawer of them, which also avoids the hazard `chat.rs`'s Remove -//! button exists to prevent. Note for `cargo tauri dev`: the item's ACL names the binary -//! that created it, so a rebuilt unsigned `b2-desktop` is a different application to the -//! Keychain and macOS will ask again. - -/// A place the host can keep one secret between launches. -/// -/// A trait for one concrete reason: **a unit test must not touch the developer's real -/// Keychain**, so `chat.rs`'s key resolution is exercised against `MemoryStore` below. -/// Host infrastructure, not one of the enumerated model seams (ADR-0005). +//! One item, not one per endpoint. Under `cargo tauri dev` a rebuilt unsigned binary is a +//! new application to the Keychain, so macOS asks again. + +/// A place the host can keep one secret between launches. A trait so unit tests never +/// touch the real Keychain; not one of the model seams (ADR-0005). pub trait KeyStore { - /// The remembered key, or `None` when there is none — *or* when the store - /// refused. The two are deliberately one answer: a caller whose fallback is - /// "no stored key" does the same thing either way, and the difference is - /// logged rather than branched on. + /// The remembered key, or `None` when there is none or the store refused (logged). fn load(&self) -> Option; - /// Remember `key`, replacing whatever was there. `false` means the store - /// refused, which is the caller's cue to keep the key **for this session - /// only** rather than to fail the save. + /// Remember `key`. `false` means the store refused: keep the key for this session. fn save(&self, key: &str) -> bool; - /// Forget the remembered key. `true` when the store no longer holds one — including - /// when it never did, which is the outcome this asks for and not a failure. - /// - /// The return value is load-bearing, and an earlier draft got it wrong by making - /// removal infallible. A refused delete leaves the item in the Keychain; if the caller - /// drops the key from memory anyway, Settings shows a keyless configuration and the - /// **next launch reads the credential straight back out**. So removal is - /// all-or-nothing: `false` means the caller must keep the key exactly as it was. + /// Forget the remembered key. `true` when the store no longer holds one, including + /// when it never did. On `false` the caller must keep the key as it was: a refused + /// delete leaves the item, and the next launch would read it back. fn clear(&self) -> bool; } -/// The macOS Keychain, and the only [`KeyStore`] the shipped app constructs. -/// -/// A unit struct: the item it addresses is a constant, so there is no state to -/// hold and no handle to keep open — which is also why `Debug` is safe to derive -/// here and pointedly *not* on [`MemoryStore`] below. +/// The macOS Keychain, the only [`KeyStore`] the shipped app constructs. Holds no key, so +/// `Debug` is safe here (unlike [`MemoryStore`]). #[derive(Debug)] pub struct Keychain; -/// The service half of the generic-password item's identity. The app's bundle -/// identifier (`tauri.conf.json`), so the item is unambiguously B2's in a -/// keychain full of other applications'. Both halves are `cfg`'d with the only -/// implementation that addresses an item — on any other platform they would name -/// nothing, and `-D warnings` is right to say so. +/// The item's service: the app's bundle identifier (`tauri.conf.json`). #[cfg(target_os = "macos")] pub const SERVICE: &str = "dev.b2.desktop"; -/// The account half. Named for what it is rather than for a user or an endpoint — -/// there is exactly one such item (see the module header). +/// The item's account; there is exactly one item. #[cfg(target_os = "macos")] pub const ACCOUNT: &str = "chat-api-key"; @@ -77,10 +40,8 @@ mod platform { delete_generic_password, get_generic_password, set_generic_password, }; - /// `errSecItemNotFound`, from Apple's `SecBase.h`. Spelled here rather than - /// pulled from `security-framework-sys` to keep this corner at one - /// dependency; the value is part of a stable public API and has been since - /// OS X 10.6. + /// `errSecItemNotFound` (`SecBase.h`), a stable value, spelled here to avoid a + /// dependency on `security-framework-sys`. const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; impl KeyStore for Keychain { @@ -89,15 +50,11 @@ mod platform { Ok(bytes) => match String::from_utf8(bytes) { Ok(key) => Some(key.trim().to_string()).filter(|k| !k.is_empty()), Err(_) => { - // Something else wrote a non-UTF-8 item under our name. - // Not ours to interpret, and not worth failing over. eprintln!("[b2] ignoring an unreadable API key in the Keychain"); None } }, - // No item is the overwhelmingly common case — every user who has - // never configured a cloud model — and it is silent, and it - // prompts for nothing. + // The common case (no cloud model configured): silent. Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => None, Err(e) => { eprintln!("[b2] could not read the API key from the Keychain: {e}"); @@ -130,11 +87,8 @@ mod platform { } } -/// Everywhere else there is no store, and the host behaves exactly as it did -/// before #176: the key lives for the session and `B2_LLM_API_KEY` is how one -/// persists. B2 ships macOS-only, so this is the compile-anywhere path rather -/// than a supported configuration — and it is the *same* path a refusing Keychain -/// takes, which is why it needs no separate handling upstream. +/// Elsewhere there is no store: the key lives for the session, as with a refusing +/// Keychain, so upstream needs no separate handling. #[cfg(not(target_os = "macos"))] mod platform { use super::{KeyStore, Keychain}; @@ -148,22 +102,17 @@ mod platform { false } - /// Trivially true: a store that never remembers anything is always - /// already in the state `clear` asks for. fn clear(&self) -> bool { true } } } -/// An in-memory [`KeyStore`] — the Keychain's test double, and the reason -/// [`KeyStore`] is a trait at all. Lives here rather than in `chat.rs` because -/// both that module's tests and `commands.rs`'s need it. +/// An in-memory [`KeyStore`], the Keychain's test double for `chat.rs` and `commands.rs`. #[cfg(test)] pub struct MemoryStore { key: std::sync::Mutex>, - /// Whether [`save`](KeyStore::save) refuses — the Keychain saying no, which - /// the host must degrade past rather than fail on. + /// Whether writes refuse, as a locked Keychain would. refuses: bool, } @@ -177,7 +126,7 @@ impl MemoryStore { } } - /// A store holding `key` already — a launch that finds a remembered key. + /// A store already holding `key`. pub fn holding(key: &str) -> Self { Self { key: std::sync::Mutex::new(Some(key.to_string())), @@ -185,8 +134,7 @@ impl MemoryStore { } } - /// A store that refuses every write: a locked Keychain, or a user who - /// declined the prompt. + /// A store that refuses every write. pub fn refusing() -> Self { Self { key: std::sync::Mutex::new(None), @@ -194,8 +142,7 @@ impl MemoryStore { } } - /// A store that already holds `key` *and* refuses to give it up — the - /// failed-removal case, which is the one that must never read as success. + /// A store holding `key` that refuses to give it up: the failed-removal case. pub fn refusing_holding(key: &str) -> Self { Self { key: std::sync::Mutex::new(Some(key.to_string())), @@ -203,7 +150,7 @@ impl MemoryStore { } } - /// What the store is holding — the assertion a test actually wants to make. + /// What the store is holding. pub fn peek(&self) -> Option { self.key.lock().expect("test store lock").clone() } @@ -225,9 +172,7 @@ impl KeyStore for MemoryStore { fn clear(&self) -> bool { let mut held = self.key.lock().expect("test store lock"); - // Nothing to remove succeeds even under refusal — the real Keychain - // answers `errSecItemNotFound` here, which `Keychain::clear` reads as - // "already in the asked-for state". + // Nothing to remove succeeds even under refusal, as `errSecItemNotFound` does. if held.is_none() { return true; } diff --git a/crates/b2-desktop/src/logging.rs b/crates/b2-desktop/src/logging.rs index 4ee4a09..1a5187d 100644 --- a/crates/b2-desktop/src/logging.rs +++ b/crates/b2-desktop/src/logging.rs @@ -1,26 +1,10 @@ -//! Opt-in structured debug logging for the desktop host — the **GUI mirror** of the CLI's -//! `init_logging`. Same knobs, same wire shape, so both adapters write into one reportable -//! JSONL dataset of the kernel's `tracing` events. +//! Opt-in structured debug logging for the desktop host, the GUI mirror of the CLI's +//! `init_logging`: same knobs (`B2_LOG`, `B2_DEBUG`, `B2_LOG_FILE` in append mode), same +//! JSONL shape. A relative `B2_LOG_FILE` resolves against the CWD, which under `make app` +//! is `crates/b2-desktop/`. //! -//! Installing the subscriber is legitimate **host** work: the core only *emits*; the -//! subscriber and its wall-clock live in the adapter, keeping `b2-core` clock-free. -//! -//! Knobs, as the CLI: stderr by default, `B2_LOG_FILE=` in **append** mode instead, -//! `B2_LOG` a tracing filter honored verbatim. With none of the three set, no subscriber is -//! installed. Relative `B2_LOG_FILE` paths resolve against the process CWD — under -//! `make app` that is `crates/b2-desktop/`, so prefer an absolute path. -//! -//! **The implied default is scoped, and both adapters scope it the same way:** with no -//! explicit `B2_LOG`, `B2_DEBUG`/`B2_LOG_FILE` imply **`b2=debug`**, never a bare `debug`. -//! This process embeds Tauri + wry + hyper + reqwest, all noisy emitters whose records have -//! a foreign shape that would pollute the dataset. Opt into the firehose explicitly. -//! -//! **One difference from the CLI, deliberate:** the CLI is short-lived and writes through a -//! plain `Mutex`. This app is long-lived and multi-threaded — the background embed -//! pass alone bursts `b2::sqlite` events off the UI thread — so blocking those threads on -//! file I/O would stutter the GUI and pollute the very throughput these logs measure. The -//! sink is a `tracing-appender` **non-blocking** writer, whose [`WorkerGuard`] flushes on -//! drop, so `main` must hold the returned guard for the whole run. +//! Unlike the CLI's `Mutex`, the sink is non-blocking, so file I/O never stalls the +//! GUI or embed threads. use std::fs::OpenOptions; use std::path::Path; @@ -28,17 +12,14 @@ use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::fmt::format::FmtSpan; use tracing_subscriber::EnvFilter; -/// Install the JSONL debug-logging subscriber if any of `B2_LOG` / `B2_DEBUG` / -/// `B2_LOG_FILE` is set; otherwise a no-op returning `None`. The returned -/// [`WorkerGuard`] owns the background writer thread's flush-on-drop — the caller -/// (`main`) must keep it alive for the process's lifetime, or buffered events are lost. +/// Install the JSONL subscriber if any of `B2_LOG` / `B2_DEBUG` / `B2_LOG_FILE` is set. +/// The caller must hold the returned [`WorkerGuard`] for the whole run, or buffered events +/// are lost. pub fn init_logging() -> Option { let log_file = std::env::var_os("B2_LOG_FILE"); let directive = match std::env::var("B2_LOG") { Ok(v) if !v.trim().is_empty() => v, - // Implied default scoped to B2's own targets (`b2::sqlite`/`vault`/`ingest`/ - // `search`/`llm`/`embed`) — the same scoping the CLI uses — so Tauri/wry/hyper - // tracing doesn't pollute the file (see the module doc). + // Scoped to B2's own targets, as in the CLI, so Tauri/wry/hyper records stay out. _ if std::env::var_os("B2_DEBUG").is_some() || log_file.is_some() => "b2=debug".to_string(), _ => return None, }; @@ -49,9 +30,6 @@ pub fn init_logging() -> Option { EnvFilter::new("debug") } }; - // Pick the sink, then wrap it non-blocking. Both arms yield the same `NonBlocking` - // writer type (the inner writer is moved onto the worker thread), so the builder - // chain below is written once regardless of stderr-vs-file. let (writer, guard) = match log_file { Some(path) => match OpenOptions::new() .create(true) @@ -69,10 +47,8 @@ pub fn init_logging() -> Option { }, None => tracing_appender::non_blocking(std::io::stderr()), }; - // Field-for-field identical to the CLI's builder so both adapters emit the same - // record shape (b2-core/tests/logging.rs pins this contract): flat event fields for - // `jq '.duration_us'`-style reporting, CLOSE span events so each façade-op span - // carries its measured duration, current-span name but no ancestor list, no ANSI. + // Identical to the CLI's builder so both emit one record shape + // (b2-core/tests/logging.rs). CLOSE span events carry each façade op's duration. tracing_subscriber::fmt() .json() .flatten_event(true) diff --git a/crates/b2-desktop/src/main.rs b/crates/b2-desktop/src/main.rs index 9fcb9e2..b9815a2 100644 --- a/crates/b2-desktop/src/main.rs +++ b/crates/b2-desktop/src/main.rs @@ -1,20 +1,9 @@ -//! `b2-desktop` — the Tauri host, B2's **second dumb adapter** over the -//! [`Vault`](b2_core::vault::Vault) façade and the GUI sibling of `b2-cli` (ADR-0012). It -//! holds **no engine logic**; the rules that keep it dumb live in this crate's `CLAUDE.md`. +//! `b2-desktop`: the Tauri host, a dumb adapter over the [`Vault`](b2_core::vault::Vault) +//! façade and the GUI sibling of `b2-cli` (ADR-0012). See this crate's `CLAUDE.md`. //! -//! Two things this file owns, both mirroring the CLI: -//! * **Vault root resolution** — an explicit launch arg, else the last vault the user -//! opened (persisted across launches), else `$B2_VAULT_PATH`. Seeded once into -//! [`AppState`] and thereafter swappable at runtime by the in-app picker, which also -//! remembers the pick. Every command opens a *fresh* vault from the current root, -//! exactly as the one-process-per-command CLI does. -//! * **Embedder wiring** — pure reads open with the deterministic fake; anything that -//! embeds a query or writes vectors opens the real model (`b2_embed::embedder_for`, -//! the rule the CLI uses too) and fails fast with "run `b2 init`" if absent. `project` opens the fake, so the first tree paint -//! never waits on a model load. `B2_EMBEDDER=fake` forces the fake everywhere. -//! -//! And one it hands off: the **menu bar** is declared in [`menu`] rather than inherited, so -//! its chords are B2's own data and the UI can list them (ADR-0017, #119). +//! This file owns vault root resolution (launch arg, else the last opened vault, else +//! `$B2_VAULT_PATH`; swappable at runtime by the picker) and embedder wiring, both as in the +//! CLI. Every command opens a fresh vault from the current root. // This binary is desktop-only (no mobile entry point), so a plain `main` suffices. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] @@ -40,42 +29,23 @@ use std::sync::Mutex; use tauri::{Emitter, Manager}; use watch::VaultWatcher; -/// The state file remembering the last opened vault (see [`state_file`]). +/// The state file remembering the last opened vault. const LAST_VAULT_FILE: &str = "last-vault"; -/// The host's shared state: the active vault root plus the background-reindex and chat -/// control bits. The root is resolved once at startup and swappable at runtime by the -/// picker, so it sits behind a [`Mutex`]; every command still opens its own short-lived -/// [`Vault`] over the *current* root, the faithful mirror of the CLI opening a fresh vault -/// per invocation. `None` means no vault is configured. -/// -/// The reindex and chat slots are host **infrastructure**, not engine logic: *how the -/// window drives and interrupts* a long façade op stays here, *what* it computes stays in -/// the core. Each is a [`Slot`]: single-in-flight, released on every exit path, cancelled -/// cooperatively from another thread. +/// The host's shared state: the active vault root (`None` when unconfigured), the reindex +/// and ask [`Slot`]s, and the chat preferences. pub struct AppState { root: Mutex>, - /// The vector-writing embed pass (the model-free `project` runs outside it by - /// design). Cancelled by `cancel_reindex` and by a vault switch; a running embed - /// reads it at each batch boundary. + /// The vector-writing embed pass (`project` runs outside it). Cancelled by + /// `cancel_reindex` and by a vault switch; checked at each batch boundary. pub reindex: Slot, - /// The streaming answer (`ask` and `why_similar` share it). Cancelled by - /// `cancel_ask` (the chat pane's Esc); the token callback reads it at every token, - /// and the seam reports a stop honestly as a cancelled — not failed — completion. + /// The streaming answer (`ask` and `why_similar` share it), cancelled by `cancel_ask`. pub ask: Slot, - /// The chat endpoint, model, and the key in force. Behind a `Mutex` for the - /// vault root's reason: Settings changes it at runtime and every later ask - /// resolves a fresh provider from it. **Never vault or index state** - /// (GH #151) — see `chat.rs`. + /// The chat endpoint, model and key in force; never vault or index state (GH #151). chat: Mutex, - /// Held for the whole of one Settings save, which the `chat` lock alone cannot cover. - /// - /// A save is a read-modify-write spanning the Keychain, the `chat` mutex and - /// `chat.json`, and `chat_prefs()` deliberately drops its lock between them — it must, - /// since a Keychain write can block on the OS access prompt and no ask should wait - /// behind that. So two overlapping saves can interleave, and the loser writes *its* - /// preferences after the winner installed the newer ones in memory. The window is small, - /// but the Keychain prompt is exactly what makes a save long enough to be caught. + /// Held for a whole Settings save. A save spans the Keychain, `chat` and `chat.json`, + /// and `chat` is unlocked between them (a Keychain prompt can block), so without this + /// two saves could interleave. chat_saving: Mutex<()>, } @@ -84,8 +54,7 @@ impl AppState { Self::with_chat(root, ChatPrefs::default()) } - /// [`new`](Self::new) with explicit chat preferences — what `main` builds - /// from the persisted file, and what the tests build without touching it. + /// [`new`](Self::new) with explicit chat preferences. pub fn with_chat(root: Option, chat: ChatPrefs) -> Self { Self { root: Mutex::new(root), @@ -96,63 +65,47 @@ impl AppState { } } - /// Claim the Settings save for its whole read-modify-write — see - /// [`chat_saving`](Self::chat_saving). Blocks rather than refusing: a save is - /// short and a user who pressed Save twice meant both, so the second must - /// *follow* the first rather than be dropped (contrast the `ask` slot, - /// where two answers at once is a genuine conflict). + /// Claim the Settings save ([`chat_saving`](Self::chat_saving)). Blocks rather than + /// refusing: a second Save should follow the first, not be dropped. pub fn begin_chat_save(&self) -> std::sync::MutexGuard<'_, ()> { lock_recover(&self.chat_saving) } - /// The chat preferences in force, cloned out so the lock is **not** held - /// while a command opens a network connection (the `current_root` rule). + /// The chat preferences in force, cloned so no lock is held over network I/O. pub fn chat_prefs(&self) -> ChatPrefs { lock_recover(&self.chat).clone() } - /// Replace the chat preferences (the Settings section's Save). Takes effect - /// for every subsequent ask, since each resolves a fresh provider. + /// Replace the chat preferences; every later ask resolves a fresh provider. pub fn set_chat_prefs(&self, prefs: ChatPrefs) { *lock_recover(&self.chat) = prefs; } - /// The current vault root, cloned out so the lock is **not** held while a command - /// opens a vault (which may load the model — slow). `None` when unconfigured. + /// The current vault root, cloned so no lock is held while a vault (and model) opens. pub fn current_root(&self) -> Option { self.lock_root().clone() } - /// Point the app at a new vault root (the vault switcher). Takes effect for every - /// subsequent command, since each opens a fresh vault over `current_root`. + /// Point the app at a new vault root (the vault switcher). pub fn set_root(&self, root: &Path) { *self.lock_root() = Some(root.to_path_buf()); } - /// The critical sections here are a single clone or store — neither can panic — - /// so the lock can never be poisoned; see [`lock_recover`]. + /// See [`lock_recover`]. fn lock_root(&self) -> std::sync::MutexGuard<'_, Option> { lock_recover(&self.root) } } -/// Lock a mutex, recovering the inner value rather than unwrapping if the lock is ever -/// poisoned (the no-panic rule). Every mutex in this host guards a critical section of -/// a single clone/store/drop — none can panic, so poisoning is effectively impossible, -/// but the rule holds regardless if a poison ever somehow occurs. +/// Lock a mutex, recovering the value if it is ever poisoned (the no-panic rule). Every +/// critical section here is a single clone, store or drop, so poisoning shouldn't happen. pub(crate) fn lock_recover(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } -/// Open a fresh vault over the configured root with the right embedder — the desktop -/// mirror of the CLI's `open_vault`, over the same `b2_embed::embedder_for` rule. -/// `needs_semantic` commands (`search`/`link`/`embed`) load the real model (fail-fast -/// "run `b2 init`" if absent); pure reads — and `project`, which never touches vectors — -/// use the fake. Errors with [`CmdError::VaultRequired`] if no vault is set. -/// -/// Returns the vault and, when the real model was loaded, its configured id — which is -/// what `embed` attributes its time to, read off the model actually loaded rather than a -/// second look at a config that may have changed since. +/// Open a fresh vault over the configured root with the right embedder, as the CLI's +/// `open_vault` does (`b2_embed::embedder_for`). Also returns the loaded model's id, which +/// `embed` attributes its time to. pub fn open_vault( state: &AppState, needs_semantic: bool, @@ -161,8 +114,8 @@ pub fn open_vault( open_vault_at(&root, needs_semantic) } -/// [`open_vault`] over an explicit root — for a command that needs the root itself as -/// well, so the two can't come from either side of a vault switch. +/// [`open_vault`] over an explicit root, so a command that also needs the root can't see +/// two sides of a vault switch. pub fn open_vault_at( root: &Path, needs_semantic: bool, @@ -179,28 +132,19 @@ pub fn open_vault_at( }) } -/// Read-path open: a fresh vault over the fake embedder (no model load), for commands -/// that never embed. [`open_vault`] with the model id discarded. +/// A fresh vault over the fake embedder, for commands that never embed. pub fn open_read(state: &AppState) -> Result { Ok(open_vault(state, false)?.0) } -/// Wants-the-real-model open: a fresh vault over the real embedder (fail-fast "run -/// `b2 init`" if absent), for commands that embed. [`open_vault`] with the id discarded. +/// A fresh vault over the real embedder, for commands that embed. pub fn open_semantic(state: &AppState) -> Result { Ok(open_vault(state, true)?.0) } -/// Whether the real (semantic) embedder is available right now — mirrors the CLI: false -/// under `B2_EMBEDDER=fake`, or if the model isn't provisioned. `vault_info` uses it to -/// tell the UI whether semantic ranking is live, so the app never overstates the fake. -/// -/// A **probe, never a load** (#133): `is_model_provisioned` is the repo's one "installed" -/// check, so this answers the question `load` would without parsing `config.json`, building -/// the tokenizer, or mmapping the weights — which matters because `vault_info` calls it on -/// every first paint and vault switch. What the probe trades away: a present-but-corrupt -/// model reads as `semantic: true` and fails at the first `search`/`reindex` instead, which -/// is already fail-fast and actionable. +/// Whether the real embedder is available (not under `B2_EMBEDDER=fake`, and provisioned), +/// so the UI never overstates the fake. A file probe, never a load (#133), since +/// `vault_info` calls it on every first paint; a corrupt model fails later, at `search`. pub fn semantic_available() -> bool { if b2_embed::fake_requested() { return false; @@ -208,14 +152,9 @@ pub fn semantic_available() -> bool { EmbedConfig::load().is_ok_and(|c| c.is_model_provisioned(&c.model)) } -/// Resolve the vault root once at startup. Precedence, most-explicit first: an explicit -/// **launch argument** (the CLI's positional); the **last vault the user opened** via the -/// picker; then `$B2_VAULT_PATH` (the CLI's `-C` / env). -/// -/// The remembered choice deliberately beats `$B2_VAULT_PATH`: on a GUI the picker is the -/// primary way you choose a vault, so the env var seeds the *first* run and a launch arg -/// remains the escape hatch for one session. A leading-`-` first arg is ignored, so a macOS -/// `-psn_…` Finder argument is never mistaken for a path. +/// Resolve the vault root once at startup: launch arg, then the last picked vault, then +/// `$B2_VAULT_PATH`. The picker beats the env var because on a GUI it is how you choose. A +/// leading-`-` arg is ignored, so macOS's `-psn_…` is never taken for a path. fn resolve_root() -> Option { let arg = std::env::args() .nth(1) @@ -225,9 +164,7 @@ fn resolve_root() -> Option { pick_root(arg, read_last_vault(), env) } -/// The pure precedence rule behind [`resolve_root`], split out so it is unit-testable -/// without touching process args, the environment, or the filesystem: launch arg, then -/// the remembered pick, then the env fallback. +/// The precedence rule behind [`resolve_root`]. fn pick_root( arg: Option, remembered: Option, @@ -236,87 +173,53 @@ fn pick_root( arg.or(remembered).or(env) } -/// The remembered vault root from the last picker choice, or `None` if there is none, -/// the file is unreadable/empty, **or the remembered directory no longer exists** (moved -/// or deleted). Falling through on a stale entry lets startup drop back to the env -/// default rather than opening a vault whose every command would then error. +/// The remembered vault root, or `None` if there is none or the directory no longer +/// exists, so startup falls back to the env default. fn read_last_vault() -> Option { read_last_vault_from(&state_file::path(LAST_VAULT_FILE)?) } -/// [`read_last_vault`] against an explicit file path — the testable core (a tempfile -/// stands in for the real state file). Strips only trailing newline(s), so a path is -/// preserved verbatim, and requires the target to be an existing directory. +/// [`read_last_vault`] against an explicit path. Strips only trailing newlines. fn read_last_vault_from(file: &Path) -> Option { let contents = std::fs::read_to_string(file).ok()?; let path = PathBuf::from(contents.trim_end_matches(['\n', '\r'])); path.is_dir().then_some(path) } -/// Remember `root` as the last opened vault so the next launch reopens it. **Best-effort -/// host state** ([`state_file::update`]): remembering must never fail the vault switch the -/// user just made. Called only from the `choose_vault` command wrapper (an explicit user -/// pick), never from the unit-tested state transition, so tests don't touch the real data -/// dir. +/// Remember `root` as the last opened vault. Best-effort; called only from `choose_vault`, +/// so tests never touch the real data dir. fn persist_last_vault(root: &Path) { state_file::update(LAST_VAULT_FILE, "remember the last vault", |file| { persist_last_vault_to(file, root) }); } -/// [`persist_last_vault`] against an explicit file path — the testable core. Creates the -/// parent dir if needed and writes the path as the file's sole line. +/// [`persist_last_vault`] against an explicit path. fn persist_last_vault_to(file: &Path, root: &Path) -> std::io::Result<()> { state_file::write(file, root.to_string_lossy().as_bytes()) } fn main() { - // Opt-in structured logging (B2_LOG/B2_DEBUG/B2_LOG_FILE), the GUI mirror of the - // CLI. Bind the guard for the whole run: it owns the non-blocking writer thread's - // flush-on-drop, and `.run()` below blocks until the app exits, so `_guard` lives - // exactly as long as the app does. `None` (no logging requested) is a plain no-op. + // Held for the whole run: dropping it flushes the log writer. let _guard = logging::init_logging(); - // The chat endpoint/model the user last chose, plus the API key out of the Keychain. - // Adapter state like the remembered vault, and best-effort throughout: nothing - // configured resolves to the same local default the CLI uses with no flags. A vault - // with no cloud model configured has no Keychain item, so the common launch asks for - // nothing and prompts for nothing. let state = AppState::with_chat(resolve_root(), chat::read_prefs(&keychain::Keychain)); let app = tauri::Builder::default() - // The menu bar, declared (#119). Without this call Tauri installs - // `Menu::default()`, whose dozen accelerators nothing in the app can enumerate - // — and AppKit dispatches them before the webview sees a key, so the keyboard - // registry can't observe them either. `menu::MENU` is that list, made data. + // Declared, not `Menu::default()`, so its chords are enumerable (#119). .menu(menu::build) - // ...and the handler for the lines in it that are B2's own rather than the - // platform's (the View menu's three sizes). The host forwards the chosen id and - // stops there: what a size *means* — the ladder, its ends, remembering it — is - // `ui/src/zoom.ts`'s, because this crate holds no logic. A predefined item never - // arrives here; those are handled natively, which is the point of them. + // B2's own items: forward the id; `ui/src/zoom.ts` decides what it means. .on_menu_event(|app, event| { let _ = app.emit(menu::MENU_COMMAND_EVENT, event.id().0.as_str()); }) - // The dialog plugin backs the native folder picker for `choose_vault`. It is - // driven host-side only; the webview gets no dialog permission (capabilities/ - // default.json), so it can never open a dialog itself. + // The dialog, opener and clipboard plugins are driven host-side only; the webview + // gets none of their permissions (capabilities/default.json). .plugin(tauri_plugin_dialog::init()) - // The opener plugin backs the fallback card's *Open in system default* - // (`open_resource`, file-type slice 1). Same posture: host-side only, the - // webview holds no opener permission. .plugin(tauri_plugin_opener::init()) - // The clipboard plugin backs the editor's ⌘⇧V (`clipboard_text`, paste as plain - // text). Same posture again: host-side only, no clipboard permission in the webview. .plugin(tauri_plugin_clipboard_manager::init()) .manage(state) - // Filesystem auto-reload (#14 / crates/b2-desktop/CLAUDE.md): its own managed state so the - // pure `AppState` machine stays free of an OS watch handle. Started below once the - // app handle exists, and re-pointed on a vault switch (`choose_vault`). + // Filesystem auto-reload (#14), re-pointed on a vault switch. .manage(VaultWatcher::default()) .setup(|app| { - // Watch the startup vault, if one resolved. Best-effort: `watch` swallows and - // logs a failure, so a platform without a watch backend still launches (the - // conflict bar remains the fallback). No vault configured → nothing to watch; - // the first `choose_vault` starts it. + // Watch the startup vault, if any; best-effort. if let Some(root) = app.state::().current_root() { app.state::().watch(app.handle(), &root); } @@ -370,8 +273,7 @@ fn main() { commands::set_chat_config, ]) .run(tauri::generate_context!()); - // The one failure left to report is the app itself not starting (no window, no - // webview). Say so and exit non-zero — the no-panic rule, even here. + // The app itself didn't start: say so and exit non-zero, without panicking. if let Err(e) = app { eprintln!("[b2] the desktop app could not start: {e}"); std::process::exit(1); @@ -380,10 +282,7 @@ fn main() { #[cfg(test)] mod tests { - //! Host-side vault-resolution tests: the pure precedence rule and the "last opened - //! vault" state file's persist/read round-trip. All hermetic — `pick_root` touches - //! nothing global, and the file helpers run against a tempdir, never the real data - //! dir (which only the `choose_vault` wrapper writes, in production). + //! Vault-resolution tests, hermetic: the file helpers run against a tempdir. use super::*; use std::path::PathBuf; @@ -394,8 +293,7 @@ mod tests { #[test] fn pick_root_precedence_arg_then_remembered_then_env() { - // (arg, remembered, env) → the chosen root. Arg wins outright; the remembered - // pick beats the env fallback; env is used only when nothing more explicit is set. + // (arg, remembered, env) → the chosen root. let cases = [ (p("/arg"), p("/mem"), p("/env"), p("/arg")), (None, p("/mem"), p("/env"), p("/mem")), @@ -419,7 +317,7 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let vault = tmp.path().join("my vault"); // a space proves we don't trim it std::fs::create_dir_all(&vault).unwrap(); - // The state file sits under a not-yet-created subdir — persist must `mkdir -p`. + // The parent dir doesn't exist yet. let file = tmp.path().join("state/b2/last-vault"); persist_last_vault_to(&file, &vault).unwrap(); @@ -430,7 +328,6 @@ mod tests { fn read_last_vault_ignores_a_stale_or_missing_directory() { let tmp = tempfile::TempDir::new().unwrap(); let file = tmp.path().join("last-vault"); - // A remembered vault that has since been deleted must not be reopened. std::fs::write(&file, tmp.path().join("gone").to_string_lossy().as_bytes()).unwrap(); assert_eq!(read_last_vault_from(&file), None); } @@ -438,9 +335,7 @@ mod tests { #[test] fn read_last_vault_ignores_missing_or_empty_file() { let tmp = tempfile::TempDir::new().unwrap(); - // No file at all. assert_eq!(read_last_vault_from(&tmp.path().join("absent")), None); - // An empty file (whitespace only) is not a path. let empty = tmp.path().join("empty"); std::fs::write(&empty, "\n").unwrap(); assert_eq!(read_last_vault_from(&empty), None); @@ -452,7 +347,7 @@ mod tests { let vault = tmp.path().join("vault"); std::fs::create_dir_all(&vault).unwrap(); let file = tmp.path().join("last-vault"); - // A trailing newline (as `persist` never writes, but a hand-edit might) is fine. + // `persist` never writes one, but a hand-edit might. std::fs::write(&file, format!("{}\n", vault.display())).unwrap(); assert_eq!(read_last_vault_from(&file), Some(vault)); } diff --git a/crates/b2-desktop/src/menu.rs b/crates/b2-desktop/src/menu.rs index 6c36f1b..653bccb 100644 --- a/crates/b2-desktop/src/menu.rs +++ b/crates/b2-desktop/src/menu.rs @@ -1,44 +1,21 @@ -//! The app's macOS menu bar — **declared**, not inherited (ADR-0017, #119). +//! The app's macOS menu bar, declared rather than inherited from `Menu::default()` +//! (ADR-0017, #119). AppKit dispatches menu chords before the webview sees the key, so K1 +//! needs them enumerable: [`MENU`] is read by [`build`] and by [`chords`] (the UI's +//! reference sheet and conflict check). //! -//! Tauri applies `Menu::default()` to an app that sets none, and a dozen chords ride in -//! with it. Those chords are live in the window and used to be invisible twice over: -//! nothing enumerates the default, and AppKit dispatches a menu key equivalent inside -//! `NSApplication.sendEvent` *before* the key window's responder chain, so they never reach -//! the webview's keydown handler either. Invariant K1 promises a keyboard path that is -//! *findable*, and you cannot document what you cannot enumerate. -//! -//! So the menu is B2's own data now. [`MENU`] is the whole of it, with exactly two readers: -//! [`build`], which is what the window gets, and [`chords`], which the `menu_chords` -//! command hands the UI for the reference sheet and the registry's conflict check. -//! -//! **The items stay predefined on purpose.** The Edit menu is load-bearing rather than -//! decorative — Cut/Copy/Paste work in the webview *because* the native items route the -//! standard selectors to it. The consequence is that B2 does not *choose* these -//! accelerators: muda assigns them and exposes no getter, so the `keys` column below -//! restates them. This is the one place to fix if a muda release moves one. -//! -//! **Two departures from `Menu::default()`**, neither touching a chord: its Window menu -//! repeats Close Window (⌘W), which already lives in File, and its Help menu is empty on -//! macOS. Neither survives here. +//! The native items stay predefined because they route Cut/Copy/Paste into the webview. +//! muda assigns their accelerators and exposes no getter, so the `keys` column restates +//! them; fix it here if a muda release moves one. use serde::Serialize; use tauri::menu::{AboutMetadata, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Runtime}; -/// The event the host emits when one of B2's **own** menu items is chosen — the View -/// menu's three zoom lines, today. The payload is the item's [`ItemSpec::id`], and -/// `ui/src/api.ts` carries the mirror of this string; change the two together. -/// -/// Why an event at all, rather than the host simply zooming. The *rule* — the ladder of -/// sizes, its walls, and remembering the choice — lives in `ui/src/zoom.ts`, because a -/// reading size is a viewing preference and this crate holds no logic (the one rule). -/// AppKit just happens to be where the keystroke lands, so the host's whole job is to -/// say which line was chosen and let the frontend decide what that means. +/// Emitted when one of B2's own items is chosen, with the item's [`ItemSpec::id`]. Must +/// match `ui/src/api.ts`. The zoom rule itself lives in `ui/src/zoom.ts`. pub const MENU_COMMAND_EVENT: &str = "menu-command"; -/// The native behavior an item delegates to — one variant per [`PredefinedMenuItem`] -/// constructor B2 uses. An enum rather than a function pointer so [`MENU`] stays a -/// plain, readable table that the tests below can walk. +/// The native behavior an item delegates to, one per [`PredefinedMenuItem`] constructor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Item { About, @@ -55,33 +32,24 @@ enum Item { SelectAll, Fullscreen, Minimize, - /// macOS's name for `maximize` — the label the platform itself uses. + /// macOS's name for `maximize`. Zoom, Separator, - /// **B2's own**, rather than a native behavior delegated to. The only kind of item - /// here that has no `PredefinedMenuItem` behind it: choosing it emits - /// [`MENU_COMMAND_EVENT`] carrying the row's id, and the frontend decides what it - /// means. Its accelerator is derived from the row's `keys` ([`muda_accelerator`]), - /// so — unlike every predefined row above, whose chord muda assigns and this table - /// merely restates — this is a chord B2 actually chooses. + /// B2's own item: emits [`MENU_COMMAND_EVENT`] with the row's id. Unlike predefined + /// rows, its accelerator is B2's choice, derived from `keys` ([`muda_accelerator`]). Command, } /// One line of the menu. #[derive(Debug, Clone, Copy)] struct ItemSpec { - /// Stable id, and the join key the UI mirrors this row by (`edit.copy`). - /// Deliberately *not* prefixed `menu.`: the registry already spells the - /// right-click menu's own commands that way (`menu.open`, `menu.item.next`). + /// Stable id the UI joins on (`edit.copy`). Not prefixed `menu.`, which the registry + /// uses for the right-click menu. id: &'static str, item: Item, - /// What the menu shows — and, since the reference sheet renders these verbatim, - /// what the keyboard reference calls the action. One string, both places. + /// Shown in both the menu and the keyboard reference. label: &'static str, - /// The chord macOS gives this item, spelled in the chord syntax of - /// `ui/src/bindings.ts` (which is CodeMirror's) so the UI can parse it with the - /// same parser it uses for B2's own chords. `None` for an item with no - /// accelerator — those are real menu items, but they are not keyboard surface. + /// The item's chord in `ui/src/bindings.ts`'s (CodeMirror's) syntax, or `None`. keys: Option<&'static str>, } @@ -101,9 +69,7 @@ const SEPARATOR: ItemSpec = ItemSpec { /// B2's menu bar, in the order it is drawn. const MENU: &[SectionSpec] = &[ - // The application menu. macOS draws this one from the bundle, and `Menu::default` - // passes the package name here — which is this same string (tauri.conf.json's - // `productName`). + // Matches tauri.conf.json's `productName`, as `Menu::default` would pass. SectionSpec { title: "B2", items: &[ @@ -151,8 +117,7 @@ const MENU: &[SectionSpec] = &[ keys: Some("Mod-w"), }], }, - // The load-bearing one: these route the platform's editing selectors into the - // webview, which is how copy and paste work at all inside the note editor. + // Routes the editing selectors into the webview: how copy and paste work at all. SectionSpec { title: "Edit", items: &[ @@ -195,13 +160,8 @@ const MENU: &[SectionSpec] = &[ }, ], }, - // The one section with items of B2's own. The three sizes are here rather than in - // `ui/src/bindings.ts` for the reason this module exists at all: a menu accelerator - // is dispatched before the key window's responder chain, so a chord spelled in both - // places is a chord the webview never receives. Since macOS expects Zoom In / Zoom - // Out / Actual Size to *be* in the View menu — with their chords printed beside them, - // which is where most people find them — the menu is the honest owner, and the - // registry stays out of these three keystrokes entirely. + // Zoom lives here, not in `ui/src/bindings.ts`: a menu chord never reaches the webview, + // and macOS expects these in the View menu. SectionSpec { title: "View", items: &[ @@ -251,11 +211,8 @@ const MENU: &[SectionSpec] = &[ }, ]; -/// One menu item that carries a chord — the host's half of the app's keyboard -/// contract, serialized to the UI by the `menu_chords` command. -/// -/// Borrowed rather than owned because [`MENU`] is static: there is nothing to build, -/// only something to hand over. +/// One menu item that carries a chord, sent to the UI by `menu_chords`. Borrowed because +/// [`MENU`] is static. #[derive(Debug, Clone, Copy, Serialize)] pub struct MenuChord { pub id: &'static str, @@ -264,11 +221,7 @@ pub struct MenuChord { pub keys: &'static str, } -/// Every chord the menu bar takes, in menu order. -/// -/// Items with no accelerator are skipped: this is the keyboard surface, not an -/// inventory of the menu. `ui/src/menukeys.ts` mirrors the result — see the pin in -/// this module's tests. +/// Every chord the menu bar takes, in menu order. Mirrored by `ui/src/menukeys.ts`. pub fn chords() -> Vec { MENU.iter() .flat_map(|section| section.items) @@ -282,23 +235,12 @@ pub fn chords() -> Vec { .collect() } -/// One chord, translated from the registry's spelling into the one Tauri's accelerator -/// parser reads (`Mod-Shift-z` → `CmdOrCtrl+Shift+z`). -/// -/// **Derived rather than written down**, and that is the whole point of the function: an -/// [`Item::Command`] row would otherwise carry the same chord twice — once for the UI to -/// mirror and once for muda to bind — with nothing but care keeping them equal. Tauri -/// takes the accelerator as a string and *silently drops one it can't parse* -/// (`.parse().ok()`), so the failure mode of a drifted second spelling is not an error -/// but a menu item that quietly has no shortcut. One source, no drift, no silence. -/// -/// The split is CodeMirror's own rule, `-(?!$)`, for the reason `parseChord` gives: `-` -/// is both the separator and a key you can press, so `Mod--` is ⌘ plus the hyphen. +/// A registry chord in Tauri's accelerator syntax (`Mod-Shift-z` → `CmdOrCtrl+Shift+z`). +/// Derived so a command row has one spelling: Tauri silently drops an accelerator it can't +/// parse. Splits on CodeMirror's `-(?!$)`, since `Mod--` is ⌘ plus the hyphen. fn muda_accelerator(chord: &str) -> String { let mut parts: Vec<&str> = Vec::new(); let mut rest = chord; - // Cut at every `-` that isn't the last character; what's left when none remains is - // the key. `split` can't express "not at the end", so this walks it. while let Some(i) = rest[..rest.len().saturating_sub(1)].find('-') { parts.push(&rest[..i]); rest = &rest[i + 1..]; @@ -326,19 +268,14 @@ pub fn build(app: &AppHandle) -> tauri::Result> { Ok(menu) } -/// One [`ItemSpec`] as the native item it becomes — predefined for everything the -/// platform already does, and B2's own for [`Item::Command`]. -/// -/// Boxed because those are two unrelated types and a submenu takes `&dyn IsMenuItem`; -/// it is one allocation per row, once, at launch. +/// One [`ItemSpec`] as its native item: predefined, or B2's own for [`Item::Command`]. fn item_for( app: &AppHandle, spec: &ItemSpec, about: &AboutMetadata<'static>, ) -> tauri::Result>> { if spec.item == Item::Command { - // `spec.id` is the payload the frontend switches on, so the item's menu id and - // the row's id are the same string by construction. + // The menu id is the payload the frontend switches on. let accel = spec.keys.map(muda_accelerator); let item = MenuItem::with_id(app, spec.id, spec.label, true, accel)?; return Ok(Box::new(item)); @@ -346,12 +283,8 @@ fn item_for( Ok(Box::new(predefined(app, spec, about)?)) } -/// The About panel's contents, from the same sources `Menu::default` reads: the -/// package info and the bundle config. -/// -/// `'static` because the only borrowed field is the panel's `icon`, which B2 leaves -/// unset — eliding it here would tie the metadata to the handle it was read from for -/// no reason. +/// The About panel's contents, from the same sources `Menu::default` reads. `'static` +/// because the only borrowed field, `icon`, is unset. fn about_metadata(app: &AppHandle) -> AboutMetadata<'static> { let pkg = app.package_info(); let bundle = &app.config().bundle; @@ -364,9 +297,7 @@ fn about_metadata(app: &AppHandle) -> AboutMetadata<'static> { } } -/// One [`ItemSpec`] as the native item it delegates to. Every item passes its own -/// `label`, so what the menu shows and what the keyboard reference prints are the -/// same string rather than two that agree today. +/// One [`ItemSpec`] as the predefined item it delegates to, with its own `label`. fn predefined( app: &AppHandle, spec: &ItemSpec, @@ -390,19 +321,15 @@ fn predefined( Item::Minimize => PredefinedMenuItem::minimize(app, text), Item::Zoom => PredefinedMenuItem::maximize(app, text), Item::Separator => PredefinedMenuItem::separator(app), - // Unreachable: `item_for` takes this branch before calling here. Handled rather - // than `unreachable!()` — a panic in the menu builder is a window that never - // opens, and a separator is the harmless thing to draw if the two ever disagree. + // Unreachable (`item_for` handles it); a panic here would mean no window. Item::Command => PredefinedMenuItem::separator(app), } } #[cfg(test)] mod tests { - //! The menu as *data*. [`build`] needs a running app and is left to the app to - //! exercise; the table it reads is what has to hold together, and every check here - //! is a claim the UI relies on — a chord it can parse, an id it can join by, and a - //! list that matches its mirror. + //! The menu as data: [`build`] needs a running app, so these check the table the UI + //! relies on. use super::*; use std::collections::HashSet; @@ -412,24 +339,18 @@ mod tests { MENU.iter().flat_map(|section| section.items) } - /// Is this spelled the way `ui/src/bindings.ts`'s `parseChord` reads a chord? - /// - /// A deliberately small check, not a second parser: it exists to catch a chord - /// written in the *platform's* spelling (`CmdOrCtrl+C`, which is what Tauri's own - /// accelerator syntax would want) leaking into a table the UI parses with - /// CodeMirror's. `menukeys.test.ts` runs the real parser over the mirror. + /// Is this spelled the way `ui/src/bindings.ts`'s `parseChord` reads a chord? A small + /// check against Tauri's spelling (`CmdOrCtrl+C`) leaking in; `menukeys.test.ts` runs + /// the real parser. fn is_registry_chord(spec: &str) -> bool { - // The same `-(?!$)` cut `muda_accelerator` makes, and for the same reason: `-` is - // both the separator and a key, so ⌘- is spelled `Mod--`. + // The same `-(?!$)` cut as `muda_accelerator`. let mut parts: Vec<&str> = Vec::new(); let mut key = spec; while let Some(i) = key[..key.len().saturating_sub(1)].find('-') { parts.push(&key[..i]); key = &key[i + 1..]; } - // One character, and never an uppercase one — `parseChord` lowercases what it - // reads, so an uppercase key here is a chord that parses to something else. - // Symbols are allowed: `=` and `-` are the View menu's. + // One character, never uppercase: `parseChord` lowercases what it reads. let key_ok = key.len() == 1 && !key.chars().any(|c| c.is_ascii_uppercase()); key_ok && parts @@ -439,9 +360,6 @@ mod tests { #[test] fn every_item_has_a_unique_id_and_a_label() { - // The id is what the UI's mirror joins on, so a duplicate would make one of the - // two rows unaddressable; the label is what both the menu and the keyboard - // reference print, so an empty one is a blank row in the sheet. let mut seen = HashSet::new(); for spec in all_items() { if spec.item == Item::Separator { @@ -466,10 +384,7 @@ mod tests { #[test] fn no_two_items_answer_to_the_same_chord() { - // The sheet lists one action per chord, so a menu that binds ⌘W twice — which - // `Menu::default` does, with Close Window in both File and Window — would print - // two rows the reader can't choose between. Dropping that duplicate is one of - // this module's two departures from the default. + // `Menu::default` binds ⌘W in both File and Window; B2 drops the duplicate. let mut seen = HashSet::new(); for c in chords() { assert!( @@ -491,32 +406,24 @@ mod tests { c.keys ); } - // And the guard has teeth: the spelling this is here to keep out. assert!(!is_registry_chord("CmdOrCtrl+C")); assert!(!is_registry_chord("Mod-Meh-c")); assert!(!is_registry_chord("Mod-C")); - // ...and it accepts the two symbol keys the View menu is spelled with. assert!(is_registry_chord("Mod--")); assert!(is_registry_chord("Mod-=")); } #[test] fn a_command_item_carries_a_chord_muda_can_actually_parse() { - // The one place B2 *chooses* an accelerator rather than restating one macOS - // assigned — and Tauri drops an unparseable accelerator silently - // (`.parse().ok()`), so a wrong spelling here is a menu line with no shortcut and - // no complaint. `muda_accelerator` is the single source; this pins what it emits. + // Tauri drops an unparseable accelerator silently, so pin what this emits. assert_eq!(muda_accelerator("Mod-="), "CmdOrCtrl+="); assert_eq!(muda_accelerator("Mod--"), "CmdOrCtrl+-"); assert_eq!(muda_accelerator("Mod-0"), "CmdOrCtrl+0"); assert_eq!(muda_accelerator("Mod-Shift-z"), "CmdOrCtrl+Shift+z"); assert_eq!(muda_accelerator("Mod-Ctrl-f"), "CmdOrCtrl+Ctrl+f"); - // A bare key keeps its lone self rather than becoming an empty modifier. assert_eq!(muda_accelerator("-"), "-"); assert_eq!(muda_accelerator("f"), "f"); - // And every command row in the real table survives the trip: modifiers muda - // knows, one key left over, nothing empty. for spec in all_items().filter(|s| s.item == Item::Command) { let keys = spec.keys.unwrap_or_else(|| panic!("{}: no chord", spec.id)); let accel = muda_accelerator(keys); @@ -535,10 +442,6 @@ mod tests { #[test] fn a_command_item_is_addressable_and_every_other_item_is_not() { - // The event payload is the row's id, so a command row without one is a menu line - // the frontend cannot act on. The converse matters just as much: a predefined row - // must stay predefined, because those are what route the platform's editing - // selectors into the webview (copy and paste work *because* of them). for spec in all_items().filter(|s| s.item == Item::Command) { assert!(!spec.id.is_empty(), "a command item with no id"); assert!( @@ -551,12 +454,8 @@ mod tests { #[test] fn the_exported_chords_are_what_the_ui_mirrors() { - // `ui/src/menukeys.ts` carries this same list — the UI's only offline knowledge of - // what the menu takes, and what its conflict gate reads. **Change the two - // together**: the app compares them at startup (`menuDrift`) and reports a - // mismatch, the same posture as `WRITE_CONFLICT_MESSAGE`. A row moving in or out is - // a change to what the app reserves from its own keyboard, which is exactly what - // used to happen invisibly — hence a pin rather than a count. + // `ui/src/menukeys.ts` carries this same list; change the two together (the app + // also reports drift at startup, `menuDrift`). let exported: Vec = chords() .iter() .map(|c| format!("{} {} {}", c.id, c.keys, c.label)) diff --git a/crates/b2-desktop/src/slot.rs b/crates/b2-desktop/src/slot.rs index 9e17774..179f6c4 100644 --- a/crates/b2-desktop/src/slot.rs +++ b/crates/b2-desktop/src/slot.rs @@ -1,16 +1,12 @@ -//! A single-in-flight slot for a long, cancellable façade op — host **infrastructure**, -//! not engine logic. The window drives two such ops, the background embed and a streaming -//! answer, and both need the same four things: refuse a second run while one is going, -//! release on every exit path, let another thread ask the run to stop, and keep a stale -//! stop request from killing the *next* run. +//! A single-in-flight slot for a long, cancellable façade op (the background embed, a +//! streaming answer): refuses a second run, releases on every exit path, and keeps a stale +//! stop request from killing the next run. use std::ops::ControlFlow; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -/// How often [`Slot::cancel_and_wait`] re-asserts the cancel flag and re-checks whether -/// the run has wound down. Short enough to feel instant on a vault switch, long enough -/// not to spin hot. +/// How often [`Slot::cancel_and_wait`] re-asserts the cancel and re-checks the run. const CANCEL_POLL: Duration = Duration::from_millis(25); /// One op's slot: whether a run holds it, and whether that run has been asked to stop. @@ -20,8 +16,7 @@ pub struct Slot { cancel: AtomicBool, } -/// Proof of holding a [`Slot`]; dropping it releases the slot, so it is freed on -/// **every** exit path — a normal return, an early `?` (no vault, no model), a panic. +/// Proof of holding a [`Slot`]; dropping it releases the slot on every exit path. #[derive(Debug)] pub struct SlotGuard<'a>(&'a Slot); @@ -32,9 +27,8 @@ impl Drop for SlotGuard<'_> { } impl Slot { - /// Claim the slot for a fresh run, or `None` when one is already in flight. Winning - /// also clears any stale cancel — the request that stopped the *previous* run (or a - /// vault switch) must not stop this one before it starts. + /// Claim the slot, or `None` when a run is in flight. Clears any stale cancel, so the + /// request that stopped the previous run can't stop this one. pub fn try_claim(&self) -> Option> { self.running .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) @@ -48,8 +42,7 @@ impl Slot { self.running.load(Ordering::SeqCst) } - /// Ask the running op to stop at its next checkpoint. Cooperative — never a thread - /// kill, so no torn writes. A no-op if nothing is running. + /// Ask the running op to stop at its next checkpoint (cooperative, so no torn writes). pub fn cancel(&self) { self.cancel.store(true, Ordering::SeqCst); } @@ -59,8 +52,7 @@ impl Slot { self.cancel.load(Ordering::SeqCst) } - /// The cancel flag as the façade's loops read it: [`ControlFlow::Break`] once a stop - /// has been asked for, else [`ControlFlow::Continue`]. + /// The cancel flag as the façade's loops read it. pub fn flow(&self) -> ControlFlow<()> { if self.cancelled() { ControlFlow::Break(()) @@ -69,9 +61,9 @@ impl Slot { } } - /// Cancel any run in flight and **block until it winds down**. Re-asserts the cancel - /// on every poll, so it wins even against a run that claimed the slot (clearing the - /// flag) a moment after the first request; returns at once when nothing is running. + /// Cancel any run in flight and block until it winds down. Re-asserts the cancel on + /// every poll, so it wins against a run that claimed the slot (clearing the flag) just + /// after the first request. pub fn cancel_and_wait(&self) { loop { self.cancel(); @@ -111,11 +103,9 @@ mod tests { slot.cancel(); assert!(slot.cancelled()); assert!(slot.flow().is_break()); - // A fresh run clears the cancel a prior run (or vault switch) left behind… let _held = slot.try_claim(); assert!(!slot.cancelled()); assert!(slot.flow().is_continue()); - // …and a new request is observable again. slot.cancel(); assert!(slot.flow().is_break()); } diff --git a/crates/b2-desktop/src/state_file.rs b/crates/b2-desktop/src/state_file.rs index 16a2e0e..1703fa7 100644 --- a/crates/b2-desktop/src/state_file.rs +++ b/crates/b2-desktop/src/state_file.rs @@ -1,19 +1,14 @@ -//! The host's per-machine state files — the last opened vault, the chat settings, the -//! embed-time ledger. All live under `/b2/` (macOS: -//! `~/Library/Application Support/b2/`, Linux: `~/.local/share/b2/`), the same vendor dir -//! `b2-embed` keeps its model cache in, and all are **best-effort**: remembering something -//! must never fail the action the user just took. Never vault or index state. +//! The host's per-machine state files (last vault, chat settings, embed-time ledger) under +//! `/b2/`. Best-effort: remembering must never fail the user's action. use std::path::{Path, PathBuf}; -/// `/b2/`, or `None` when the platform has no data dir — in which case -/// the caller simply doesn't remember. +/// `/b2/`, or `None` when the platform has no data dir. pub fn path(name: &str) -> Option { dirs::data_dir().map(|d| d.join("b2").join(name)) } -/// Write `contents` to `file`, creating its parent dir first — the testable core every -/// state file's writer shares. +/// Write `contents` to `file`, creating its parent dir first. pub fn write(file: &Path, contents: &[u8]) -> std::io::Result<()> { if let Some(parent) = file.parent() { std::fs::create_dir_all(parent)?; diff --git a/crates/b2-desktop/src/stats.rs b/crates/b2-desktop/src/stats.rs index 1af780a..f9dbd48 100644 --- a/crates/b2-desktop/src/stats.rs +++ b/crates/b2-desktop/src/stats.rs @@ -1,39 +1,29 @@ -//! Cumulative, per-model **embedding time** — the desktop's persistent "how much has -//! embedding cost with this model" ledger, surfaced in Settings so a model swap can be -//! judged on its real speed. +//! Cumulative per-model embedding time, shown in Settings so a model swap can be judged on +//! its real speed. The adapter times the embed pass because `b2-core` has no clock. //! -//! Host-owned state, like `persist_last_vault`: `b2-core` stays wall-clock-free, so the -//! *adapter* times the embed pass. Keyed by model id, so each bucket's totals stay directly -//! comparable. A bucket is a running total for the model's **current stint**: [`reset`] -//! drops it when the user switches *to* that model, because the swap re-embeds the whole -//! corpus (ADR-0007) and the ledger must restart with the vectors. Purely diagnostic and -//! best-effort — a read/write failure never fails an embed. +//! A bucket covers the model's current stint: [`reset`] drops it on a switch to that model, +//! since the swap re-embeds the whole corpus (ADR-0007). Best-effort throughout. use crate::state_file; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::Path; -/// The ledger's state file, `/b2/embed-stats.json` (see [`state_file`]). +/// The ledger's state file (see [`state_file`]). const STATS_FILE: &str = "embed-stats.json"; -/// One model's accumulated embedding cost. `total_ms / chunks` is the throughput the -/// Settings pane shows; `runs` counts the embed passes that contributed. +/// One model's accumulated embedding cost; `total_ms / chunks` is its throughput. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelStat { - /// Total wall-clock milliseconds spent embedding chunks with this model (the model - /// *load* is excluded — the adapter starts the clock after the model is loaded, so - /// this is embedding throughput, not one-time setup). + /// Milliseconds spent embedding, excluding the model load. pub total_ms: u64, - /// Total chunks embedded across those runs — the throughput denominator. + /// Total chunks embedded across those runs. pub chunks: u64, /// How many embed runs contributed to this total. pub runs: u64, } -/// One model's row as the Settings pane reads it (`embed_stats`, `ui/src/types.ts`'s -/// `EmbedStat`): the id beside its [`ModelStat`] fields, flattened, so the IPC payload is -/// the ledger's own bucket rather than a hand-copied mirror of it. +/// One model's row as the Settings pane reads it (`ui/src/types.ts`'s `EmbedStat`). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EmbedStat { pub model: String, @@ -47,9 +37,7 @@ struct StatsFile { models: BTreeMap, } -/// The whole ledger, one row per model, or empty when there's no data dir / no file / an -/// unreadable-or-corrupt file — stats are never load-bearing, so a bad file degrades -/// cleanly to "no history" rather than surfacing an error. +/// The whole ledger, one row per model. A missing or corrupt file reads as no history. pub fn read_all() -> Vec { let Some(path) = state_file::path(STATS_FILE) else { return Vec::new(); @@ -61,8 +49,7 @@ pub fn read_all() -> Vec { .collect() } -/// [`read_all`] against an explicit path — the testable core. A missing or malformed file -/// reads as the empty ledger. +/// [`read_all`] against an explicit path. fn read_from(path: &Path) -> StatsFile { std::fs::read_to_string(path) .ok() @@ -70,26 +57,20 @@ fn read_from(path: &Path) -> StatsFile { .unwrap_or_default() } -/// Serialize the ledger and rewrite it at `path` — the shared write tail of -/// [`record_to`] and [`reset_in`]. Only a write that has something to say reaches here -/// (a no-op reset returns before it), so creating the parent dir on the way is safe. +/// Serialize the ledger and rewrite it at `path`. fn write_ledger(path: &Path, file: &StatsFile) -> std::io::Result<()> { let text = serde_json::to_string_pretty(file).map_err(std::io::Error::other)?; state_file::write(path, text.as_bytes()) } -/// Add one embed run's `(elapsed_ms, chunks)` to `model`'s running total. Best-effort: -/// a missing data dir or a write failure is logged to stderr and swallowed — recording a -/// measurement must never fail the embed the user actually asked for. +/// Add one embed run to `model`'s running total. Best-effort: failures go to stderr. pub fn record(model: &str, elapsed_ms: u64, chunks: u64) { state_file::update(STATS_FILE, "record embed stats", |path| { record_to(path, model, elapsed_ms, chunks) }); } -/// [`record`] against an explicit path — the testable core. Read-modify-write: load the -/// ledger, fold the run into `model`'s bucket (saturating, so a pathological total can't -/// panic), and rewrite. Creates the file (and parent dir) on first use. +/// [`record`] against an explicit path. Creates the file on first use. fn record_to(path: &Path, model: &str, elapsed_ms: u64, chunks: u64) -> std::io::Result<()> { let mut file = read_from(path); let entry = file.models.entry(model.to_string()).or_default(); @@ -99,41 +80,34 @@ fn record_to(path: &Path, model: &str, elapsed_ms: u64, chunks: u64) -> std::io: write_ledger(path, &file) } -/// Forget `model`'s accumulated total, so its bucket restarts on the next [`record`]. -/// Called when the user **switches to** this model: the swap drops the vault's vectors, so -/// the next reindex re-embeds the whole corpus and the ledger must restart with it — -/// otherwise switching back and forth would stack corpus after corpus onto one bucket. Only -/// the switched-to model is touched, so the others' history survives for comparison. -/// Best-effort like [`record`]. +/// Forget `model`'s total, called when the user switches to it: the next reindex re-embeds +/// the whole corpus, which must not stack onto the old bucket. Other models keep their +/// history. Best-effort. pub fn reset(model: &str) { state_file::update(STATS_FILE, "reset embed stats", |path| { reset_in(path, model) }); } -/// [`reset`] against an explicit path — the testable core. Drops `model`'s bucket and -/// rewrites the ledger; a **no-op with no write** when the model has no history (a -/// never-embedded model, or no file yet), so switching to it can't create an empty ledger -/// or churn the file. +/// [`reset`] against an explicit path. Writes nothing when the model has no history. fn reset_in(path: &Path, model: &str) -> std::io::Result<()> { let mut file = read_from(path); if file.models.remove(model).is_none() { - return Ok(()); // nothing recorded for this model — leave the file untouched + return Ok(()); } write_ledger(path, &file) } #[cfg(test)] mod tests { - //! Hermetic: every case runs against a tempfile, never the real data dir (which only - //! the production `record`/`read_all` wrappers resolve). + //! Hermetic: every case runs against a tempfile, never the real data dir. use super::*; #[test] fn record_accumulates_across_runs_per_model() { let tmp = tempfile::TempDir::new().unwrap(); - // Parent dir does not exist yet — the first record must `mkdir -p`. + // Parent dir does not exist yet. let path = tmp.path().join("state/b2/embed-stats.json"); record_to(&path, "m/base", 1000, 40).unwrap(); @@ -145,7 +119,6 @@ mod tests { assert_eq!(base.total_ms, 3500, "two runs' ms sum"); assert_eq!(base.chunks, 100); assert_eq!(base.runs, 2); - // A different model accumulates in its own bucket, untouched by base. let small = &ledger["m/small"]; assert_eq!(small.total_ms, 300); assert_eq!(small.runs, 1); @@ -159,8 +132,6 @@ mod tests { record_to(&path, "m/base", 1000, 40).unwrap(); record_to(&path, "m/small", 300, 50).unwrap(); - // Switching *to* base restarts its stint; small's history is untouched so the two - // models stay side-by-side comparable in the Settings pane. reset_in(&path, "m/base").unwrap(); let ledger = read_from(&path).models; @@ -173,8 +144,7 @@ mod tests { "other model survives the reset" ); - // The next run rebuilds base from zero — a full re-embed after the swap, not - // stacked onto the old 40 (this is the accumulation bug the reset fixes). + // The next run starts from zero, not stacked onto the old 40. record_to(&path, "m/base", 2000, 60).unwrap(); let base = &read_from(&path).models["m/base"]; assert_eq!(base.chunks, 60); @@ -185,7 +155,6 @@ mod tests { fn reset_is_a_noop_for_unknown_model_or_missing_file() { let tmp = tempfile::TempDir::new().unwrap(); - // No file yet: nothing to reset, and we must not create an empty ledger. let absent = tmp.path().join("absent/embed-stats.json"); reset_in(&absent, "m/base").unwrap(); assert!( @@ -193,7 +162,6 @@ mod tests { "reset must not create a file when there's no history" ); - // Existing ledger, but the model has no bucket → the file is left untouched. let path = tmp.path().join("embed-stats.json"); record_to(&path, "m/base", 1000, 40).unwrap(); reset_in(&path, "m/never-embedded").unwrap(); @@ -204,8 +172,7 @@ mod tests { ); } - /// The Settings pane reads these four keys (`ui/src/types.ts`'s `EmbedStat`), so the - /// flattened row must serialize exactly as the hand-written mirror it replaced. + /// The Settings pane reads these four keys (`ui/src/types.ts`'s `EmbedStat`). #[test] fn a_ledger_row_crosses_ipc_with_the_ui_field_names() { let row = EmbedStat { @@ -225,9 +192,7 @@ mod tests { #[test] fn missing_or_corrupt_file_reads_as_empty() { let tmp = tempfile::TempDir::new().unwrap(); - // No file at all. assert!(read_from(&tmp.path().join("absent.json")).models.is_empty()); - // A garbage file is treated as no history, never an error. let bad = tmp.path().join("bad.json"); std::fs::write(&bad, "not json {{{").unwrap(); assert!(read_from(&bad).models.is_empty()); diff --git a/crates/b2-desktop/src/watch.rs b/crates/b2-desktop/src/watch.rs index b8623f1..704166c 100644 --- a/crates/b2-desktop/src/watch.rs +++ b/crates/b2-desktop/src/watch.rs @@ -1,18 +1,8 @@ -//! Native filesystem watch → **auto-reload on external edits** (#14). B2's premise is that -//! the vault is *also* edited outside the app (Obsidian/vim, a `git pull`), so the window -//! has to notice and reconcile. The conflict bar remains the fallback for the one case this -//! cannot cover safely: an external edit to the note you are *actively typing in*. +//! Filesystem watch for auto-reload on external edits (#14). Coalesces a burst of OS events +//! into one debounced `vault-changed` event; the frontend reconciles through existing façade +//! ops (ADR-0012). The conflict bar still covers an external edit to the note being typed in. //! -//! **Still a dumb adapter** (ADR-0012): the watcher holds no engine logic — it watches the -//! vault directory, coalesces a burst of raw OS events into one **debounced -//! `vault-changed` pulse**, and emits it. The frontend reconciles by calling the *existing* -//! façade ops. This is host infrastructure, the same class as the background-reindex task -//! and the OS folder picker. -//! -//! **Security: the webview gets no filesystem permission.** The watch runs entirely in the -//! Rust host, and the pulse is a bare signal carrying no paths, so it adds nothing to the -//! webview's least-privilege capability set. The frontend never touches the disk; it -//! re-reads through the façade, which keeps path resolution centralized. +//! The event carries no paths, so the webview still needs no filesystem permission. use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; use std::path::{Path, PathBuf}; @@ -21,36 +11,22 @@ use std::sync::Mutex; use std::time::Duration; use tauri::{AppHandle, Emitter}; -/// The event name the host emits and the webview listens for. **Pinned** to the mirror -/// constant in `ui/src/api.ts` by `vault_changed_event_matches_the_frontend` — change both -/// together (the same "one contract, one string" discipline as the write-conflict message). +/// The event the webview listens for; must match `ui/src/api.ts` (pinned by a test). pub const VAULT_CHANGED_EVENT: &str = "vault-changed"; -/// How long the debounce thread waits for the event stream to go quiet before emitting one -/// pulse. A single editor save or a `git pull` fires a *burst* of OS events; coalescing on -/// the trailing edge turns the burst into exactly one reconcile. Short enough to feel -/// immediate, long enough to swallow a burst. +/// Quiet period before one event is emitted for a burst (a save or a `git pull`). const DEBOUNCE: Duration = Duration::from_millis(300); -/// Managed state holding the active watcher. Replacing or dropping it stops the previous -/// watch (its channel sender drops, so the debounce thread's `recv` ends) — how a vault -/// switch re-points the watch and shutdown tears it down. -/// -/// Kept **out of [`AppState`](crate::AppState)** on purpose: the watcher pulls in `notify` -/// and a live OS handle, while `AppState` is the pure, unit-tested root+reindex state -/// machine, and wiring the two would drag a filesystem handle into those hermetic tests. +/// Managed state holding the active watcher; replacing or dropping it stops the previous +/// watch. Kept out of [`AppState`](crate::AppState) so its tests stay free of OS handles. #[derive(Default)] pub struct VaultWatcher(Mutex>); impl VaultWatcher { - /// Point the watch at `root` (recursively), replacing any previous watch. Best-effort - /// host state, exactly like remembering the last vault: a platform that can't watch - /// (permissions, an unsupported backend) logs to stderr and runs on — auto-reload is a - /// convenience over the always-present conflict-bar fallback, never a hard dependency, - /// so it must not fail a launch or a vault switch. + /// Point the watch at `root`, replacing any previous watch. Best-effort: a failure logs + /// to stderr and never fails a launch or vault switch. pub fn watch(&self, app: &AppHandle, root: &Path) { - // Drop the old watcher first so only one watch is ever live (its sender drops → - // its debounce thread ends). + // Drop the old watcher first so only one watch is ever live. *self.lock() = None; match start(app.clone(), root) { Ok(watcher) => *self.lock() = Some(watcher), @@ -58,47 +34,34 @@ impl VaultWatcher { } } - /// The critical section here is a single store/drop that can't panic, so the lock - /// can never be poisoned; see [`lock_recover`](crate::lock_recover). + /// See [`lock_recover`](crate::lock_recover). fn lock(&self) -> std::sync::MutexGuard<'_, Option> { crate::lock_recover(&self.0) } } -/// Build a recursive watcher on `root` and spawn its debounce thread. The `notify` callback -/// runs on `notify`'s own thread and does the minimum — forward each raw event into a -/// channel; all coalescing happens in [`debounce_loop`], off that thread. +/// Build a recursive watcher on `root` and spawn its debounce thread. fn start(app: AppHandle, root: &Path) -> notify::Result { let (tx, rx) = mpsc::channel::>(); let mut watcher = notify::recommended_watcher(move |res| { - // A send error means the debounce thread is gone (we're shutting this watch down); - // nothing to do but drop the event. let _ = tx.send(res); })?; watcher.watch(root, RecursiveMode::Recursive)?; - // Canonicalize once so event paths (which the OS reports canonicalized — e.g. - // `/private/var` for a `/var` root on macOS) strip cleanly against it. A root - // that can't canonicalize falls back to itself; the filter fails open anyway. + // Event paths arrive canonicalized (macOS `/private/var` for `/var`), so strip against + // a canonical root. let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); std::thread::spawn(move || debounce_loop(rx, app, canonical_root)); Ok(watcher) } -/// Coalesce bursts of raw filesystem events into one `vault-changed` pulse per quiet period. -/// Blocks for the first event of a burst, then drains until [`DEBOUNCE`] passes with no new -/// event, emitting a single pulse iff the burst touched a **vault member** — any file the -/// walk would see (index churn under `.b2/`, `.git/` internals, and other dot-prefixed -/// paths are filtered out — see [`touches_vault`]). Ends when the watcher (and thus the -/// sending half) is dropped. +/// Emit one `vault-changed` per burst that touched a vault member ([`touches_vault`]). +/// Ends when the watcher is dropped. fn debounce_loop(rx: mpsc::Receiver>, app: AppHandle, root: PathBuf) { loop { - // Block for the first event of a fresh burst; a receive error means the watcher was - // dropped (vault switch / shutdown) — stop the thread. let mut relevant = match rx.recv() { Ok(ev) => event_touches_vault(&root, &ev), Err(_) => return, }; - // Drain the rest of the burst until the stream goes quiet for DEBOUNCE. loop { match rx.recv_timeout(DEBOUNCE) { Ok(ev) => relevant |= event_touches_vault(&root, &ev), @@ -113,8 +76,6 @@ fn debounce_loop(rx: mpsc::Receiver>, app: AppHandle, root } } -/// Emit one `vault-changed` pulse when the burst mattered. A send error (the window closed) -/// is not fatal — the app is going away anyway. fn emit_if(app: &AppHandle, relevant: bool) { if relevant { let _ = app.emit(VAULT_CHANGED_EVENT, ()); @@ -128,26 +89,18 @@ fn event_touches_vault(root: &Path, ev: ¬ify::Result) -> bool { } } -/// Whether a filesystem event touches a **vault member** — the watch mirror of the -/// walk's routing rule (b2-core `collect_vault_files`, file-type slice 1 spec §6): -/// any path with **no dot-prefixed component below the vault root** counts, so a -/// Finder-dropped PNG pulses like a note edit, while the self-inflicted noise stays -/// filtered — the index (`.b2/` sqlite, rewritten continuously by a save's trailing -/// embed), `.git/` internals on a pull, `.obsidian/` workspace churn. +/// Whether an event touches a vault member, mirroring the walk's rule (b2-core +/// `collect_vault_files`): no dot-prefixed component below the root. Filters out `.b2/`, +/// `.git/` and `.obsidian/` churn. /// -/// The dot rule needs *vault-relative* components (a dot-dir **above** the root — -/// `~/.config/vaults/…` — must not mute everything), so each path is stripped -/// against the pre-canonicalized root. The former `.md`-allowlist avoided that -/// stripping; the price of covering resources is taking it on — mitigated by -/// canonicalizing the root once at watch start, and by **failing open**: a path -/// that won't strip (an unexpected mount/symlink shape) counts as relevant, costing -/// at most one extra debounced pulse (a cheap re-list), never a silently dead reload. +/// Components are vault-relative, so a dot-dir above the root doesn't mute the vault. A path +/// that won't strip fails open: one extra reload beats a silently dead one. fn touches_vault(root: &Path, paths: &[PathBuf]) -> bool { paths.iter().any(|p| match p.strip_prefix(root) { Ok(rel) => !rel .components() .any(|c| c.as_os_str().to_str().is_some_and(|s| s.starts_with('.'))), - Err(_) => true, // fail open — see above + Err(_) => true, }) } @@ -158,8 +111,6 @@ mod tests { #[test] fn vault_member_paths_are_relevant_dot_prefixed_churn_is_not() { let root = Path::new("/v"); - // A note edit anywhere in the tree is relevant — and, since file-type slice 1, - // so is any resource the walk would inventory (a Finder-dropped PNG must pulse). assert!(touches_vault( root, &[PathBuf::from("/v/notes/spaced-repetition.md")] @@ -170,7 +121,6 @@ mod tests { &[PathBuf::from("/v/assets/diagram.png")] )); assert!(touches_vault(root, &[PathBuf::from("/v/no-extension")])); - // …but the disposable sqlite index (the trailing-embed write storm) is not… assert!(!touches_vault(root, &[PathBuf::from("/v/.b2/b2.sqlite")])); assert!(!touches_vault( root, @@ -180,7 +130,6 @@ mod tests { root, &[PathBuf::from("/v/.b2/b2.sqlite-shm")] )); - // …nor `.git` internals from a pull, `.obsidian/` churn, or dotfiles. assert!(!touches_vault(root, &[PathBuf::from("/v/.git/index")])); assert!(!touches_vault( root, @@ -192,7 +141,6 @@ mod tests { #[test] fn the_dot_rule_is_vault_relative_and_fails_open() { - // A dot-dir ABOVE the root must not mute the vault it contains… let dotted_root = Path::new("/home/u/.config/vaults/v"); assert!(touches_vault( dotted_root, @@ -202,8 +150,7 @@ mod tests { dotted_root, &[PathBuf::from("/home/u/.config/vaults/v/.b2/b2.sqlite")] )); - // …and a path that doesn't strip against the root counts as relevant (fail - // open): one extra debounced pulse beats a silently dead auto-reload. + // A path outside the root fails open. assert!(touches_vault( Path::new("/v"), &[PathBuf::from("/elsewhere/x.bin")] @@ -212,8 +159,6 @@ mod tests { #[test] fn a_burst_touching_any_vault_member_is_relevant() { - // A rename fires create+remove; a pull touches many files at once. As long as one - // path in the coalesced burst is a vault member, the burst earns a single pulse. let burst = [ PathBuf::from("/v/.b2/b2.sqlite-wal"), PathBuf::from("/v/.git/ORIG_HEAD"), @@ -224,9 +169,6 @@ mod tests { #[test] fn vault_changed_event_matches_the_frontend() { - // The webview listens for this exact string (ui/src/api.ts `VAULT_CHANGED_EVENT`). - // Pin it here so a rename on either side can't silently break auto-reload — the - // same cross-language "change them together" guard as the write-conflict message. let api_ts = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ui/src/api.ts"), ) diff --git a/crates/b2-embed/Cargo.toml b/crates/b2-embed/Cargo.toml index 2e415d0..e909a91 100644 --- a/crates/b2-embed/Cargo.toml +++ b/crates/b2-embed/Cargo.toml @@ -5,27 +5,19 @@ edition = "2021" rust-version = "1.96" description = "B2's real local embedder — candle-backed inference behind the b2-core Embedder seam" -# Heavy ML deps (candle + tokenizers) live *only* here, never in b2-core, so the -# core test suite stays fast, deterministic, and model-free (CLAUDE.md). The `b2` -# binary depends on this crate to inject the real model. +# Heavy ML deps live only here, never in b2-core, so the core suite stays model-free +# (CLAUDE.md). [dependencies] b2-core = { path = "../b2-core" } candle-core = "0.9" candle-nn = "0.9" candle-transformers = "0.9" -# Download seam for `b2 init`. `default-features = false` is load-bearing (GH #174): -# hf-hub's defaults are ["default-tls", "tokio", "ureq"], and the `tokio` one pulls -# reqwest → hyper → h2 → tokio — the whole async HTTP stack, compiled at opt-3 by the -# workspace's dependency profile, to serve an async API B2 never calls (we use -# `hf_hub::api::sync`). Dropping the defaults also drops `default-tls` → `native-tls`, -# which is the one *behavioural* change: hf-hub's sync agent falls back to ureq's own -# default TLS (rustls + webpki-roots) instead of the OS trust store. That is already -# what b2-llm speaks to cloud endpoints with, and what hf-hub's redirect-following -# agent used even before this — only the first hop was native-tls. Proxies are -# unaffected (hf-hub calls `try_proxy_from_env` either way). The trade is that a -# corporate MITM CA installed in the *system* store is no longer trusted here; -# `ureq/native-certs` is the escape hatch if that ever bites. +# Download seam for `b2 init`. No default features (GH #174): the `tokio` default pulls the +# whole async HTTP stack for an API B2 never calls (we use `hf_hub::api::sync`). This also +# drops `native-tls`, so TLS is ureq's rustls + webpki-roots rather than the OS trust store: +# a corporate MITM CA in the system store is not trusted. `ureq/native-certs` is the escape +# hatch. hf-hub = { version = "0.4", default-features = false, features = ["ureq"] } tokenizers = "0.21" serde = { version = "1.0.228", features = ["derive"] } @@ -34,31 +26,22 @@ toml = "0.8" dirs = "6.0" thiserror = "2.0.18" -# On macOS, link Apple's Accelerate (vDSP/BLAS) for candle's CPU matmuls. Without -# it candle falls back to a naive kernel that makes bge-base inference ~10-100× -# slower — the real bottleneck behind a slow `b2 reindex`, far more than batching. -# Cargo merges these features with the base deps above; on non-macOS the feature is -# simply absent (candle stays portable), so a Linux build is unaffected. +# On macOS, link Accelerate for candle's CPU matmuls; without it bge-base inference is +# ~10-100× slower. Other targets are unaffected. [target.'cfg(target_os = "macos")'.dependencies] candle-core = { version = "0.9", features = ["accelerate"] } candle-nn = { version = "0.9", features = ["accelerate"] } candle-transformers = { version = "0.9", features = ["accelerate"] } -# Research lever (GH #40): run the BERT forward pass on the Apple-Silicon Metal GPU instead -# of the CPU. `metal` is *additive* over `accelerate` (independent code paths — CPU mode still -# gets Accelerate BLAS, Metal mode uses the GPU), and the device is chosen at load via candle's -# `metal_is_available()` (== `cfg!(feature = "metal")`) with a graceful CPU fallback — see -# `model.rs::select_device`. Switching device is a *model swap* (the recorded model id gains an -# `@metal` tag), so a re-embed is forced and `search` fails fast rather than mixing vectors. -# **macOS-only:** the candle metal-kernels crate builds only on macOS; don't pass `--features -# metal` on Linux. Enable per-build: `cargo run -p b2-cli --features metal -- reindex`. +# Run the forward pass on the Metal GPU (GH #40), additive over `accelerate`, with a CPU +# fallback (`model.rs::select_device`). Switching device is a model swap (the model id gains +# `@metal`), so it forces a re-embed (ADR-0007). macOS-only: don't pass `--features metal` on +# Linux. [features] metal = ["candle-core/metal", "candle-nn/metal", "candle-transformers/metal"] [dev-dependencies] tempfile = "3.27.0" -# The eval harness's search evidence probe (GH #201) reads `chunks_fts` directly -# over the connection `b2_core::open` hands back, so the examples need rusqlite's -# *types* in scope. Dev-only, and feature-unified with b2-core's bundled build — -# this adds nothing to the `b2` binary's tree. +# The eval's search evidence probe (GH #201) reads `chunks_fts` directly. Dev-only, and +# feature-unified with b2-core's bundled build, so it adds nothing to the `b2` binary. rusqlite = "0.40.1" diff --git a/crates/b2-embed/examples/calibrate.rs b/crates/b2-embed/examples/calibrate.rs index 26b2237..cbe0f1e 100644 --- a/crates/b2-embed/examples/calibrate.rs +++ b/crates/b2-embed/examples/calibrate.rs @@ -1,10 +1,7 @@ -//! Real-vault discovery calibration — the instrument ADR-0014's Phase 0a promoted out of -//! GH #196's hand arithmetic. It runs against **any built vault**, needs no labels, and -//! prints the numbers every discovery-surfacing ruling has turned on: per-anchor pool -//! distributions, each anchor's leader cosine and z, what a z gate would serve versus what -//! always-serve does, and the strength bands the desktop would paint. This is process rule -//! 5 made mechanical — **a constant derived from a corpus's score distribution is invalid -//! until transfer-checked on a real vault**. +//! Real-vault discovery calibration (ADR-0014, GH #196). Runs against any built vault with +//! no labels, and prints per-anchor pool distributions, leader cosine and z, what a z gate +//! would serve against always-serve, and the strength bands. Process rule 5: a constant +//! derived from a corpus's score distribution is invalid until checked on a real vault. //! //! ```console //! make calibrate VAULT=$HOME/notes # per-anchor lines + the summary block @@ -15,29 +12,13 @@ //! make calibrate VAULT=$HOME/notes ARGS="--mutual-k 5" # replay the fold at a different depth //! ``` //! -//! Beside the retired z gate it replays the two **default-disclosure** candidates ADR-0014 -//! admits a fold from. The **mutual-k reciprocity fold** (GH #200): B is reciprocal for A -//! iff A ranks within B's own top `mutual_k`, and the replayed default view is the ranked -//! list's longest reciprocal prefix — a cut in the served order, never a filter that skips -//! rows. That bake-off has **ruled, and no fold ships**: the window is empty on both corpora, -//! and this instrument supplied the reading that generalized it — the same `k` is a different -//! rule on every vault (10 discloses 36% of cards on the orthogonal corpus, 91% on the dense -//! fixture, 98% on `fixtures/test-vault`). The **authored-edge reference bar** is replayed -//! beside it, and this is the only instrument that *can*: the rule calibrates from the score -//! distribution of the human's own committed edges, and both eval corpora are link-free by -//! construction. Unlike reciprocity that is a distributional constant, so this instrument is -//! not an aside for it — it is the whole of its evidence. +//! It also replays the two default-disclosure folds (GH #200): mutual-k reciprocity (ruled +//! out; the same `k` is a different rule on every vault) and the authored-edge reference +//! bar, which only a real vault with links can price. //! -//! **It is a pure read** — stored vectors only, no model call — so it runs in seconds on any -//! personal-scale vault and never perturbs what it measures. -//! -//! The z is **recomputed harness-side from the served scores**, kept independent of the -//! engine's own statistic so the instrument can also *check* it: where the engine ships a z -//! beside a candidate, the two are diffed and a drift reports as a fault. -//! -//! The replayed gate defaults to the constants ADR-0014 retired, so the acceptance reading -//! reproduces GH #196's finding on its reporting vault: 16 of 17 anchors dark. It is a -//! **simulation** — the shipped surface gates nothing. +//! A pure read of stored vectors, no model call. The z is recomputed from the served scores +//! and diffed against the engine's as a drift check. The replayed gate defaults to the +//! constants ADR-0014 retired; it is a simulation, and the shipped surface gates nothing. mod common; @@ -48,39 +29,32 @@ use rusqlite::Connection; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -/// How deep each anchor's pool is read. A bar is judged against the population it -/// has to cut, so the read must cover the whole candidate set, not a pane's -/// prefix; anything a personal vault holds fits far under this. +/// How deep each anchor's pool is read: the whole candidate set, which a bar is judged +/// against. const SCAN_LIMIT: usize = 100_000; -/// The replayed gate is inert under this population — `discover.rs`'s statistics -/// guard, restated here because the replay must match the rule it prices. (The -/// strength bands the pane paints are [`common::BAND_STRONG_Z`]'s, shared with -/// `make eval`'s calibration block, where their values are re-measured.) +/// The replayed gate is inert under this population: `discover.rs`'s guard, restated. const MIN_POPULATION: usize = 12; -/// The z existence gate being replayed — GH #197 retired it from the engine; the -/// instrument keeps it (and any variant the flags name) priceable. +/// The z existence gate being replayed, retired from the engine by GH #197. struct GateSim { leader_z: f64, member_z: f64, } -/// One anchor's complete reading: its whole candidate pool in rank order, with -/// the harness-side z when the population carries a statistic. +/// One anchor's whole candidate pool in rank order, with the harness-side z. struct AnchorReading { path: String, /// (candidate path, cosine, engine z if shipped) in served (nearest-first) order. pool: Vec<(String, f64, Option)>, - /// Harness-recomputed z per pool entry — `None` when the population is under - /// [`MIN_POPULATION`] or has zero variance (no statistic exists). + /// Harness-recomputed z per pool entry; `None` under [`MIN_POPULATION`] or at zero + /// variance. z: Option>, } impl AnchorReading { fn from_candidates(path: &str, cands: &[SimilarView]) -> Self { let d2: Vec = cands.iter().map(|c| c.score * c.score).collect(); - // The harness's own z (never the engine's), gated at the replayed rule's - // population floor before the shared restatement's own inertness guard. + // The harness's own z, never the engine's. let z = (d2.len() >= MIN_POPULATION) .then(|| passage_z(&d2)) .flatten(); @@ -98,9 +72,8 @@ impl AnchorReading { self.pool.iter().map(|(_, cos, _)| *cos).collect() } - /// How many candidates the replayed z gate would serve, capped at `limit` — - /// 0 with the leader gate fired ("dark"), everything (to the cap) where the - /// pool is too small for a statistic (the rule's own inertness). + /// How many candidates the replayed gate serves, capped at `limit`: 0 when the leader + /// gate fires, everything when the pool has no statistic. fn gate_serves(&self, gate: &GateSim, limit: usize) -> usize { match &self.z { None => self.pool.len().min(limit), @@ -117,9 +90,7 @@ impl AnchorReading { } } - /// Band histogram over the top-`limit` cards always-serve shows: - /// (strong ●●●, clear ●●○, near ●○○), or `None` for an ungraded pool — - /// the A6 readout (do dense vaults compress every card into one band?). + /// Band histogram (strong, clear, near) over the top-`limit` cards; `None` when ungraded. fn bands(&self, limit: usize) -> Option<(usize, usize, usize)> { let z = self.z.as_ref()?; let (mut strong, mut clear, mut near) = (0, 0, 0); @@ -133,8 +104,7 @@ impl AnchorReading { Some((strong, clear, near)) } - /// Worst |engine z − recomputed z| across the pool — the drift check, biting - /// only where the engine shipped a z at all. + /// Worst |engine z − recomputed z| across the pool, where the engine shipped a z. fn recheck_delta(&self) -> f64 { let Some(z) = &self.z else { return 0.0 }; self.pool @@ -144,10 +114,8 @@ impl AnchorReading { .fold(0.0, f64::max) } - /// How many pool entries the drift check actually compared — both z's - /// present. Zero pairs must print as "nothing to cross-check", never as a - /// pass: a check that reports success without having run is the - /// advisory-but-exit-0 hole this repo's gates exist to close. + /// How many entries the drift check compared. Zero must print as "nothing to + /// cross-check", never as a pass. fn recheck_pairs(&self) -> usize { if self.z.is_none() { return 0; @@ -159,18 +127,10 @@ impl AnchorReading { } } -/// **Candidate 2** of GH #200's bake-off, replayed: an *authored-edge reference bar*. It -/// calibrates "what related looks like **in this vault**" from the one labelled population -/// every real vault carries — the score distribution of the human's committed edges — and -/// folds the default view at the longest prefix scoring at or above it. -/// -/// Priceable only where that population exists, which is why it lives here rather than in -/// `make eval`: **both eval corpora are link-free by construction**. Its pair score is the -/// same statistic discovery ranks on, computed over the *linked* pairs discovery never scores -/// (the 1-hop exclusion removes exactly them). The bar is the population's **lower quartile** -/// — a candidate at least as related as the weaker quarter of what this human already linked -/// — and the whole distribution prints beside it, because a quantile is a choice and a choice -/// printed as one number is an assumption. +/// GH #200's candidate 2: an authored-edge reference bar. It calibrates from the best-passage +/// cosines of the human's own linked pairs (absent from the link-free eval corpora) and +/// folds at the longest prefix at or above their lower quartile. The whole distribution +/// prints, since the quantile is a choice. struct EdgeBar { /// Authored edges whose pair could be scored (both notes embedded). n: usize, @@ -181,16 +141,13 @@ struct EdgeBar { } impl EdgeBar { - /// The bar itself — the lower quartile of the authored-edge cosines. + /// The lower quartile of the authored-edge cosines. fn bar(&self) -> f64 { self.q1 } - /// This vault's authored-edge pair cosines. Undirected and de-duplicated: an - /// edge read from both endpoints is one relation, and counting it twice - /// would weight the reciprocally-visible pairs double. `None` when the vault - /// has no scorable authored edge — the rule has no population, which is a - /// reading about the vault, not a failure of the instrument. + /// This vault's authored-edge pair cosines, undirected and de-duplicated. `None` when + /// the vault has no scorable authored edge. fn read(vault: &Vault, conn: &Connection) -> Result, Box> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut cos: Vec = Vec::new(); @@ -199,9 +156,7 @@ impl EdgeBar { if neighbors.is_empty() { continue; } - // Loaded once per source note and dropped with it: the population is - // read edge by edge rather than by caching the whole vault's - // vectors, so a large vault costs time here, never memory. + // Per source note, not cached, so a large vault costs time, not memory. let src: Vec> = b2_core::db::note_chunk_vectors(conn, ¬e.path)? .into_iter() .map(|(_, v)| v) @@ -219,10 +174,7 @@ impl EdgeBar { .into_iter() .map(|(_, v)| v) .collect(); - // Best-passage, the same statistic `similar` ranks on: the - // nearest chunk pair across the two notes. A pair with an - // unembedded side (or a dangling/resource target) scores nothing - // and drops out rather than entering the population as a zero. + // Best-passage, as `similar` ranks. An unembedded side drops out. let best = src .iter() .flat_map(|x| dst.iter().map(move |y| b2_core::embed::l2_sq(x, y))) @@ -246,8 +198,7 @@ impl EdgeBar { })) } - /// How many of `pool`'s leading candidates clear the bar — candidate 2's - /// fold, in the same prefix form candidate 1 takes. + /// How many of `pool`'s leading candidates clear the bar (a prefix fold). fn fold(&self, pool: &[(String, f64, Option)], limit: usize) -> usize { pool.iter() .take(limit) @@ -256,21 +207,14 @@ impl EdgeBar { } } -/// English function words, probed for **where they weigh** against a query's -/// content (GH #201). The lexical-anchor rule weighs each term by its IDF, so -/// its whole premise is that a vault's function words weigh near nothing beside -/// its subject words. That is a claim about a vault, not about English, and this -/// is where it is checked. +/// English function words (GH #201). The lexical-anchor rule assumes they weigh near nothing +/// in a vault's IDF; this checks that per vault. const FUNCTION_WORDS: [&str; 12] = [ "the", "and", "of", "to", "a", "in", "is", "it", "that", "for", "with", "on", ]; -/// Built-in nonsense queries — the **strict** half of D2's negative pile, and -/// the only half that can be built in: an off-topic *phrase* is off-topic -/// relative to a particular vault ("choreographing a ballroom waltz" is a -/// labelled negative on the eval corpus and would be a positive in a dancer's -/// vault), so the eval corpus is where those live. These are strings no vault -/// holds, which makes them vault-independent. +/// Built-in nonsense queries: the only vault-independent negatives, since an off-topic +/// phrase is only off-topic relative to a particular vault. const NONSENSE: [&str; 4] = [ "shjfasd", "vrelqip zonktar wembleforth", @@ -278,83 +222,66 @@ const NONSENSE: [&str; 4] = [ "zzzyqx", ]; -/// How many notes contribute a title-as-query positive. A cap rather than a -/// sample: the walk takes them in `list_notes` order, which is deterministic, so -/// a re-run on an unchanged vault reads the same queries. +/// How many notes contribute a title-as-query positive, in deterministic `list_notes` order. const MAX_TITLE_QUERIES: usize = 400; -/// One query's transfer reading — the same two absolute signals `make eval`'s -/// bake-off judges, read on a real vault instead of a labelled corpus. +/// One query's transfer reading: `make eval`'s two signals, on a real vault. struct SearchProbe { query: String, - /// Share of the query's term IDF this vault carries. `None` when nothing in - /// the query carries weight here. + /// Share of the query's term IDF this vault carries; `None` when no term has weight. coverage: Option, /// Dense top-1 cosine; `None` on a vault with no embedding space. best_cos: Option, - /// What the shipped bar would say — `true` = the default view vouches. + /// Whether the shipped bar vouches. vouched: bool, - /// For a title-as-query probe, the note the title came from — the one row - /// this bench can certify as relevant with no label (GH #206). `None` for - /// nonsense. + /// For a title query, the note it came from: the one row certified relevant without a + /// label (GH #206). own_path: Option, - /// The served list's per-hit provenance, in fused order — what the tail - /// families' constraints are read from. + /// The served list's per-hit provenance, in fused order. rows: Vec, } impl SearchProbe { - /// Where this probe's own note landed in the served list, 0-based — `None` - /// when retrieval never served it within the limit (or the probe has no own - /// note at all, which is every nonsense negative). + /// Where this probe's own note landed in the served list, 0-based. fn own_rank(&self) -> Option { let own = self.own_path.as_ref()?; self.rows.iter().position(|r| &r.path == own) } } -/// One served row's per-hit provenance (GH #206) — `EvidencedResult`, shorn of -/// the display fields this instrument never prints. +/// One served row's per-hit provenance (GH #206). struct TailRow { path: String, bm25_rank: Option, cos: Option, } -/// One tail family's edge: the tightest constant that still hides no own note, -/// and the served row that pins it there. -/// -/// Owned rather than borrowed out of the probe pile: the edge outlives the fold -/// that finds it — it is carried to both renderings — and a lifetime here would -/// buy nothing but the borrow. +/// One tail family's edge: the tightest constant that hides no own note, and the row that +/// pins it. struct TailEdge { value: f64, query: String, path: String, } -/// A row served above its own note carrying **no finite cosine** — the reading -/// that kills a cosine family outright rather than merely constraining it. +/// A row served above its own note with no finite cosine, which kills a cosine family. struct DeadRow { query: String, path: String, - /// Dense-only besides, which kills the two-signal family too: no lexical - /// rank and no finite cosine passes at any bar. + /// Also dense-only, which kills the two-signal family too. dense_only: bool, } -/// An own note the dense-only fold would hide: a row the lexical half never -/// ranked, served *above* the one row this bench certifies. +/// An own note the dense-only fold would hide, behind a row the lexical half never ranked. struct LexHidden { query: String, - /// 0-based, like every other rank this instrument reports (the text block - /// prints them +1). + /// 0-based, like every rank here (printed +1). own_rank: usize, dense_only_rank: usize, } -/// The per-hit tail families (GH #206) priced on this vault: how tight each -/// family's constant could be drawn before it hides a row the bench certifies. +/// The per-hit tail families (GH #206) priced on this vault: how tight each constant can be +/// before it hides an own note. struct TailReading { own_served: usize, own_missed: usize, @@ -368,15 +295,9 @@ struct TailReading { } impl TailReading { - /// `make eval`'s tail bake-off derives each family's admissible window from - /// the labelled corpora; this is the reading that says whether such a window - /// survives a real vault (process rule 5 — owed even by the parameterless - /// family, whose "lexical half never ranked it" signal is partly a fact - /// about pool depth against vault size). No labels here: the one served row - /// a title query certifies is its **own note**, so each family is priced on - /// what its constant would have to be to hide none of them — the tripwire - /// direction. The fold is a prefix cut (D1), so every row served above an - /// own note must pass the family's test too. + /// Whether `make eval`'s tail windows survive a real vault (process rule 5). Each + /// family is priced so it hides no title query's own note; the fold is a prefix cut + /// (D1), so every row above an own note must pass too. fn read(positives: &[SearchProbe]) -> Self { let mut r = Self { own_served: 0, @@ -397,8 +318,7 @@ impl TailReading { continue; }; r.own_served += 1; - // The drop family's reference is the list's own best served cosine — - // the whole list's, not the prefix's, matching how the fold would read. + // The whole list's best cosine, not the prefix's, as the fold reads it. let best = p .rows .iter() @@ -413,9 +333,7 @@ impl TailReading { }); } for row in prefix { - // Finiteness-filtered (PR #212 review): a NaN cosine is *no - // reading*, and `is_none_or` would otherwise let a NaN first row - // seed an edge. It lands in the dead arm, named, never skipped. + // A NaN is no reading and would seed an edge via `is_none_or` (PR #212). match row.cos.filter(|c| c.is_finite()) { None => { if r.cos_dead.is_none() { @@ -452,8 +370,7 @@ impl TailReading { } } -/// The half of the search bench that needs a calibrated bar: everything below -/// the model-free function-word reading, which is judged against one. +/// The half of the search bench that needs a calibrated bar. struct JudgedSearch { bar: b2_core::search::EvidenceBar, positives: Vec, @@ -461,30 +378,23 @@ struct JudgedSearch { tail: TailReading, } -/// The whole search-side reading, **computed once**. The text block and the -/// `--json` object are two renderings of this one value rather than two -/// computations of it, so a sweep that scripts the JSON and a human reading the -/// table cannot be looking at different numbers. +/// The whole search-side reading, computed once so the text and `--json` renderings can't +/// disagree. struct SearchReading { - /// The embedding space the reading was taken in — carried so the renderer - /// names the same model the bar was looked up for, never one passed beside it. + /// The model the bar was looked up for. model_id: String, - /// Chunks in the index — the scale every weight below is read against. chunk_total: usize, - /// The weight a word the vault has never seen would carry: the scale the - /// function words are read against, since that is what an absent *content* - /// word contributes to the same sum. + /// The weight of a word the vault lacks: the scale function words are read against. absent_idf: f64, /// Each [`FUNCTION_WORDS`] entry with the weight it carries here. function_words: Vec<(String, f64)>, - /// `None` when the active model has no calibrated bar — the piles need one - /// to be judged, so none of them was read. + /// `None` when the model has no calibrated bar. judged: Option, } impl SearchReading { - /// The heaviest function word's weight — the anchor test's premise, which - /// holds only where it is small beside [`Self::absent_idf`]. + /// The heaviest function word's weight; the anchor test needs it small beside + /// [`Self::absent_idf`]. fn heaviest(&self) -> f64 { self.function_words .iter() @@ -492,36 +402,23 @@ impl SearchReading { .fold(0.0_f64, f64::max) } - /// That weight as a share of an absent word's, in percent — the printed form. + /// That weight as a percentage of an absent word's. fn heaviest_share(&self) -> f64 { 100.0 * self.heaviest() / self.absent_idf.max(f64::EPSILON) } } -/// The **search evidence transfer check** (ADR-0015) — process rule 5's bench for the -/// query-level bar, which is a distributional constant and therefore invalid until a real -/// vault has answered for it. -/// -/// It needs no labels, and that is the point: the positives are each note's own **title**, a -/// query the vault demonstrably holds material for by construction, and the negatives are -/// [`NONSENSE`]. Neither is a hand-label, so running this on someone's notes costs them -/// nothing and the reading cannot be tuned by relabelling. -/// -/// It **can** see the tripwire direction — a bar that cuts queries a real vault holds -/// material for is the failure ADR-0014 punished. It **cannot** see the paraphrase case, -/// which needs judgement and is what the labelled corpus is for. Read the two together. +/// The search evidence transfer check (ADR-0015): process rule 5's bench for the query-level +/// bar. Label-free: positives are note titles, negatives are [`NONSENSE`]. It sees the +/// tripwire direction but not paraphrase, which is the labelled corpus's job. fn read_search_transfer( vault: &Vault, conn: &Connection, model_id: &str, limit: usize, ) -> Result> { - // The function-word reading is **model-free**: it is a fact about this - // vault's vocabulary, and it is the lexical anchor's whole premise. It is - // read before the bar lookup, and printed above it, because gating it behind - // a calibrated bar (as this did until the PR #205 review) left a - // fake-embedded vault printing nothing at all, which the recipe's own help - // text promised it would not. + // The function-word reading is model-free, so it is read before, and without, a + // calibrated bar (PR #205). let probe = b2_core::search::lexical_evidence(conn, &FUNCTION_WORDS.join(" "))?; let reading = SearchReading { model_id: model_id.to_string(), @@ -535,7 +432,6 @@ fn read_search_transfer( judged: None, }; - // Only the *verdicts* need a bar, so only they stop here. let Some(bar) = b2_core::search::EvidenceBar::for_model(model_id) else { return Ok(reading); }; @@ -546,12 +442,9 @@ fn read_search_transfer( let view = vault.search_evidence(query, limit)?; Ok(SearchProbe { query: query.to_string(), - // The engine's own term weights — `make eval`'s labelled bake-off is - // where the formula is independently restated and drift-checked. coverage: term_coverage(&view), best_cos: view.best_cos, - // The engine's own verdict, not a restatement of it: this bench - // prices what would actually ship. + // The engine's verdict: this bench prices what would ship. vouched: view.vouched.unwrap_or(true), own_path, rows: view @@ -595,8 +488,7 @@ fn read_search_transfer( }) } -/// The search reading as the text block — the human half of what -/// [`read_search_transfer`] measured. +/// The search reading as the text block. fn print_search_transfer(reading: &SearchReading) { println!(); println!("search evidence transfer check (D2 — process rule 5's bench for GH #201's bar)"); @@ -668,9 +560,7 @@ fn print_search_transfer(reading: &SearchReading) { if cut.len() > 10 { println!(" … and {} more", cut.len() - 10); } - // The closest calls, not a sample: a bar is placed against the queries that - // nearly miss, and on a vault of any size those are the ten weakest — the - // same reason the eval prints piles rather than means. + // The ten weakest, since a bar is placed against the near misses. let mut weakest: Vec<&SearchProbe> = positives.iter().collect(); weakest.sort_by(|a, b| { a.best_cos @@ -761,8 +651,6 @@ fn print_search_transfer(reading: &SearchReading) { None => bar_line("cos ≥ c", &tail.cos_edge, false), } match &tail.cos_dead { - // A dead row that is also dense-only kills the two-signal family too: - // no lexical rank and no finite cosine passes at no bar. Some(d) if d.dense_only => println!( " lex-or-cos ≥ c DEAD — {} → {} is dense-only with no finite cosine", truncate(&d.query, 32), @@ -781,8 +669,7 @@ fn print_search_transfer(reading: &SearchReading) { println!(" where the joint corpus edge also hides nothing here (process rule 5)"); } -/// A fixed-precision optional reading, or an em dash where there is none — the -/// one spelling of "no reading" the table uses. +/// A fixed-precision optional reading, or an em dash. fn fmt_opt(v: Option, places: usize) -> String { v.map(|c| format!("{c:.places$}")) .unwrap_or_else(|| "—".to_string()) @@ -827,14 +714,11 @@ fn run() -> Result<(), Box> { "usage: calibrate [--limit N] [--leader-z Z] [--member-z Z] [--mutual-k N] \ [--search] [--json]", )?; - // The reciprocity depth defaults to the simulated pane size, resolved after - // parsing so flag order can't matter. + // Resolved after parsing so flag order can't matter. let mutual_k = mutual_k_flag.unwrap_or(limit); - // A pure read over stored vectors, so the fake-embedder open is correct — the - // same posture as `b2 similar`. The recorded identity is read straight from - // the index, because it is what the vectors actually are, not what an - // injected embedder would be. + // A pure read of stored vectors, so the fake-embedder open is correct (as `b2 similar`). + // The model identity comes from the index: it is what the vectors actually are. let vault = Vault::open(&vault_root)?; let conn = b2_core::open(&vault_root.join(".b2").join("b2.sqlite"))?; let Some((model, dim)) = b2_core::db::recorded_embedder(&conn)? else { @@ -855,21 +739,15 @@ fn run() -> Result<(), Box> { for note in ¬es { let cands = vault.similar(¬e.path, SCAN_LIMIT)?; if cands.is_empty() { - // No stored vectors on the anchor, or nothing unlinked with vectors - // to compare against — a genuinely empty candidate set, named rather - // than averaged in as a zero. + // Named rather than averaged in as a zero. poolless.push(note.path.clone()); } else { readings.push(AnchorReading::from_candidates(¬e.path, &cands)); } } - // The mutual-k reciprocity fold, replayed (GH #200). Every note's own top `mutual_k` - // candidate paths come from the same full-depth pools just read, so reciprocity costs no - // extra discovery pass. A candidate with no pool of its own cannot reciprocate. The fold - // is the ranked list's longest reciprocal prefix, capped at `limit` — prefix form is - // ADR-0014's admissibility requirement, since a fold that skipped rank 2 to admit rank 5 - // would visibly disagree with the row order. + // The mutual-k reciprocity fold (GH #200), from the pools just read: the longest + // reciprocal prefix, capped at `limit`. Prefix form is ADR-0014's requirement. let top_of: HashMap<&str, HashSet<&str>> = readings .iter() .map(|r| { @@ -900,16 +778,11 @@ fn run() -> Result<(), Box> { }) .collect(); - // Candidate 2 of the same bake-off (GH #200): the authored-edge reference - // bar, priceable only where the human has committed edges — which is why it - // is measured here and not in `make eval` (both eval corpora are link-free - // by construction, so the rule has no population there). + // Candidate 2 (GH #200), the authored-edge reference bar. let edge_bar = EdgeBar::read(&vault, &conn)?; - // The search-side bench (GH #201), opt-in because it is the one part of this - // instrument that is **not** a pure read: judging the cosine half means - // embedding a query, which means loading the real model. The lexical half - // needs no model at all, so a fake-embedded vault still gets that much. + // The search bench (GH #201) is opt-in because the cosine half loads the real model to + // embed queries. A fake-embedded vault still gets the lexical half. let read_search = || -> Result> { if model == b2_core::embed::FAKE_MODEL_ID { read_search_transfer(&vault, &conn, &model, limit) @@ -1111,16 +984,14 @@ fn run() -> Result<(), Box> { ); } - // The table comes first and the model loads after it, so the discovery - // reading is on screen while the embedder is read off disk. + // After the table, so the discovery reading shows while the model loads. if search { print_search_transfer(&read_search()?); } Ok(()) } -/// The same reading as one JSON object on stdout — for scripting a sweep (the A7 -/// population-size sweep, a Phase-2 bake-off harness) without scraping the table. +/// The same reading as one JSON object on stdout, for scripting a sweep. #[allow(clippy::too_many_arguments)] fn print_json( vault_root: &std::path::Path, @@ -1141,12 +1012,9 @@ fn print_json( "dim": dim, "limit": limit, "gate": { "leader_z": gate.leader_z, "member_z": gate.member_z, "min_population": MIN_POPULATION }, - // The replayed fold's depth (GH #200): candidate B is reciprocal iff - // the anchor sits in B's own top `mutual_k`; `fold_serves` below is the - // ranked list's longest reciprocal prefix, capped at `limit`. + // The replayed fold's depth (GH #200). "mutual_k": mutual_k, - // Candidate 2's population and the bar read off it (GH #200) — `null` - // on a link-free vault, which is the rule's own reading there. + // `null` on a link-free vault (GH #200). "edge_bar": edge_bar.as_ref().map(|b| serde_json::json!({ "n": b.n, "bar": r4(b.bar()), @@ -1172,39 +1040,24 @@ fn print_json( })).collect::>(), })).collect::>(), "no_pool": poolless, - // Additive beside the discovery object, never redefining a key of it - // (GH #219): `null` means `--search` was not asked for, which is a fact - // about the invocation, not about the vault. + // Additive (GH #219); `null` means `--search` was not asked for. "search": search.as_ref().map(search_json), }); println!("{row}"); } -/// The search-side bench as JSON (GH #219) — the same reading -/// [`print_search_transfer`] renders as text. -/// -/// It follows the discovery object's **re-derivability** convention: every -/// summary the text block prints is left to the consumer, and what is emitted is -/// what the summary was computed *from* — each probe's own query, coverage, -/// cosine and the engine's own `vouched`, plus every served row's provenance. A -/// sweep that wants "how many titles did the bar cut" counts them; a sweep that -/// wants something the table never printed can have it too. -/// -/// The one exception is the tail families, which are emitted **derived**: a -/// family's edge is an extremum over the whole positive pile, and a consumer -/// re-deriving it would be re-implementing the fold's admissibility rule rather -/// than reading a number off it. They are re-derivable from `positives` all the -/// same. +/// The search-side bench as JSON (GH #219). Emits the per-probe inputs rather than the text +/// block's summaries, except the tail families' edges, which are emitted derived so a +/// consumer need not re-implement the fold. fn search_json(reading: &SearchReading) -> serde_json::Value { let probe = |p: &SearchProbe| { serde_json::json!({ "query": p.query, "coverage": p.coverage.map(r4), "best_cos": p.best_cos.map(r4), - // The engine's verdict, not a restatement of it. "vouched": p.vouched, "own_path": p.own_path, - // 0-based, like `bm25_rank`; `null` = never served within `limit`. + // 0-based; `null` when not served within `limit`. "own_rank": p.own_rank(), "rows": p.rows.iter().map(|r| serde_json::json!({ "path": r.path, @@ -1213,9 +1066,7 @@ fn search_json(reading: &SearchReading) -> serde_json::Value { })).collect::>(), }) }; - // A family reads one of three ways, exactly as the text block prints it: - // `needs` (this row pins the constant), `unconstrained` (no row engages the - // test), `dead` (a row no constant can admit). + // A family is `needs`, `unconstrained` or `dead`, as the text block prints it. let edge = |e: &Option| match e { None => serde_json::json!({ "status": "unconstrained" }), Some(e) => serde_json::json!({ @@ -1234,9 +1085,6 @@ fn search_json(reading: &SearchReading) -> serde_json::Value { }; serde_json::json!({ "chunk_total": reading.chunk_total, - // The lexical anchor's premise, model-free and read per vault: what a - // word this vault has never seen weighs, and what each function word - // weighs beside it. "function_words": { "absent_idf": r4(reading.absent_idf), "heaviest_share_pct": r4(reading.heaviest_share()), @@ -1245,12 +1093,7 @@ fn search_json(reading: &SearchReading) -> serde_json::Value { "idf": r4(*idf), })).collect::>(), }, - // One key, not four: the bar and the three piles read against it exist - // together or not at all, so they nest under the `Option` that decides - // it rather than each carrying its own `null` for a consumer to test. - // `null` = no calibrated bar for this model, so nothing under it was - // read: the piles need one to be judged (M2). The function-word reading - // above is model-free and is there either way. + // `null` when the model has no calibrated bar (M2). "judged": reading.judged.as_ref().map(|j| serde_json::json!({ "bar": { "min_term_coverage": j.bar.min_term_coverage, @@ -1277,8 +1120,7 @@ fn search_json(reading: &SearchReading) -> serde_json::Value { Some(d) => dead(d), None => edge(&j.tail.cos_edge), }, - // A dead row that is also dense-only kills the two-signal - // family too; one that is not leaves it constrained. + // Dead only when the dead row is also dense-only. "lex_or_cos": match &j.tail.cos_dead { Some(d) if d.dense_only => dead(d), _ => edge(&j.tail.lexcos_edge), @@ -1293,9 +1135,7 @@ fn search_json(reading: &SearchReading) -> serde_json::Value { }) } -/// Four decimals, the one rounding this object applies — enough to reproduce -/// every printed reading, short of dumping a float's full noise into a dataset -/// meant to be diffed. +/// Four decimals: enough to reproduce every printed reading. fn r4(x: f64) -> f64 { (x * 1e4).round() / 1e4 } diff --git a/crates/b2-embed/examples/common/mod.rs b/crates/b2-embed/examples/common/mod.rs index d3e82a0..f3082b2 100644 --- a/crates/b2-embed/examples/common/mod.rs +++ b/crates/b2-embed/examples/common/mod.rs @@ -1,17 +1,11 @@ //! Helpers the eval-harness examples share (`eval`, `calibrate`, `stability`, and //! `b2-llm`'s `groundedness`, which pulls this file in by `#[path]`). //! -//! Cargo discovers examples as `examples/*.rs` and `examples/*/main.rs`, so this -//! directory is a module, never an example of its own. Every example that says -//! `mod common;` compiles the whole file and uses a different subset of it, hence -//! the `dead_code` allowance — the same posture as `b2-core`'s `tests/common`. +//! Each example uses a different subset, hence the `dead_code` allowance. //! -//! What lives here is plumbing and the harness's **own** restatements of engine -//! maths. The restatements stay restatements on purpose: they exist so an -//! instrument can cross-check the engine, and a call into the engine would turn -//! each check into a comparison of a number with itself. Nothing here may import -//! beyond what `b2-llm`'s dev-dependencies also carry (b2-core, b2-embed, -//! serde_json, tempfile). +//! Engine maths restated here stays restated: calling the engine would make each +//! cross-check compare a number with itself. Import nothing beyond `b2-llm`'s +//! dev-dependencies (b2-core, b2-embed, serde_json, tempfile). #![allow(dead_code)] use b2_core::vault::{NoteSummary, SearchEvidenceView}; @@ -19,12 +13,8 @@ use b2_embed::{provision, EmbedConfig, LocalEmbedder}; use std::error::Error; use std::path::{Path, PathBuf}; -/// The strength-band landmarks the desktop paints (`ui/src/strength.ts`, GH #182): -/// `●●●` at or above the labelled-mate population's upper quartile, `●●○` at or -/// above the retired leader bar. Restated constants, not imports — the bands are -/// UI copy, and `make eval`'s calibration block is the instrument their values are -/// re-measured by (and what a loner anchor's always-served cards *claim*, GH #197's -/// A2 readout). +/// The strength-band z landmarks the desktop paints (`ui/src/strength.ts`, GH #182), +/// restated because they are UI copy; `make eval`'s calibration block re-measures them. pub const BAND_STRONG_Z: f64 = 2.52; pub const BAND_CLEAR_Z: f64 = 1.96; @@ -58,13 +48,9 @@ impl Band { } } -/// Z-score a population of squared distances, oriented nearer = higher — the -/// harness's own restatement of the arithmetic `discover::candidates` applies to -/// the stage-2 best-pair distances (GH #192), kept so the engine's z can be -/// cross-checked rather than merely trusted. `None` when no meaningful statistic -/// exists (under two values, or zero variance), mirroring the engine's own -/// inertness guard; a caller replaying a larger minimum population applies it -/// before calling. +/// Z-score squared distances, nearer = higher: a restatement of `discover::candidates` +/// (GH #192) to cross-check the engine. `None` under two values or at zero variance; a +/// caller with a larger minimum population applies it first. pub fn passage_z(d2: &[f64]) -> Option> { if d2.len() < 2 { return None; @@ -76,10 +62,8 @@ pub fn passage_z(d2: &[f64]) -> Option> { (sd > 0.0).then(|| d2.iter().map(|d| (mean - d) / sd).collect()) } -/// A `similar` score is negated L2 distance between L2-normalized vectors (the -/// real embedder normalizes every row), so it converts exactly: `cos = 1 − d²/2`. -/// Cosine is the unit the floor ruling is stated in and the unit that survives a -/// model swap comparison, so the piles are recorded in it. +/// A `similar` score is negated L2 distance between normalized vectors, so +/// `cos = 1 − d²/2` exactly. Piles are recorded in cosine, which compares across models. pub fn cosine_of(score: f64) -> f64 { 1.0 - (score * score) / 2.0 } @@ -100,12 +84,9 @@ pub fn pile_stats(pile: &[f64]) -> Option<(f64, f64, f64)> { Some((sorted[0], median, sorted[sorted.len() - 1])) } -/// A note's title as a search query: its frontmatter title, else its file slug with -/// `-`/`_` read as spaces (`drone-comb` → "drone comb"). `None` when that is blank. -/// -/// Titles are the label-free positives both search benches share (GH #201): a -/// note's own title names material the vault demonstrably holds, so nothing read -/// off one can be relabelled to clear it. +/// A note's title as a search query: frontmatter title, else the file slug with `-`/`_` as +/// spaces. `None` when blank. Titles are the label-free positives the search benches share +/// (GH #201). pub fn title_query(note: &NoteSummary) -> Option { let title = note.title.clone().unwrap_or_else(|| { Path::new(¬e.path) @@ -116,14 +97,9 @@ pub fn title_query(note: &NoteSummary) -> Option { (!title.trim().is_empty()).then_some(title) } -/// The share of a query's term IDF the vault carries, read off the view's own -/// per-term weights ([`b2_core::vault::QueryTermView::idf`]) rather than a second -/// copy of the formula. `None` when no term carries any weight (the lexical half -/// abstaining, not scoring zero). -/// -/// `make eval`'s labelled bake-off keeps its own restatement of this arithmetic -/// on purpose, as a drift check against the engine; one such check is the check, -/// so every other bench reads the engine's weights through here. +/// The share of a query's term IDF the vault carries, from the engine's own per-term +/// weights. `None` when no term has weight (the lexical half abstaining, not scoring zero). +/// `make eval`'s bake-off keeps the one independent restatement, as a drift check. pub fn term_coverage(view: &SearchEvidenceView) -> Option { let total: f64 = view.terms.iter().map(|t| t.idf).sum(); (total > f64::EPSILON).then(|| { @@ -136,11 +112,8 @@ pub fn term_coverage(view: &SearchEvidenceView) -> Option { }) } -/// A throwaway vault holding a copy of one **flat** corpus directory — the eval -/// corpora are single-level, so only regular files are copied: `fs::copy` errors -/// on a directory, and a future subfolder (or any stray non-file) must not abort -/// the run. The temp dir lives exactly as long as this value, so declare the -/// `Vault` opened on [`Self::root`] after it (locals drop in reverse order). +/// A throwaway vault copying the regular files of one flat corpus directory. The temp dir +/// lives as long as this value, so declare the `Vault` opened on [`Self::root`] after it. pub struct ScratchVault { _tmp: tempfile::TempDir, root: PathBuf, @@ -165,14 +138,8 @@ impl ScratchVault { } } -/// Load the configured embedding model, provisioning it first only when it is -/// not already loadable. -/// -/// `provision`'s idempotent fast path *is* a full model load, so calling it and -/// then loading again pays for the model twice on every warm run. Loading first -/// is the same decision in the other order: a model that loads is exactly what -/// the fast path would have accepted, and one that doesn't goes through -/// `provision` (fetch + verify) before the load that follows. +/// Load the configured model, provisioning only when it won't load. Loading first avoids +/// paying for two loads per warm run, since `provision`'s fast path is itself a full load. pub fn load_or_provision(config: &EmbedConfig) -> Result> { if let Ok(embedder) = LocalEmbedder::load(config) { return Ok(embedder); @@ -186,12 +153,8 @@ pub fn has_flag(args: &[String], name: &str) -> bool { args.iter().any(|a| a == name) } -/// A typo'd flag must not run a *different* measurement than the one asked for. -/// -/// `known` lists every accepted flag; `valued` the ones among them that take a -/// value as the next argument (`--vault path`; the `--vault=path` form needs no -/// entry). Positional arguments are refused too — every flag-driven example here -/// takes none. +/// Refuse unknown flags and positionals, so a typo can't run a different measurement. +/// `valued` lists the flags that take the next argument as a value. pub fn reject_unknown_flags( args: &[String], known: &[&str], @@ -213,8 +176,7 @@ pub fn reject_unknown_flags( if !known.contains(&name) { return Err(format!("unknown flag {arg:?}; known: {}", known.join(" ")).into()); } - // `--sweep=1` would pass the name check and then match no `has_flag` — - // the same silent wrong measurement as a typo, so a switch takes no value. + // `--sweep=1` would match no `has_flag`, so a switch takes no value. if inline_value && !valued.contains(&name) { return Err(format!("flag {name} takes no value (got {arg:?})").into()); } @@ -223,8 +185,7 @@ pub fn reject_unknown_flags( Ok(()) } -/// Append one row to a results log (creating it on first run). Append-only, so -/// runs accumulate into one dataset — the same convention as `B2_LOG_FILE`. +/// Append one row to a results log, creating it on first run. pub fn append_result(path: &Path, row: &serde_json::Value) -> Result<(), Box> { use std::io::Write; let mut f = std::fs::OpenOptions::new() @@ -248,10 +209,8 @@ pub fn git_short_sha() -> Option { Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) } -/// `s` cut to at most `max` chars, an ellipsis marking the cut. Counted in chars -/// and cut on a char boundary (byte-index arithmetic lands mid-codepoint on text -/// with em dashes or °C), and saturating, so `max == 0` yields a bare ellipsis -/// rather than an underflow panic. +/// `s` cut to at most `max` chars with an ellipsis. Counts chars, not bytes, so it never +/// splits a codepoint. pub fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() diff --git a/crates/b2-embed/examples/eval/ablation.rs b/crates/b2-embed/examples/eval/ablation.rs index ec3aad0..9cc89ab 100644 --- a/crates/b2-embed/examples/eval/ablation.rs +++ b/crates/b2-embed/examples/eval/ablation.rs @@ -1,6 +1,5 @@ -//! The opt-in A/Bs, each judged against the default config's passes and logged as rows -//! of their own beside its row: `--stemmer` (the FTS tokenizer ablation, GH #157) and -//! `--sweep` (the in-process chunker sweep, GH #44). +//! The opt-in A/Bs, each judged against the default config's passes and logged as its own +//! row: `--stemmer` (FTS tokenizer, GH #157) and `--sweep` (chunker, GH #44). use crate::common::append_result; use crate::discovery::score_similar; @@ -13,37 +12,26 @@ use b2_core::db::FtsTokenizer; use b2_core::vault::Vault; use std::path::Path; -/// The default config's scored run — what every ablation row is judged against -/// (the paired per-query moves) and logged beside. A short-lived, read-only view -/// over values `run` owns, built once and handed to each A/B. +/// The default config's scored run, which every ablation row is judged against. A +/// short-lived, read-only view over values `run` owns. #[derive(Clone, Copy)] pub struct Baseline<'a> { - /// The results log every row is appended to. pub log: &'a Path, pub run: RunId<'a>, pub notes: usize, pub chunks: usize, pub embed_secs: f64, - /// The labelled positives every pass below was scored over. pub queries: &'a [Labelled], pub bm25: &'a Pass, pub vector: &'a Pass, pub hybrid: &'a Pass, } -/// The FTS tokenizer ablation (the #157 instrument). +/// The FTS tokenizer ablation (GH #157): shipped `porter unicode61` against unstemmed +/// `unicode61` over identical chunks and vectors. Discovery never touches FTS, so it isn't +/// re-scored; the dense ablation is, as a check that the harness itself is sound. /// -/// One lever, isolated: `rebuild_fts` swaps the tokenizer over the identical -/// chunk rows and vectors — the shipped `porter unicode61` against the -/// unstemmed `unicode61` the A/B retired, kept measurable so the verdict can -/// be re-tried as the corpus grows. Discovery is deliberately not re-scored — -/// `similar` never touches FTS (centroid shortlist + chunk vectors), so its -/// numbers cannot move and the ablation row records no `similar` keys. The -/// dense ablation IS re-scored, as an instrument check: FTS cannot reach it -/// either, so a moved dense rank means the harness is broken, not the engine. -/// -/// `bm25_unstemmed` is the lexical arm, which `run` scores while the vault is still -/// projected-but-unembedded (so `search` is honestly BM25-only under both tokenizers). +/// `bm25_unstemmed` is scored by `run` before embedding, so it is BM25-only. pub fn stemmer( vault: &Vault, base: &Baseline, @@ -102,8 +90,7 @@ pub fn stemmer( }, ), )?; - // Hand the vault back untainted, so a `--sweep` in the same run (and the - // reference numbers above) stay under the shipped default tokenizer. + // Restore the shipped tokenizer for a `--sweep` in the same run. vault.rebuild_fts(FtsTokenizer::PorterUnicode61)?; Ok(()) } @@ -116,14 +103,9 @@ pub fn sweep( sim_set: &SimilarSet, ) -> Result<(), Box> { let positives = base.queries; - // The #44 grid: both directions on each swept knob, plus the one - // interaction worth a row. `target_tokens` brackets the 450 default - // (250 / 350 / 600); `overlap_frac` brackets 0.15 (0.0 / 0.30); - // `target-250+heading-path` exists because a smaller chunk carries less - // of its own context, which is exactly when the breadcrumb prefix (D3) - // is most plausibly worth its tokens. `chars_per_token` and - // `backscan_tokens` stay unswept: they are calibration constants of the - // token proxy and the boundary search, not retrieval-quality levers. + // The #44 grid brackets each knob's default. `target-250+heading-path` pairs the heading + // prefix (D3) with small chunks, where it most plausibly helps. `chars_per_token` and + // `backscan_tokens` are calibration constants, not quality levers, so stay unswept. let variants: Vec<(&str, ChunkConfig)> = vec![ ( "target-250", @@ -178,12 +160,7 @@ pub fn sweep( ]; println!("\n{}", "=".repeat(78)); println!("chunker sweep (same model, same corpus; default row above for reference)"); - // `mate MRR` rather than the per-anchor `similar h@3` this column used - // to carry: that number saturates (GH #183), so as a *comparison* - // column across variants it could only ever print 1.00 (GH #188). - // `strangers` replaced `neg clean` when GH #197 retired the existence - // gate: under always-serve a negative anchor always serves, so that - // column could only ever print 0/5 — the same cannot-move failure. + // Per-mate MRR and strangers, because per-anchor hit@3 saturates (GH #183, #188). println!( "{:<24} {:>7} {:>8} note h@1/MRR vec h@1/MRR chunk h@1/MRR mate MRR strangers", "config", "chunks", "embed_s" @@ -209,10 +186,8 @@ pub fn sweep( sim.mate.mrr(), sim.strangers.len(), ); - // The readout the A/B is actually judged on: at this n every aggregate - // delta above is 1–2 queries, so the aggregate is a smoke alarm and the - // per-query win/loss list is the data (docs/evals.md, the - // process rules). + // What the A/B is judged on: at this n an aggregate delta is 1–2 queries, so the + // per-query moves are the data (docs/evals.md). print_rank_moves(positives, base.hybrid, &pass); append_result( base.log, diff --git a/crates/b2-embed/examples/eval/dense.rs b/crates/b2-embed/examples/eval/dense.rs index 32823cc..9ebece6 100644 --- a/crates/b2-embed/examples/eval/dense.rs +++ b/crates/b2-embed/examples/eval/dense.rs @@ -17,20 +17,15 @@ use b2_core::embed::Embedder; use b2_core::vault::Vault; use std::path::Path; -/// Score the dense single-domain fixture (GH #196/#197, Phase 0b) in a throwaway -/// vault of its own: per-mate discovery ranks against `similar-dense.json`, and -/// the empty-pane sweep across **every** note in the fixture — not only the -/// labelled anchors, because the assertion is about the surface ("no pane in a -/// dense vault is dark"), not about the labels. +/// Score the dense fixture in its own vault: per-mate ranks against `similar-dense.json`, +/// and the empty-pane sweep over every note, since that assertion is about the surface, not +/// the labels. pub fn score_dense( evals_dir: &Path, set: &SimilarSet, embedder: SharedEmbedder, ) -> Result> { let corpus_dir = evals_dir.join("corpus-dense"); - // The run's one loaded model, shared rather than reloaded: the fixture's whole - // point is an isolated run, and that isolation is the vault's — its own - // throwaway copy, its own index — not a second copy of the same weights. let model_id = embedder.model_id().to_string(); let scratch = ScratchVault::copy_flat(&corpus_dir)?; let vault = Vault::open_with_embedder(scratch.root(), Box::new(embedder))?; @@ -45,24 +40,19 @@ pub fn score_dense( mate_ranks: Vec::new(), empty_panes: Vec::new(), detail: Vec::new(), - // The bake-off's absolute bench (GH #200): every note is an anchor here, - // because the ruling being tested is about the *surface* — a vault where - // everything relates may never default to "nothing relates" — and the - // labels cover only a few of these notes. + // Every note is an anchor (GH #200): the ruling is about the surface. fold: score_fold(&vault, set, "dense", true)?, - // The bar's hardest bench, for the same reason the fold's is: this is the - // geometry that disqualified the rule that lost (GH #201). + // The geometry that disqualified the losing rule (GH #201). search: score_dense_search(&vault, &model_id)?, }; - // The pane sweep: every note is an anchor, labelled or not. + // Every note is an anchor, labelled or not. for note in vault.list_notes()? { pass.notes += 1; if vault.similar(¬e.path, SIM_K)?.is_empty() { pass.empty_panes.push(note.path); } } - // Per-mate ranks on the labelled anchors, the orthogonal corpus's metric - // re-used verbatim (GH #183's non-saturating readout). + // Per-mate ranks on the labelled anchors (GH #183). for label in &set.anchors { let candidates = vault.similar(&label.anchor, SIM_K)?; for expected in &label.expected { @@ -94,82 +84,51 @@ pub struct DensePass { pub notes: usize, pub chunks: usize, pub embed_secs: f64, - /// Per-mate ranks at [`SIM_K`] — the fixture's rank metric. + /// Per-mate ranks at [`SIM_K`]. pub mate: Agg, /// (anchor, mate, rank) per labelled mate, for the printed lines and the row. pub mate_ranks: Vec<(String, String, Option)>, - /// Notes whose discovery pane served nothing — asserted empty (GH #196/#197). + /// Notes whose discovery pane served nothing; asserted empty (GH #196/#197). pub empty_panes: Vec, pub detail: Vec, - /// The fold bake-off on this fixture (GH #200) — swept over **every** note, - /// which is where the candidates' hardest bench is: a rule whose default - /// view goes dark on a single-domain vault is disqualified, not re-tuned. + /// The fold bake-off over every note (GH #200): a rule whose default view goes dark here + /// is disqualified, not re-tuned. pub fold: FoldBench, - /// D2's shipped bar replayed on this fixture (GH #201) — see - /// [`score_dense_search`]. + /// D2's shipped bar replayed on this fixture (GH #201). pub search: DenseSearch, } -/// The shipped search evidence bar's reading **on the single-domain fixture** (ADR-0015). -/// -/// This exists because the bar's first form died here and nowhere else. A hard `df <= 10%` -/// content ceiling read 0 cut / 0 served on the labelled orthogonal corpus — clean by every -/// number that bench can produce — and then classed `drone` (df 3) and `comb` (df 7) as -/// stopwords in a vault about beekeeping, cutting 3 of 15 answerable queries. The lexical -/// rule's hazard is **topical concentration**, which the orthogonal corpus cannot express, so -/// a run that judges the bar only there judges it on the geometry it survives. -/// -/// The reading was first taken once, by hand. Taking it *once* is the thing GH #187 named, -/// so it is re-derived every run, here, beside the fold bench that already sweeps this -/// fixture. **In the exit gate** since GH #202, as a row of its own rather than headroom on -/// the labelled corpus's, because it watches a different *geometry*. +/// The shipped search evidence bar's reading on the single-domain fixture (ADR-0015). The +/// lexical rule's hazard is topical concentration, which the orthogonal corpus cannot +/// express. Gated since GH #202. pub struct DenseSearch { - /// Every note's own title replayed as a query — the **tripwire direction** - /// (D2: a labelled-relevant query cut is zero with no headroom). Titles need - /// no labels and so nothing here can be relabelled to clear a reading. + /// Every note's title replayed as a query: the tripwire direction (D2). pub titles: Vec, /// Nonsense, the defect direction. See [`DENSE_NONSENSE`]. pub nonsense: Vec, - /// `None` when the active model has no calibrated bar (M2) — the coverage - /// readings still print, the verdicts do not exist to print. + /// `None` when the model has no calibrated bar (M2); coverage still prints. pub bar: Option, } -/// One query's reading on the dense fixture: the two absolute signals D2 judges, -/// and the engine's own verdict rather than a restatement of it. +/// One query's reading on the dense fixture: D2's two signals and the engine's verdict. pub struct SearchProbe { pub query: String, - /// IDF-weighted term coverage; `None` when no term carries any weight (the - /// lexical half abstaining, not scoring zero). + /// IDF-weighted term coverage; `None` when no term carries weight. pub coverage: Option, pub best_cos: Option, - /// `Vault::search_evidence`'s verdict — what would actually ship. `None` - /// mirrors [`DenseSearch::bar`]. + /// `Vault::search_evidence`'s verdict. `None` mirrors [`DenseSearch::bar`]. pub vouched: Option, - /// The served list with its per-hit provenance — the tail bake-off's dense - /// bench (GH #206). On a title query `keep` is true for every row **by - /// geometry**, not by label: a single-domain vault's lists are all real - /// matches, so a tail rule that truncates one is disqualified — the same - /// absolute GH #200 enforced for discovery, and the reason this fixture - /// needs no `tail_relevant` labels. + /// The served list, for the tail bake-off (GH #206). On a title query every row is kept + /// by geometry, not label, so a tail rule that truncates one is disqualified. pub rows: Vec, } -/// The negatives replayed on the dense fixture: **nonsense only**. The labelled negatives in -/// `queries.json` are the *orthogonal* corpus's, and process rule 2's token audit is what -/// makes them negatives — an audit that says nothing about a different corpus. Against -/// `corpus-dense` the phrase-shaped ones disqualify themselves on their merits ("why parrots -/// mimic speech" shares `mimic` with `robbing-behavior.md`, which is a thing the rule -/// deliberately serves). Nonsense needs no audit in any vault, which is why it transfers. +/// The negatives replayed on the dense fixture: nonsense only. `queries.json`'s negatives +/// are audited against the orthogonal corpus and don't transfer; nonsense needs no audit. pub const DENSE_NONSENSE: [&str; 2] = ["shjfasd", "vrelqip zonktar wembleforth"]; -/// Replay the shipped bar over the dense fixture (see [`DenseSearch`]). -/// -/// Coverage is read off [`b2_core::vault::QueryTermView::idf`] — the view's own -/// weights, not a second copy of the formula ([`term_coverage`]). The orthogonal -/// corpus's bake-off re-derives its arithmetic deliberately, as a drift check -/// against the engine; one such check is the check, and a second would only be two -/// places to fix. +/// Replay the shipped bar over the dense fixture, reading coverage off the engine's weights +/// ([`term_coverage`]). pub fn score_dense_search( vault: &Vault, model_id: &str, @@ -195,9 +154,7 @@ pub fn score_dense_search( }; let mut titles = Vec::new(); for note in vault.list_notes()? { - // The fixture's notes carry no frontmatter title, so the slug is the - // query — `drone-comb` → "drone comb", which is the pair of words the - // retired ceiling called stopwords. + // No frontmatter titles here, so the slug is the query (`drone-comb`). if let Some(title) = title_query(¬e) { titles.push(read(&title, true)?); } @@ -217,12 +174,7 @@ pub fn print_dense_search(search: &DenseSearch) { println!( " search bar D2's shipped bar replayed on this geometry (GH #201; GATED since GH #202)" ); - // The coverage reading comes FIRST, and above the bar, because it is - // **model-free**: a fact about this vault's vocabulary, and the lexical - // half's whole premise. Gating it behind a calibrated bar would print - // nothing at all on a vault the harness can still say something true about — - // the defect PR #205's review already fixed in `calibrate.rs` (c03f8cd), met - // again here (PR #207 review). + // Coverage is model-free, so it prints before, and without, a calibrated bar (PR #207). let covs: Vec = search.titles.iter().filter_map(|p| p.coverage).collect(); let cov_line = match pile_stats(&covs) { Some((min, med, max)) => format!("{min:.2}/{med:.2}/{max:.2}"), @@ -230,7 +182,6 @@ pub fn print_dense_search(search: &DenseSearch) { }; println!(" title-as-query coverage min/med/max {cov_line}"); - // Only the *verdicts* below need a bar, so only they stop here. let Some(bar) = search.bar else { println!(" no calibrated bar for this model — no verdict is offered (M2)"); return; @@ -274,9 +225,8 @@ pub fn print_dense_search(search: &DenseSearch) { ); } -/// The dense fixture's search reading as JSON (`search_transfer` in the dense -/// row) — every probe, so any bar is re-derivable from a row without re-running -/// the model, the `discovery_fold` convention. +/// The dense fixture's search reading as JSON (`search_transfer`), every probe included so +/// any bar is re-derivable without re-running the model. pub fn dense_search_json(search: &DenseSearch) -> serde_json::Value { let probes = |pile: &[SearchProbe]| { pile.iter() @@ -286,9 +236,7 @@ pub fn dense_search_json(search: &DenseSearch) -> serde_json::Value { "coverage": p.coverage.map(|c| (c * 1e4).round() / 1e4), "best_cos": p.best_cos.map(|c| (c * 1e4).round() / 1e4), "vouched": p.vouched, - // NEW subkey (absent before GH #206): the served list's - // per-hit provenance, so the tail constraints below are - // re-derivable from a row without re-running the model. + // Absent before GH #206. "rows": p.rows.iter().map(|row| serde_json::json!({ "path": row.path, "bm25_rank": row.bm25_rank, @@ -307,8 +255,7 @@ pub fn dense_search_json(search: &DenseSearch) -> serde_json::Value { "nonsense_served": search.nonsense.iter().filter(|p| p.vouched == Some(true)).count(), "titles": probes(&search.titles), "nonsense": probes(&search.nonsense), - // NEW subkey (absent before GH #206): the per-hit tail families' - // dense-bench constraints — the absolute this fixture supplies. + // Absent before GH #206. "tail": dense_tail_json(&search.titles), }) } @@ -347,15 +294,12 @@ pub fn print_dense_report(dense: &DensePass) { ); } print_dense_search(&dense.search); - // Model-geometry reading, not a verdict, so it prints with or without a - // calibrated bar — the same posture as the coverage line above it. + // A geometry reading, not a verdict, so it prints without a calibrated bar too. print_dense_tail(&dense.search.titles); } -/// The dense fixture's own JSONL row. Tagged `"corpus": "dense"` — the key that -/// keeps rows from ever averaging across corpora (the orthogonal rows carry -/// `"corpus": "orthogonal"`); a smaller shape than the main row on purpose, -/// since the fixture scores discovery alone. +/// The dense fixture's own JSONL row, tagged `"corpus": "dense"` so rows never average +/// across corpora. pub fn dense_row(run: RunId, dense: &DensePass) -> serde_json::Value { let RunId { git, model, dim } = run; let ts = unix_secs(); @@ -374,13 +318,8 @@ pub fn dense_row(run: RunId, dense: &DensePass) -> serde_json::Value { })).collect::>(), "empty_panes": { "n": dense.notes, "empty": dense.empty_panes.len(), "detail": dense.empty_panes }, "discovery_fold": fold_json(&dense.fold), - // NEW key (absent from rows before 2026-08-22): the shipped search bar - // replayed on this fixture (GH #201). Deliberately NOT the orthogonal - // row's `search_evidence`: that key holds the *labelled* bake-off, and - // this is the label-free transfer reading — a different measurement, so - // it takes a different name. Same convention as every key above: new, - // never a redefinition, so no reader has to branch on `corpus` to learn - // which shape it is holding (PR #207 review). + // Absent before 2026-08-22 (GH #201). Not named `search_evidence`: that key is the + // labelled bake-off, and a key is never redefined across corpora (PR #207). "search_transfer": dense_search_json(&dense.search), "similar_detail": dense.detail.iter().map(|d| serde_json::json!({ "anchor": d.anchor, diff --git a/crates/b2-embed/examples/eval/discovery.rs b/crates/b2-embed/examples/eval/discovery.rs index a46ea76..7d4c6c0 100644 --- a/crates/b2-embed/examples/eval/discovery.rs +++ b/crates/b2-embed/examples/eval/discovery.rs @@ -8,145 +8,80 @@ use crate::metrics::{paths_match, Agg, Window}; use crate::{SIM_K, Z_SCAN_LIMIT}; use b2_core::vault::Vault; -/// A full pass over the discovery labels: rank aggregates over the positive -/// anchors, the suppression tally over the negative ones, and every surfaced -/// candidate's cosine sorted into the two calibration piles the quality floor -/// is read from (index-engine.md §3's ruling, PR #145). +/// A full pass over the discovery labels: rank aggregates, the negative-anchor tally, and +/// the cosine calibration piles (index-engine.md §3, PR #145). #[derive(Default)] pub struct SimilarPass { - /// Rank of the first `expected` hit per positive anchor — the pre-existing - /// hit@1 / hit@3 / MRR discovery metrics, unchanged. + /// Rank of the first `expected` hit per positive anchor. pub rank: Agg, - /// **Per-mate** ranks: one entry per `(anchor, expected mate)` pair rather than one per - /// anchor (GH #183). [`Self::rank`] takes the *first* labelled mate it finds and stops, - /// so an anchor scores a hit on its easiest one and every harder mate is invisible — - /// which is how that metric sat pinned at 1.000 across the ordering change it was - /// supposed to judge. Worse, *adding* a hard mate makes `rank` strictly easier. - /// - /// Scoring each mate on its own is the non-saturating readout GH #183 asked for: a mate - /// sliding from rank 2 to rank 4 moves this number even while `rank` stays at ceiling. A - /// `None` is honest — that mate did not surface at all. - /// - /// **In the exit gate** since GH #188, at [`FLOOR_MATE_MRR`](crate::gate::FLOOR_MATE_MRR). It shipped reporting-only - /// for one issue's worth of time on the measure-then-calibrate precedent, and that is - /// where the baseline came from. - /// - /// (A pass-vs-pass suppression diff — `mate_raw` / `mate_suppressed`, asserted at zero — - /// stood beside this until GH #217: once ADR-0014 retired the existence gate, both - /// passes read the *identical* `similar` call, so the diff compared a call against - /// itself and could never fire. An existence gate returning to the path is what the - /// dense fixture's zero-empty-panes assertion and both per-mate MRR floors catch — - /// against ground truth, not against a second read of the same surface.) + /// Per-mate ranks, one per `(anchor, expected mate)` pair (GH #183). [`Self::rank`] + /// stops at the easiest mate and saturates; this moves when any mate slides. Gated at + /// [`FLOOR_MATE_MRR`](crate::gate::FLOOR_MATE_MRR) (GH #188). pub mate: Agg, - /// **Strangers**: unlabelled notes the shipped surface serves on a *positive* anchor, - /// within the same top-`SIM_K` the ranks are read at — `(anchor, path)` per card, so the - /// smoke alarm comes with the list you argue against it with (process rule 1). - /// - /// This is discovery's **precision** side, and the harness had none: the gated numbers - /// watch mate ranks and negative anchors, and the negatives gate is structurally blind to - /// a member bar — while `member_z <= leader_z`, a negative anchor is clean iff its leader - /// is cut, so a relaxed member bar spends its entire cost here (GH #187/#188). - /// - /// Deliberately **reported, not gated**, and the reason is the gaming direction: the - /// cheapest way to shrink this count is to *label the stranger*, which silently moves the - /// per-mate metric too. The labels are not exhaustive, so an unlabelled note served is - /// not proof of junk — it reads as a smoke alarm with names attached. + /// Strangers: `(anchor, path)` for unlabelled notes served on a positive anchor within + /// `SIM_K`, discovery's precision side (GH #187/#188). Reported, not gated: the cheapest + /// way to shrink it is to label the stranger, and labels aren't exhaustive. pub strangers: Vec<(String, String)>, - /// Positive anchors serving at least one stranger — the spread behind - /// [`Self::strangers`], since one anchor with a long tail and five anchors - /// with one card each are different failures at the same count. + /// Positive anchors serving at least one stranger: the spread behind the count. pub stranger_anchors: usize, - /// Negative anchors asked. pub neg_n: usize, - /// Negative anchors that surfaced zero candidates. Under always-serve - /// (GH #197) an anchor with scorable candidates always serves, so this - /// reads 0 on this corpus — recorded for row comparability (the key's - /// meaning, "anchors serving nothing", is unchanged), no longer asserted: - /// the labels still say "nothing relates", and what the served cards - /// *claim* is measured by their bands (A2's readout, calibration block). + /// Negative anchors that surfaced zero candidates. Reads 0 under always-serve (GH #197); + /// kept for row comparability, not asserted. pub neg_clean: usize, - /// Candidates surfaced across all negative anchors — cards whose labelled - /// answer was "nothing". + /// Candidates surfaced across all negative anchors. pub neg_cards: usize, /// Cosines of surfaced candidates a human labelled genuinely related. pub related: Vec, - /// Cosines of everything else surfaced — non-expected candidates of positive - /// anchors, and every candidate of a negative anchor. + /// Cosines of everything else surfaced. pub junk: Vec, - /// Every anchor's surfaced list in rank order. The flat piles above judge the - /// absolute floor; the *relative* drop-off cutoff is judged within one - /// anchor's list (how far did #2 fall from #1?), and naming the pair behind a - /// pile value needs the anchor too — so the order is recorded, not just the - /// distribution. + /// Every anchor's surfaced list in rank order, for relative drop-off readings and for + /// naming the pair behind a pile value. pub detail: Vec, } /// One anchor's surfaced candidates, in rank order, for the results log. pub struct AnchorDetail { pub anchor: String, - /// True for a negative anchor (empty `expected`). pub negative: bool, /// (candidate path, cosine, human-labelled related) per surfaced candidate. pub candidates: Vec<(String, f64, bool)>, } -/// One row of the z dump: a candidate in the band's unit, with the human label -/// attached. +/// One row of the z dump: a candidate's z with its human label. pub struct ZCand { pub path: String, - /// The stage-2 best-passage z (GH #192's unit; the strength band's input, - /// gating nothing since GH #197) — straight from the engine's own - /// statistics. + /// The engine's stage-2 best-passage z (GH #192): the strength band's input. pub z: f64, - /// The same z recomputed harness-side from the served scores (z over squared - /// best-pair distance, nearer = higher). An instrument check, not a reading: - /// if this drifts from `z`, the engine's statistic moved and the harness's - /// model of it is stale. `None` only when the population had zero variance - /// and no z exists. + /// The same z recomputed from the served scores; a drift from `z` means the engine's + /// statistic moved. `None` at zero variance. pub z_recheck: Option, - /// The stage-2 score as served (negated best chunk-pair L2) — kept so the - /// row also records the model-comparable cosine, not only the - /// anchor-relative z. + /// The stage-2 score as served (negated best chunk-pair L2), for the cosine. pub score: f64, - /// Labelled a mate of this anchor. pub mate: bool, } -/// One anchor's **complete** reading in the band's unit — every candidate note -/// in the corpus, in z order, with the human label attached (GH #187; stage-2 -/// best-passage z since GH #192; the z travels ungated on the one shipped -/// surface since GH #197, so the dump *is* the served list read deep). -/// -/// The cosine piles above are the same numbers in a model-comparable unit; this -/// is the anchor-relative unit a z existence bar would judge in, which is why -/// the piles could never re-derive one's constants. +/// One anchor's complete reading in z order: every candidate note, labelled (GH #187). +/// The anchor-relative unit a z existence bar would judge in, unlike the cosine piles. pub struct AnchorZ { pub anchor: String, - /// True for a negative anchor (empty `expected`) — its whole list is - /// strangers by label, and its leader is what a leader gate would answer to. pub negative: bool, - /// Every candidate in served order, which *is* descending z. + /// Every candidate in served order, which is descending z. pub candidates: Vec, } impl AnchorZ { - /// The top candidate's z — what a leader gate would read. `None` only if - /// the anchor produced no scorable candidate at all. + /// The top candidate's z, what a leader gate would read. pub fn leader(&self) -> Option { self.candidates.first().map(|c| c.z) } - /// This anchor's labelled mates' z's (empty for a negative anchor). pub fn mates(&self) -> impl Iterator + '_ { self.candidates.iter().filter(|c| c.mate).map(|c| c.z) } - /// Everything on this anchor's list a human did *not* label — on a positive - /// anchor, exactly the population a member bar would have to cut. + /// Unlabelled candidates: on a positive anchor, what a member bar would have to cut. pub fn strangers(&self) -> impl Iterator + '_ { self.candidates.iter().filter(|c| !c.mate).map(|c| c.z) } - /// The worst disagreement between the engine's z and the harness's own - /// recomputation from the served scores, across this anchor's candidates — - /// the statistic-level drift check ([`ZCand::z_recheck`]). + /// The worst [`ZCand::z_recheck`] disagreement across this anchor's candidates. pub fn recheck_delta(&self) -> f64 { self.candidates .iter() @@ -155,36 +90,27 @@ impl AnchorZ { } } -/// The z dump across every discovery anchor, split into the populations an existence bar -/// would answer to (GH #187; the unit is the stage-2 best-passage z, and it gates nothing — -/// ADR-0014). +/// The z dump across every discovery anchor (GH #187; gates nothing, ADR-0014). /// -/// Three populations, because a two-bar rule's constants answer to different ones — the -/// conflation is what made "the negatives gate would catch a bad `member_z`" look true when -/// it was not. A leader gate is calibrated by negative-anchor leaders against positive-anchor -/// leaders; a member bar by strangers against labelled mates. Both windows are re-derived -/// every run — the standing record of why no such rule ships, and the first reading any -/// Phase-2 candidate answers to. +/// A leader gate is calibrated by negative- against positive-anchor leaders; a member bar by +/// strangers against mates. Keep the populations separate: conflating them made a bad +/// member bar look caught when it was not. #[derive(Default)] pub struct FloorZ { /// Every anchor with computed statistics, in label order. pub anchors: Vec, - /// Anchors whose candidate pool was under `STATS_MIN_POPULATION` or had zero - /// variance, so no z exists to calibrate from. Named rather than silently - /// dropped: on a corpus small enough for this to happen, every window below - /// is measured on fewer anchors than the labels suggest. + /// Anchors with no z (pool under `STATS_MIN_POPULATION`, or zero variance), named so the + /// windows aren't read as covering them. pub ungraded: Vec, } impl FloorZ { - /// (a) Labelled mates' z's — what a member bar would have to keep. + /// Labelled mates' z's: what a member bar would have to keep. pub fn mate_z(&self) -> Vec { self.anchors.iter().flat_map(|a| a.mates()).collect() } - /// (b) Strangers on **positive** anchors — what a member bar would have to - /// cut. The negative anchors' own candidates are deliberately excluded: - /// they are the leader pair's business, and folding them in here is what - /// would let a member window look wider than it is. + /// Strangers on positive anchors: what a member bar would have to cut. Negative anchors + /// are excluded; they would make the member window look wider than it is. pub fn stranger_z(&self) -> Vec { self.anchors .iter() @@ -192,7 +118,7 @@ impl FloorZ { .flat_map(|a| a.strangers()) .collect() } - /// (c) Negative anchors' leaders — what a leader gate would have to cut. + /// Negative anchors' leaders: what a leader gate would have to cut. pub fn neg_leader_z(&self) -> Vec { self.anchors .iter() @@ -200,8 +126,7 @@ impl FloorZ { .filter_map(|a| a.leader()) .collect() } - /// Positive anchors' leaders — what a leader gate would have to keep, or it - /// empties a list that has a real mate on it. + /// Positive anchors' leaders: what a leader gate would have to keep. pub fn pos_leader_z(&self) -> Vec { self.anchors .iter() @@ -210,11 +135,8 @@ impl FloorZ { .collect() } - /// The worst engine-vs-recomputed z disagreement across every anchor — the - /// statistic-level instrument check (see [`ZCand::z_recheck`]). Small fp - /// noise is expected (the engine z-scores f32 squared distances; the - /// harness recomputes them from the f32-sqrt'd scores), so the printed - /// check tolerates 1e-3 z and reports the observed maximum. + /// The worst [`ZCand::z_recheck`] disagreement across every anchor. f32 round-trips add + /// noise, so the printed check tolerates 1e-3. pub fn recheck_delta(&self) -> f64 { self.anchors .iter() @@ -223,15 +145,8 @@ impl FloorZ { } } -/// Score the discovery labels — **one pass, one surface** (GH #197). -/// -/// Everything reads `Vault::similar`, the always-served ranked list: the ranks, -/// the strangers, the negatives' card tally, and the cosine piles + per-anchor -/// detail the calibration blocks consume. (A second pass with a pass-vs-pass -/// suppression diff existed while the GH #150 existence gate shipped; under -/// always-serve it re-read the identical call and could never fire, so it -/// retired — GH #217, and see [`SimilarPass::mate`] for what catches a -/// returning gate instead.) +/// Score the discovery labels in one pass over `Vault::similar`, the always-served list +/// (GH #197). pub fn score_similar( vault: &Vault, set: &SimilarSet, @@ -252,8 +167,7 @@ pub fn score_similar( .position(|c| label.expected.iter().any(|e| paths_match(&c.path, e))) .map(|p| p + 1); pass.rank.add(rank); - // …and again per labelled mate, so a hard one can't hide behind an - // easy one (GH #183 — see `SimilarPass::mate`). + // Per labelled mate, so a hard one can't hide behind an easy one (GH #183). for expected in &label.expected { let mate_rank = candidates .iter() @@ -261,10 +175,7 @@ pub fn score_similar( .map(|p| p + 1); pass.mate.add(mate_rank); } - // The precision side of the same list (GH #188 — see - // `SimilarPass::strangers`): everything served here that no label - // claims. Scored on the shipped surface at the ranks' own depth, - // because that is the list a human is handed. + // Strangers (GH #188), at the ranks' own depth. let before = pass.strangers.len(); for c in &candidates { if !label.expected.iter().any(|e| paths_match(&c.path, e)) { @@ -275,7 +186,6 @@ pub fn score_similar( pass.stranger_anchors += 1; } } - // The calibration piles + per-anchor detail, off the same served list. let mut ordered = Vec::with_capacity(candidates.len()); for c in &candidates { let related = !negative && label.expected.iter().any(|e| paths_match(&c.path, e)); @@ -296,15 +206,9 @@ pub fn score_similar( Ok(pass) } -/// Dump every candidate's **band-unit z** (stage-2 best-passage z, GH #192) on -/// every discovery anchor — the deep discovery pass (GH #187). -/// -/// Since GH #197 the z travels ungated on the one shipped surface, so the dump -/// is simply `similar` read at [`Z_SCAN_LIMIT`] rather than `SIM_K`: a bar is -/// calibrated against the population it has to cut, and the strangers just -/// under a served prefix are precisely the ones a lower bar would admit. (This -/// used to need the engine's floor moved out of the way with `-∞` bars; the -/// machinery went with the gate.) +/// Dump every candidate's z on every discovery anchor (GH #187, #192): `similar` read at +/// [`Z_SCAN_LIMIT`], since the strangers just past a served prefix are what a lower bar +/// would admit. pub fn score_floor_z( vault: &Vault, set: &SimilarSet, @@ -312,18 +216,12 @@ pub fn score_floor_z( let mut dump = FloorZ::default(); for label in &set.anchors { let candidates = vault.similar(&label.anchor, Z_SCAN_LIMIT)?; - // z is uniform within one query by construction (`discover::candidates` - // gives every surviving candidate a z or none of them), so the leader's - // presence decides the whole list — an anchor with no statistics is - // named, never half-counted. + // `discover::candidates` gives every candidate a z or none, so the leader decides. let Some(true) = candidates.first().map(|c| c.z.is_some()) else { dump.ungraded.push(label.anchor.clone()); continue; }; - // The band z, recomputed harness-side over the same population the - // engine just served: z over squared best-pair distance (score is - // negated L2, so d² = score²), oriented nearer = higher. Should equal - // the engine's own z to fp noise — the statistic-level drift check. + // Score is negated L2, so d² = score². Should match the engine's z to fp noise. let d2: Vec = candidates.iter().map(|c| c.score * c.score).collect(); let recheck = passage_z(&d2); dump.anchors.push(AnchorZ { @@ -348,15 +246,8 @@ pub fn score_floor_z( Ok(dump) } -/// Re-derive the admissible windows a z existence rule *would* have on the -/// corpus as it stands today (GH #187) — the standing record of why none ships -/// (GH #197), and the first reading any Phase-2 bake-off candidate answers to. -/// -/// Printed every run, and recorded in the row, precisely so no number here ever -/// gets copied into a doc comment again: the #150 windows were measured once, -/// frozen into a rustdoc, and silently falsified the first time the corpus grew -/// a note shape they were never derived from. The instrument is the citable -/// thing; any constant stays in the code it governs. +/// Re-derive the windows a z existence rule would have on today's corpus (GH #187), the +/// record of why none ships (GH #197). Cite this output; don't copy its numbers into docs. pub fn print_floor_windows(z: &FloorZ) { let (mates, strangers) = (z.mate_z(), z.stranger_z()); let (neg_leaders, pos_leaders) = (z.neg_leader_z(), z.pos_leader_z()); @@ -364,9 +255,7 @@ pub fn print_floor_windows(z: &FloorZ) { " discovery z calibration (stage-2 best-passage z — the band's input; gates NOTHING \ since GH #197)" ); - // The statistic-level instrument check: the dump's z is recomputed from the - // served scores, so a drift in the engine's statistic can't pass silently - // (tolerance covers f32 sqrt/square round-trip noise). + // Tolerance covers f32 sqrt/square round-trip noise. let recheck = z.recheck_delta(); if recheck <= 1e-3 { println!(" [check] harness recomputation matches the engine z (max Δ {recheck:.1e})"); @@ -421,9 +310,7 @@ pub fn print_floor_windows(z: &FloorZ) { ), } } - // Every negative anchor's leader with the band it paints — A2's readout: - // under always-serve these cards ARE served, and the band is what they - // claim to the human whose labels say "nothing relates". + // Negative anchors' leaders are served under always-serve; the band is what they claim. println!( " negative anchors' leaders (served under always-serve; the band carries the honesty):" ); diff --git a/crates/b2-embed/examples/eval/evidence.rs b/crates/b2-embed/examples/eval/evidence.rs index 867da1f..6ed7abc 100644 --- a/crates/b2-embed/examples/eval/evidence.rs +++ b/crates/b2-embed/examples/eval/evidence.rs @@ -11,89 +11,60 @@ use b2_core::search::EvidenceBar; use b2_core::vault::Vault; use std::path::Path; -/// One served row of a labelled query's list (GH #206): the per-hit provenance -/// RRF discards, and the row's relevance **by label** — the two things a per-hit -/// tail rule is judged between. Rank is the row's position in `rows`, never a -/// stored field: the fused order is the identity D1's prefix requirement binds. +/// One served row of a labelled query's list (GH #206): per-hit provenance and relevance by +/// label. Rank is the position in `rows`, never stored: the fused order is what D1 binds. pub struct ServedRow { pub path: String, - /// 0-based rank in the BM25 list; `None` = the lexical half never ranked - /// this chunk — the "dense-only" row. + /// 0-based rank in the BM25 list; `None` is a dense-only row. pub bm25_rank: Option, - /// This row's own cosine to the query; `None` when the dense half never - /// ranked it (a row is in at least one list, so `bm25_rank` and `cos` are - /// never both absent). + /// The row's cosine to the query; `None` when the dense half never ranked it. Never + /// both `None`. pub cos: Option, - /// In the keep-set — `relevant` ∪ `tail_relevant`, exhaustive by label - /// since GH #206: a false here is a judgement ("filler"), not an omission. + /// In `relevant` ∪ `tail_relevant`. Exhaustive, so false is a judgement, not an omission. pub keep: bool, } -/// One labelled query's evidence reading (invariants.md D2; GH #201, Phase A of -/// the disclosure work): the signals a query-level evidence rule would judge, -/// dumped before any rule exists so GH #201 derives its rule from measurement -/// rather than assumption. Nothing here gates anything. +/// One labelled query's evidence reading (D2, GH #201): the signals a query-level evidence +/// rule judges. pub struct QueryEvidence { pub query: String, - /// Chunks the OR-sanitized FTS5 expression matches at all. **Not** a - /// lexical-anchor test on phrase queries — `fts5_query` ORs every - /// alphanumeric term, so stopwords saturate this count; recorded precisely - /// so that saturation stays measured instead of assumed away. + /// Chunks the OR-sanitized FTS5 expression matches. Not a lexical anchor: stopwords + /// saturate it. pub bm25_hits: usize, - /// Best BM25 score over those matches, sign-flipped so higher = better - /// (FTS5's `rank` is more-negative-is-better). `None` when nothing matches - /// — the honest zero the fused surface currently cannot say. + /// Best BM25 score, sign-flipped so higher = better. `None` when nothing matches. pub bm25_best: Option, - /// Best cosine between the embedded query and any stored chunk vector (the - /// dense top-1) — the strongest semantic evidence the vault holds for this - /// query, which RRF discards before the surface sees it. + /// The dense top-1 cosine, which RRF discards before the surface sees it. pub best_cos: Option, - /// The note that dense top-1 belongs to, naming what the number points at. + /// The note that dense top-1 belongs to. pub top: String, - /// The served list at [`K`], in fused order, one entry per row — path, - /// per-hit provenance, and the row's relevance **by label** (GH #206). For a - /// negative query every row's `keep` is false: the whole list is junk by - /// label. `served`/`dense_only` below read this, so the counts and the rows - /// they summarize cannot drift apart. + /// The served list at [`K`] in fused order (GH #206). pub rows: Vec, - /// Chunks in the index — the denominator every `df` below is judged against. + /// Chunks in the index, the denominator for every `df`. pub chunk_total: usize, - /// Every query term with its document frequency, in query order. The - /// population the lexical-anchor rule is swept over: raw hit count and raw - /// best-BM25 both failed to separate the piles (Phase A), so the anchor is - /// derived from *these* rather than from either of those. + /// Every query term with its document frequency, in query order: what the lexical + /// anchor is derived from, since raw hits and best-BM25 failed to separate the piles. pub terms: Vec<(String, usize)>, - /// **The engine's own verdict** for this query — `Vault::search_evidence`'s `vouched`, - /// i.e. exactly what the surfaces act on (GH #202). The exit gate counts on *this*, never - /// on the harness's restatement below: an assertion about what ships must read what - /// ships. `None` when the model has no calibrated bar. - /// - /// The restatement is kept beside it because the sweep needs it — [`bake_off`] evaluates - /// the rule at coverages the engine cannot be asked about — and because two independent - /// readings of one rule are a drift check when compared. So they are: [`read_shipped_bar`] - /// prints a `[FAULT]` on any query where they disagree. + /// The engine's own verdict (GH #202), which the exit gate counts on. `None` when the + /// model has no calibrated bar. [`coverage`](Self::coverage) restates the rule for the + /// sweep; [`read_shipped_bar`] flags any disagreement. pub vouched: Option, } impl QueryEvidence { - /// What the shipped surface serves at [`K`] today. For a negative query - /// this is the measured D2 defect: `limit` confident-looking results for a - /// query the vault holds nothing for. + /// What the shipped surface serves at [`K`]; on a negative query, the D2 defect. pub fn served(&self) -> usize { self.rows.len() } - /// Of the served rows, how many the **lexical half never ranked at all** - /// (`bm25_rank: None`) — the per-hit shape of the same defect, and the - /// signal the `lexical` tail rule folds on (GH #206). + /// Served rows the lexical half never ranked, which the `lexical` tail rule folds on + /// (GH #206). pub fn dense_only(&self) -> usize { self.rows.iter().filter(|r| r.bm25_rank.is_none()).count() } - /// The share of this query's term IDF the vault carries — the harness's own - /// restatement of [`b2_core::search::LexicalEvidence::term_coverage`], kept - /// separate so a drift in the engine's rule shows up as the two disagreeing - /// rather than as silence. `None` when nothing in the query carries weight. + /// The share of this query's term IDF the vault carries: an independent restatement of + /// [`b2_core::search::LexicalEvidence::term_coverage`], as a drift check. `None` when no + /// term carries weight. pub fn coverage(&self) -> Option { let idf = |df: usize| ((self.chunk_total as f64 + 1.0) / (df as f64 + 1.0)).ln(); let total: f64 = self.terms.iter().map(|(_, df)| idf(*df)).sum(); @@ -115,47 +86,33 @@ impl QueryEvidence { } } -/// The `min_term_coverage` grid the bake-off sweeps: how much of a query's own -/// weight the vault must carry for the lexical half to vouch for it. Spans "a -/// twentieth" to "all of it", so the printed rows show the rule's whole -/// behaviour rather than a neighbourhood of the shipped constant. +/// The `min_term_coverage` grid the bake-off sweeps, spanning the rule's whole range rather +/// than a neighbourhood of the shipped constant. pub const COVERAGES: [f64; 10] = [0.05, 0.10, 0.15, 0.20, 0.25, 0.34, 0.50, 0.67, 0.85, 1.00]; -/// One coverage-bar cell of the bake-off: how the lexical half alone splits the -/// labelled piles there, and what the cosine half would then have to do for the -/// queries it leaves undecided. +/// One coverage cell of the bake-off: how the lexical half splits the labelled piles, and +/// what the cosine half must do with the rest. pub struct EvidenceCell { pub coverage: f64, - /// Positives the lexical half already vouches for. pub pos_anchored: usize, - /// Negatives the lexical half wrongly vouches for. **Any nonzero value - /// disqualifies the cell**: an anchored negative is served whatever the - /// cosine bar says, so no `min_cos` can rescue it. + /// Negatives the lexical half vouches for. Nonzero disqualifies the cell: no `min_cos` + /// can cut an anchored query. pub neg_anchored: usize, - /// Cosines of the positives the lexical half left undecided — the pile a - /// `min_cos` must KEEP. + /// Cosines of undecided positives, which a `min_cos` must keep. pub undecided_pos: Vec, - /// Cosines of the negatives left undecided — the pile it must CUT. + /// Cosines of undecided negatives, which it must cut. pub undecided_neg: Vec, } impl EvidenceCell { - /// The cosine window this cell leaves for `min_cos`, over **only** the - /// queries the lexical half did not already decide — which is the whole - /// point of reading it here rather than over every query: D2's rule is - /// lexical OR semantic, so a positive with an anchor never needs its cosine - /// kept, and a pure-cosine window (Phase A's) overstates the keep set. + /// The cosine window for `min_cos` over only the undecided queries: D2 is lexical OR + /// semantic, so an anchored positive never needs its cosine kept. pub fn window(&self) -> Option { Window::read(&self.undecided_neg, &self.undecided_pos) } - /// Whether some `min_cos` completes this cell into a rule that keeps every - /// positive and cuts every negative. - /// - /// Three ways to be admissible, and the two degenerate ones are real - /// readings rather than edge-case bookkeeping: with no undecided negatives - /// the cosine half is inert (the lexical rule did the whole job), and with - /// no undecided positives any bar above the negatives' best cosine works. + /// Whether some `min_cos` completes this cell into a rule that keeps every positive and + /// cuts every negative. An empty undecided pile on either side is admissible. pub fn admissible(&self) -> bool { if self.neg_anchored > 0 { return false; @@ -166,8 +123,7 @@ impl EvidenceCell { } } - /// The lowest `min_cos` that cuts every undecided negative — the bar's - /// measured floor. `None` when nothing is left to cut. + /// The bar's measured floor. `None` when nothing is left to cut. pub fn cut_floor(&self) -> Option { let max = self .undecided_neg @@ -177,8 +133,7 @@ impl EvidenceCell { max.is_finite().then_some(max) } - /// The highest `min_cos` that still keeps every undecided positive — the - /// bar's measured ceiling. `None` when nothing is left to keep. + /// The bar's measured ceiling. `None` when nothing is left to keep. pub fn keep_ceiling(&self) -> Option { let min = self .undecided_pos @@ -216,35 +171,25 @@ pub fn bake_off(ev: &SearchEvidence) -> Vec { cells } -/// The search evidence dump (GH #201): every labelled query's reading, split -/// into the piles a query-level evidence bar answers to — positives it must -/// keep reachable, negatives it must answer "no matches". +/// The search evidence dump (GH #201): positives the bar must keep, negatives it must cut. pub struct SearchEvidence { pub positives: Vec, pub negatives: Vec, } -/// One side's best-cos pile — the population the query-window derivation and -/// the printed pile lines both read (queries with no dense reading drop out). +/// One side's best-cos pile; queries with no dense reading drop out. pub fn best_cos_pile(rows: &[QueryEvidence]) -> Vec { rows.iter().filter_map(|r| r.best_cos).collect() } -/// Score the search evidence calibration (invariants.md D2; GH #201) — a pure -/// read over the already-built vault. Per labelled query: the lexical half's -/// reading via a direct `chunks_fts` probe (the engine's own sanitized -/// expression — the absolute signals RRF discards), the dense top-1 via the -/// vector-only ablation path, and the shipped surface's served count. +/// Score the search evidence calibration (D2, GH #201), a pure read over the built vault. pub fn score_search_evidence( vault_root: &Path, vault: &Vault, positives: &[Labelled], negatives: &[Labelled], ) -> Result> { - // A second read connection beside the Vault's own — C1: readers are - // unrestricted, and the probe wants FTS5's `rank`, which no façade read - // exposes (deliberately: bm25 units are engine internals everywhere but - // this instrument). + // A second reader (C1): the probe wants FTS5's `rank`, which no façade read exposes. let conn = b2_core::open(&vault_root.join(".b2").join("b2.sqlite"))?; let rows = |queries: &[Labelled]| -> Result, Box> { queries @@ -257,9 +202,7 @@ pub fn score_search_evidence( .first() .map(|h| h.path.clone()) .unwrap_or_else(|| "—".to_string()); - // The façade's own evidence read (GH #201) — the term dfs, the - // per-hit provenance, and the served list in one call, so the - // sweep judges exactly what the engine would. + // The façade's own evidence read, so the sweep judges what the engine would. let view = vault.search_evidence(&q.query, K)?; Ok(QueryEvidence { query: q.query.clone(), @@ -294,12 +237,8 @@ pub fn score_search_evidence( }) } -/// The lexical half's reading for one raw query, probed directly over -/// `chunks_fts` with the engine's own sanitized expression -/// ([`b2_core::search::fts5_query`]): how many chunks match at all, and the -/// best BM25 score among them (FTS5 `rank`, sign-flipped so higher = better). -/// These are exactly the absolute signals the fused path computes and then -/// discards at RRF — which is why the instrument reads them raw. +/// Match count and best BM25 (sign-flipped) for one query, probed over `chunks_fts` with the +/// engine's [`b2_core::search::fts5_query`]: the absolute signals RRF discards. pub fn bm25_probe( conn: &rusqlite::Connection, query: &str, @@ -325,13 +264,9 @@ pub fn bm25_probe( Ok((hits, Some(-best))) } -/// Print the search evidence calibration (invariants.md D2; GH #201) — the -/// query-side sibling of [`print_floor_windows`](crate::discovery::print_floor_windows). Reported, nothing gates: the -/// piles are what GH #201's query-level bar is argued from, and any constant -/// read off them owes process rule 5's real-vault transfer check before it -/// ships. The window's caveat is structural: D2's rule is lexical OR semantic -/// evidence, so a *pure-cosine* window overstates what a real bar must keep — -/// a positive query with a lexical anchor never needs its cosine kept. +/// Print the search evidence calibration (D2, GH #201). Reported, not gated. The +/// pure-cosine window overstates what a real bar must keep, since lexical evidence keeps +/// its own. pub fn print_search_evidence(ev: &SearchEvidence) { println!( " search evidence calibration (D2 — reported, nothing gates; the query bar is GH #201's \ @@ -408,11 +343,8 @@ pub fn print_search_evidence(ev: &SearchEvidence) { } } -/// Print the **search evidence bake-off** (ADR-0015, GH #201) — the query-level rule's -/// window, re-derived from the labelled piles on every run rather than quoted from the day it -/// was read. The GH #187 idiom on search's side: the constant lives in -/// [`b2_core::search::BGE_BASE_EVIDENCE_BAR`] and its *justification* is recomputed here, -/// including whether the shipped bar still sits inside the window it was read from. +/// Print the search evidence bake-off (ADR-0015, GH #201). The constant lives in +/// [`b2_core::search::BGE_BASE_EVIDENCE_BAR`]; its justification is recomputed here each run. pub fn print_search_bakeoff(ev: &SearchEvidence, cells: &[EvidenceCell], model_id: &str) { println!( " search evidence bake-off (D2 — the query-level rule, re-derived every run; GH #201)" @@ -499,15 +431,8 @@ pub fn print_search_bakeoff(ev: &SearchEvidence, cells: &[EvidenceCell], model_i println!(" the tail bake-off's, below (GH #206)."); } -/// Name the admissible cells with the most cosine headroom, and within that band -/// the **most conservative corner** — the tightest `df` fraction and the -/// strictest coverage that still buy the widest window. -/// -/// The tie matters more than the maximum does, and printing only a winner would -/// hide it: the grid's cells are not distinct rules but a plateau, and the -/// reading to carry into process rule 5's transfer check is "anywhere in this -/// band", not "at this point". A constant placed at a lone maximum would be -/// fitted to the grid's resolution. +/// Name the plateau of admissible cells with the most cosine headroom and its strictest +/// coverage. Report the plateau, not a lone maximum, which would fit the grid's resolution. pub fn widest(cells: &[&EvidenceCell]) -> String { let best = cells .iter() @@ -538,10 +463,8 @@ pub fn widest(cells: &[&EvidenceCell]) -> String { } } -/// A cell's cosine headroom: how much room a `min_cos` has between the -/// negatives it must cut and the positives it must keep. An inert cosine half -/// (nothing left undecided on one side) has unbounded room and is reported as -/// such rather than scored against bounded cells. +/// A cell's cosine headroom between the negatives to cut and positives to keep; infinite +/// when the cosine half is inert. pub fn headroom(cell: &EvidenceCell) -> f64 { match (cell.cut_floor(), cell.keep_ceiling()) { (Some(cut), Some(keep)) => keep - cut, @@ -549,33 +472,20 @@ pub fn headroom(cell: &EvidenceCell) -> f64 { } } -/// Where the **shipped** constant stands against this run's piles: the tripwire (a labelled -/// positive the bar would cut — ADR-0015 asserts zero, no headroom) and the defect it exists -/// to fix (a labelled negative it still serves). -/// -/// **In the exit gate** since GH #202, at [`MAX_POSITIVES_CUT`] and [`MAX_NEGATIVES_SERVED`]. -/// Read here rather than in the printer, so the number asserted and the number explained are -/// one number. `None` when the active model has no calibrated bar. +/// Where the shipped bar stands against this run's piles. Gated at [`MAX_POSITIVES_CUT`] and +/// [`MAX_NEGATIVES_SERVED`] (GH #202); read here so the gate and the printer share one number. pub struct ShippedBar { pub bar: EvidenceBar, - /// Labelled positives the bar cuts — the tripwire's direction. pub pos_cut: usize, - /// Labelled negatives it still serves — the defect's direction. pub neg_served: usize, - /// Queries where the engine's verdict and the harness's independent - /// restatement of the same rule **disagree** — a drift between the two, which - /// is silence unless something looks. Expected empty; printed, not gated, - /// because it accuses the instrument as readily as the engine and the reader - /// has to say which. + /// Queries where the engine's verdict and the harness's restatement disagree. Printed, + /// not gated: it may be the instrument that drifted. pub faults: Vec, } pub fn read_shipped_bar(ev: &SearchEvidence, model_id: &str) -> Option { let bar = EvidenceBar::for_model(model_id)?; - // The ENGINE's verdict, not a restatement of it: this is what the gate - // asserts and what the surfaces act on. `None` cannot occur here — the model - // has a bar or this function returned already — so it is counted as served, - // the same direction the surfaces take it (M2: no verdict is not "no match"). + // The engine's verdict. A `None` counts as served, as on the surfaces (M2). Some(ShippedBar { bar, pos_cut: ev @@ -613,8 +523,7 @@ pub fn print_shipped_bar(ev: &SearchEvidence, model_id: &str) { println!(" shipped bar: none for this model — no verdict is offered (M2)"); return; }; - // Every line below reads the ENGINE's verdict, so the numbers printed are the - // numbers gated — one reading, not two that can drift apart. + // The engine's verdict, so the numbers printed are the numbers gated. let vouches = |r: &QueryEvidence| r.vouched != Some(false); println!( " shipped bar: coverage ≥ {:.2}, cos ≥ {:.3}", diff --git a/crates/b2-embed/examples/eval/fold.rs b/crates/b2-embed/examples/eval/fold.rs index 0c35dad..d0621eb 100644 --- a/crates/b2-embed/examples/eval/fold.rs +++ b/crates/b2-embed/examples/eval/fold.rs @@ -1,6 +1,6 @@ -//! The discovery fold bake-off (GH #200, Phase B) — the *default disclosure boundary*, -//! priced on every bench this harness carries. The ruling of record is **no fold ships** -//! (docs/evals.md); the bake-off keeps re-deriving the window every run. +//! The discovery fold bake-off (GH #200): the default disclosure boundary, priced on every +//! bench. The ruling of record is that no fold ships (docs/evals.md); the window is +//! re-derived every run. use crate::common::{cosine_of, truncate}; use crate::labels::{SimilarLabel, SimilarSet}; @@ -9,46 +9,27 @@ use crate::{SIM_K, Z_SCAN_LIMIT}; use b2_core::vault::Vault; use std::collections::HashMap; -/// The reciprocity depths GH #200's candidate 1 is shown at in the headline -/// table and the per-anchor lists — [`SIM_K`] (the pane itself) with one depth -/// either side of it. They are a *display* choice only: the window below is -/// derived from the whole sweep, so no verdict turns on these three. +/// The reciprocity depths shown in the headline table: [`SIM_K`] and one either side. +/// Display only; the window is derived from the whole sweep. pub const FOLD_MUTUAL_K: [usize; 3] = [3, 5, 10]; -/// How far the reciprocity depth is swept when re-deriving candidate 1's -/// admissible window each run (GH #187's idiom on the disclosure axis — a -/// measurement in the harness, never a constant frozen into a docstring). Half -/// the orthogonal corpus's note count: a `k` past that admits most of the vault -/// as "mutually near", which is the rule going vacuous rather than a depth -/// anyone would ship. +/// How far the reciprocity depth is swept. Half the orthogonal corpus's note count: past +/// that, most of the vault is "mutually near" and the rule is vacuous. pub const FOLD_K_SWEEP: usize = 15; -/// A **default disclosure rule**: a candidate answer to "how much of the ranked -/// list does the default view vouch for?" (invariants.md D1 as redrafted; -/// GH #200). Every variant is a **prefix** rule *by construction* — it returns a -/// depth, never a set — so a rule that admits rank 5 while folding rank 2 is not -/// expressible here. D1 states that admissibility requirement; this type makes -/// violating it unrepresentable, which is the difference between a check and a -/// guarantee. +/// A default disclosure rule: how much of the ranked list the default view vouches for +/// (D1, GH #200). Returns a depth, never a set, so a non-prefix rule is unrepresentable. #[derive(Clone, Copy, PartialEq)] pub enum FoldRule { - /// **Candidate 3, the incumbent**: no fold. The default view is the whole - /// served prefix — GH #197's always-serve ruling, admissible per D1 and the - /// thing the other candidates have to beat. + /// Candidate 3, the incumbent: no fold, GH #197's always-serve. NoFold, - /// **Candidate 1**: mutual-kNN reciprocity in prefix form. Candidate B is - /// *reciprocal* for anchor A iff A sits in B's own top `k` candidates, and - /// the default view is the ranked list's longest reciprocal prefix. Rank- - /// based (the hubness-correction family: mutual proximity, CSLS), so it - /// carries no cosine or z constant — though `k` itself is measured below to - /// be scale-dependent, which is a different objection and the decisive one. + /// Candidate 1: the longest prefix whose candidates each have the anchor in their own + /// top `k`. Rank-based, so no cosine or z constant, but `k` measures scale-dependent. Mutual(usize), } impl FoldRule { - /// The rule's name in every table, row and window line — one spelling, so a - /// printed verdict and a recorded row can never name the same rule - /// differently. + /// The rule's one spelling in every table, row and window line. pub fn label(self) -> String { match self { FoldRule::NoFold => "no fold".to_string(), @@ -65,53 +46,37 @@ impl FoldRule { } } -/// One served candidate on one anchor's list, carrying everything a candidate -/// rule judges: the label, the score, and — the reciprocity signal — **where the -/// anchor sits in this candidate's own ranked list**. +/// One served candidate on one anchor's list, with what a rule judges. pub struct FoldRow { pub path: String, pub cos: f64, - /// A labelled mate of this anchor (always `false` on a negative or - /// unlabelled anchor). pub mate: bool, - /// The **anchor's** rank in *this candidate's* full-depth candidate list, or - /// `None` if the anchor is not in it at all. Storing the rank rather than a - /// per-`k` boolean is what makes the whole sweep below free: reciprocity at - /// depth `k` is `rank ≤ k`, so every `k` is priced off one discovery pass - /// per note instead of one per note per depth. + /// The anchor's rank in this candidate's full-depth list. A rank rather than a per-`k` + /// flag, so one pass prices every `k`. pub recip_rank: Option, } impl FoldRow { - /// Whether this candidate is *reciprocal* at depth `k` — i.e. the anchor - /// sits in its own top `k`. Reciprocity is necessary but not sufficient for - /// being above the fold: the fold is the longest reciprocal **prefix**, so a - /// reciprocal candidate ranked after a non-reciprocal one is below it too. + /// Whether the anchor sits in this candidate's top `k`. Not sufficient for being above + /// the fold, which is the longest reciprocal prefix. pub fn reciprocal_at(&self, k: usize) -> bool { self.recip_rank.is_some_and(|r| r <= k) } } -/// One anchor's served list, read once and judged by every candidate rule — so -/// the rules are compared on identical rows rather than on separate passes that -/// could drift. +/// One anchor's served list, read once so every rule is judged on identical rows. pub struct FoldAnchor { pub anchor: String, - /// A labelled **loner** (empty `expected`): its correct default view is - /// *empty above the fold* — the assertion GH #197 retired, returning on the - /// disclosure axis. + /// A labelled loner: its correct default view is empty above the fold. pub negative: bool, - /// Labelled mates in label order. pub expected: Vec, /// The served prefix (`limit` = [`SIM_K`]) in rank order. pub rows: Vec, } impl FoldAnchor { - /// This anchor's labelled mates that the *served prefix* reaches, as - /// `(mate, rank)`. A mate ranked past `limit` is not among them — it is - /// always-serve's own miss at this depth, and counting it against a fold - /// would charge every rule for a cost none of them caused. + /// Labelled mates the served prefix reaches, as `(mate, rank)`. A mate past `limit` is + /// always-serve's miss, not a fold's cost. pub fn served_mates(&self) -> Vec<(String, usize)> { self.expected .iter() @@ -125,71 +90,41 @@ impl FoldAnchor { } } -/// What one rule reads on one bench — GH #200's judged quantities, each carrying -/// the per-anchor list it is argued from (process rule 1: the aggregate is a -/// smoke alarm, the named list is the data). +/// What one rule reads on one bench, each quantity with its named list (process rule 1). pub struct FoldReading { pub rule: FoldRule, - /// Labelled mates the served prefix reaches but the default view does - /// **not** vouch for: `(anchor, mate, rank)`. **The fold's own cost**, and - /// the quantity GH #200 judges a candidate at zero on — a human-labelled - /// relation hidden by default. Reported rather than gated while nothing - /// folds (it reads a structural 0 under always-serve) — and GH #202 shipped - /// no fold to charge, so it stays reporting-only; it becomes an exit-gate - /// row with the first fold that does ship. + /// Served labelled mates hidden by default, `(anchor, mate, rank)`: the fold's own cost, + /// judged at zero (GH #200). Reported only until a fold ships. pub mates_folded: Vec<(String, String, usize)>, - /// Labelled mates above the fold — the other half of the same count. pub mates_above: usize, - /// Unlabelled notes the default view vouches for on a *positive* anchor — - /// the strangers count, read above the fold instead of over the whole served - /// prefix. Expected to shrink; deliberately ungated for the standing reason - /// (the cheapest way to shrink it is to label the stranger). + /// Strangers above the fold on positive anchors. Ungated, like the strangers count. pub strangers_above: Vec<(String, String)>, - /// Negative (loner) anchors whose default view is empty — the honest answer - /// their label always claimed, and what always-serve cannot assert. + /// Loner anchors whose default view is empty. pub neg_empty: usize, - /// Non-negative anchors whose default view is empty. On the dense fixture - /// **any** entry here is disqualifying (a vault where everything relates may - /// never *default* to "nothing relates"); on the orthogonal corpus it is the - /// same event read through the labels. + /// Non-negative anchors whose default view is empty. Any entry disqualifies on the dense + /// fixture. pub dark_panes: Vec, - /// Cards above the fold, summed — the default view's size against - /// always-serve's. + /// Cards above the fold, summed. pub cards_above: usize, } -/// The **admissible-`k` window** for candidate 1, re-derived from this run -/// (GH #187's idiom, moved onto the disclosure axis: print the window a rule -/// *would* have rather than freezing a constant into a docstring). Two bounds -/// that must overlap for any `k` to be shippable on this bench: -/// -/// - `keep_min` — the smallest `k` that folds **no** labelled mate and darkens -/// **no** pane. Below it the rule hides relations a human labelled. -/// - `loner_max` — the largest `k` at which **every** labelled loner's default -/// view is empty. Above it the fold stops making the claim it exists to make, -/// and a loner's pane fills up again. +/// The admissible-`k` window for candidate 1, re-derived each run. Shippable only if +/// `keep_min` (smallest `k` folding no mate and darkening no pane) ≤ `loner_max` (largest +/// `k` emptying every loner's view). pub struct FoldWindow { pub keep_min: Option, pub loner_max: Option, - /// The smallest swept `k` whose fold equals always-serve on every anchor — - /// where the rule becomes vacuous (it vouches for everything). + /// The smallest `k` whose fold equals always-serve everywhere: the rule goes vacuous. pub vacuous_at: Option, - /// The best the loner claim ever gets on this bench, as `(empty, largest k - /// still achieving it)` — printed because "never all of them" and "four of - /// five up to k = 11" are different findings, and only the second says how - /// close the rule came. + /// The best loner claim, as `(empty, largest k still achieving it)`. pub loner_best: Option<(usize, usize)>, - /// The largest swept `k` that still darkens a pane. Every `k` at or below it - /// is disqualified outright on a corpus where everything relates (D1's - /// absolute), so this is the hard floor under any window. + /// The largest `k` that still darkens a pane: the hard floor under any window (D1). pub dark_below: Option, } impl FoldWindow { - /// Whether some swept `k` satisfies both bounds at once. A bench with no - /// labelled loner has no upper bound to satisfy and so can never report an - /// open window on its own — it contributes the lower bound to the - /// bake-off's joint window, which is what the printed verdict says. + /// Whether some swept `k` satisfies both bounds. Never true on a bench with no loner, + /// which supplies only the lower bound. pub fn open(&self) -> bool { matches!((self.keep_min, self.loner_max), (Some(a), Some(b)) if a <= b) } @@ -201,36 +136,22 @@ pub struct FoldBench { pub anchors: Vec, /// The headline rules: always-serve plus [`FOLD_MUTUAL_K`]'s detail depths. pub readings: Vec, - /// `(k, reading)` across the whole swept range — what the window is derived - /// from, and printed as a compact table so the shape of the trade is visible - /// rather than asserted. + /// `(k, reading)` across the swept range, which the window is derived from. pub sweep: Vec<(usize, FoldReading)>, pub window: FoldWindow, - /// Negative anchors on this bench (0 on the dense fixture, which has no - /// loner by construction). pub neg_n: usize, - /// Labelled mates the served prefix never reaches at `limit` — always-serve's - /// own miss, identical under every rule. Printed beside the folded count so - /// no rule is charged for it. + /// Labelled mates never served at `limit`: always-serve's miss, charged to no rule. pub mates_unserved: usize, - /// The median *full-depth* candidate pool an anchor here has — what a - /// reciprocity depth is a fraction OF. A `k` is only meaningful against it: - /// "top 7" of a 14-candidate pool and "top 7" of a 30-candidate one are - /// different claims, and the two corpora's windows below are only - /// comparable in this unit. + /// The median full-depth candidate pool. A `k` only compares across corpora as a + /// fraction of it. pub pool_median: usize, - /// Authored edges in the built vault — **candidate 2's** entire calibration - /// population ("what related looks like in this vault", read off the human's - /// own committed links). Both eval corpora are link-free by construction, so - /// this reads 0 and candidate 2 is *unpriceable here*: it is measured where - /// its population exists, by `make calibrate` on a real vault. + /// Authored edges: candidate 2's calibration population. The eval corpora are link-free, + /// so candidate 2 is only priced by `make calibrate` on a real vault. pub authored_edges: usize, } -/// Every note's own ranked candidate list as `path → rank`, read at full depth — -/// the reciprocity lookup candidate 1 is judged on. Read over **every note in -/// the vault**, not only the labelled anchors: reciprocity is decided by the -/// *candidate's* list, and most candidates are not anchors. +/// Every note's full-depth candidate list as `path → rank`, the reciprocity lookup. Over +/// every note, since most candidates are not anchors. pub fn reciprocity_ranks( vault: &Vault, ) -> Result>, Box> { @@ -247,10 +168,9 @@ pub fn reciprocity_ranks( Ok(out) } -/// Score the fold bake-off on one built vault. `sweep_all` reads **every note** -/// as an anchor (the dense fixture's pane sweep, where the assertion is about -/// the surface rather than the labels); otherwise only the labelled anchors are -/// read, matching [`score_similar`](crate::discovery::score_similar)'s depth and population exactly. +/// Score the fold bake-off on one built vault. `sweep_all` reads every note as an anchor; +/// otherwise only the labelled anchors, matching +/// [`score_similar`](crate::discovery::score_similar). pub fn score_fold( vault: &Vault, set: &SimilarSet, @@ -274,11 +194,8 @@ pub fn score_fold( } } - // Outbound only. `neighbors` answers "what is 1 hop from here" and so returns - // an edge from *both* of its endpoints; summing the raw counts over every - // note would census edge **endpoints** and report candidate 2's population at - // twice its size (PR #204 review). Counting each edge once from its source is - // the number the rule would calibrate from. + // Outbound only: `neighbors` returns an edge from both endpoints, so counting all would + // double it (PR #204). let mut authored_edges = 0; for note in vault.list_notes()? { authored_edges += vault @@ -292,10 +209,7 @@ pub fn score_fold( let mut neg_n = 0; for (anchor, label) in anchors { let expected: Vec = label.map(|l| l.expected.clone()).unwrap_or_default(); - // A *labelled* loner. An unlabelled anchor on the dense sweep is not a - // negative — that fixture carries no loner at all, and reading "no - // label" as "nothing relates" would invent the claim the bench exists - // to test. + // An unlabelled anchor on the dense sweep is not a negative. let negative = label.is_some() && expected.is_empty(); if negative { neg_n += 1; @@ -421,8 +335,6 @@ pub fn read_fold(anchors: &[FoldAnchor], rule: FoldRule) -> FoldReading { reading.mates_folded.push((a.anchor.clone(), mate, rank)); } } - // The precision side, read above the fold: unlabelled cards the default - // view vouches for on a positive anchor. if !a.negative && !a.expected.is_empty() { for row in a.rows.iter().take(fold) { if !row.mate { @@ -436,9 +348,7 @@ pub fn read_fold(anchors: &[FoldAnchor], rule: FoldRule) -> FoldReading { reading } -/// The bake-off's printed readout: the headline rules side by side, the swept -/// `k` the window is derived from, and the per-anchor folds — which is the list -/// the verdict is argued from (process rule 1). +/// The bake-off's printed readout: headline rules, the `k` sweep, and per-anchor folds. pub fn print_fold_bench(bench: &FoldBench) { println!("\n{}", "=".repeat(78)); println!( @@ -477,7 +387,7 @@ pub fn print_fold_bench(bench: &FoldBench) { bench.mates_unserved ); - // The named cost of every rule that has one — the list, not the count. + // The named cost of every rule that has one. for r in &bench.readings { if r.mates_folded.is_empty() { continue; @@ -488,8 +398,7 @@ pub fn print_fold_bench(bench: &FoldBench) { } } - // The swept window — the #187 idiom on the disclosure axis: derive the - // rule's admissible range from THIS run rather than quote a constant. + // The swept window, derived from this run. println!( "\n mutual-k sweep (k = 1..{FOLD_K_SWEEP}; the window a shippable k would have to sit in)" ); @@ -555,9 +464,7 @@ pub fn print_fold_bench(bench: &FoldBench) { bench.neg_n ); } - // The verdict names *which* bound failed. "No k works" and "this corpus - // cannot decide alone" are different sentences, and a bench with no loner - // can only ever supply the lower bound. + // Name which bound failed; a bench with no loner can only supply the lower bound. println!( " → window {}", match (bench.neg_n, bench.window.keep_min, bench.window.loner_max) { @@ -592,7 +499,6 @@ pub fn print_fold_bench(bench: &FoldBench) { } ); - // The per-anchor lists at the headline depths. println!("\n per anchor — served / above the fold, by rule:"); print!(" {:<40} {:>7}", "anchor", "served"); for r in &bench.readings { @@ -632,9 +538,7 @@ pub fn print_fold_bench(bench: &FoldBench) { println!(); } - // Candidate 2's census — printed on every bench, because "we did not measure - // it" and "there was nothing to measure it on" are different sentences and - // only the second is an argument. + // Printed on every bench: "nothing to measure it on" is a finding, not an omission. println!( "\n candidate 2 (authored-edge reference bar): {} authored edges in this corpus — {}", bench.authored_edges, @@ -647,10 +551,8 @@ pub fn print_fold_bench(bench: &FoldBench) { ); } -/// The bake-off as one JSON subtree (`discovery_fold`): every rule's reading, -/// the swept window it was judged against, and the per-anchor rows both were -/// computed from — so a verdict cited from a row can be re-derived without -/// re-running the model, which is what `results.jsonl` is for. +/// The bake-off as one JSON subtree (`discovery_fold`), with the per-anchor rows so a +/// verdict can be re-derived without re-running the model. pub fn fold_json(bench: &FoldBench) -> serde_json::Value { let reading = |r: &FoldReading| { serde_json::json!({ @@ -672,11 +574,8 @@ pub fn fold_json(bench: &FoldBench) -> serde_json::Value { "limit": SIM_K, "pool_median": bench.pool_median, "neg_n": bench.neg_n, - // Always-serve's own miss at this limit, recorded apart from every - // rule's cost so the two can never be added together by a later reader. "mates_unserved": bench.mates_unserved, - // Candidate 2's population, recorded even at zero: "unpriceable here" - // is a measurement, and a row that omitted it would read as untried. + // Recorded even at zero, so the row doesn't read as untried. "authored_edges": bench.authored_edges, "rules": bench.readings.iter().map(reading).collect::>(), "sweep": bench.sweep.iter().map(|(k, r)| serde_json::json!({ @@ -706,8 +605,6 @@ pub fn fold_json(bench: &FoldBench) -> serde_json::Value { "path": row.path, "cos": (row.cos * 1e4).round() / 1e4, "mate": row.mate, - // The anchor's rank in THIS candidate's own list — the whole - // reciprocity signal, from which any k is re-derivable. "recip_rank": row.recip_rank, })).collect::>(), })).collect::>(), diff --git a/crates/b2-embed/examples/eval/gate.rs b/crates/b2-embed/examples/eval/gate.rs index 9f23312..f61515b 100644 --- a/crates/b2-embed/examples/eval/gate.rs +++ b/crates/b2-embed/examples/eval/gate.rs @@ -1,10 +1,9 @@ -//! The exit gate: the assertions `make eval` exits 2 on, read off the **default** config's +//! The exit gate: the assertions `make eval` exits 2 on, read off the default config's //! passes (docs/evals.md, "The exit gate"). A manual quality gate, never a CI test. //! -//! How each row is placed is the point, and the constants' own docs say why: a rank floor -//! sits *below* its reading (corpus-drift headroom, run noise being zero), while the -//! search-evidence rows sit *at* their structural zeros, because headroom there would read -//! as permission to serve a nonsense query or cut a real one. +//! A rank floor sits below its reading (corpus-drift headroom; run noise is zero). The +//! search-evidence rows sit at zero, since headroom there would permit serving a nonsense +//! query or cutting a real one. use crate::dense::DensePass; use crate::discovery::SimilarPass; @@ -13,62 +12,35 @@ use crate::retrieval::Pass; use crate::SIM_K; /// The soft reference floor on the default config's hybrid note hit@1. -/// Untouched by the GH #197 re-derivation: retrieval never had a gate to -/// retire. pub const FLOOR_HIT1: f64 = 0.75; -/// The floor on **per-mate** discovery MRR@[`SIM_K`] (GH #188) — the non-saturating rank -/// metric, gated once a baseline existed to price it. Re-derived for always-serve -/// (ADR-0014): the reading moved 0.633 -> 0.650 when the existence gate retired. +/// The floor on per-mate discovery MRR@[`SIM_K`] (GH #188, ADR-0014); reading 0.650. /// -/// Placed **below** the reading, never at it: a gate pinned to today's number fails on the -/// first legitimate corpus edit, which trains the one habit this harness must never train -/// (process rule 2 — editing a *label* to get green). Sizing is measured, not guessed: five -/// consecutive runs on an unchanged corpus/model/build reproduce every rank exactly, so the -/// noise floor is 0 and the headroom exists for *corpus* drift. At n = 15 mates one mate -/// lost from rank 1 costs 1/15, and this floor sits ~2 such losses under the reading. +/// Below the reading so a legitimate corpus edit doesn't tempt a relabel (process rule 2). +/// Runs are bit-reproducible, so the headroom is for corpus drift: ~2 mates lost from rank +/// 1 at n = 15. pub const FLOOR_MATE_MRR: f64 = 0.52; -/// The floor on the **dense fixture's** per-mate MRR@[`SIM_K`] (ADR-0014, Phase 0b), gated -/// once its baseline existed — the same measure-then-calibrate order as [`FLOOR_MATE_MRR`]. -/// -/// The reading is 0.467 (n = 14): the model recovers the within-cluster labels and ranks the -/// three cross-cluster claims lower, which is headroom in both directions. (Its first -/// reading was 0.502; a one-word grammar fix moved one mate a rank — the worked example of -/// why these floors carry corpus-drift margin.) At n = 14 one mate lost from rank 1 costs -/// 1/14, and this sits ~2 such losses under. Process rule 2 binds hard here: in a corpus -/// where everything relates, relabelling toward the model's order would always look -/// plausible — a red reading argues about the notes. +/// The floor on the dense fixture's per-mate MRR@[`SIM_K`] (ADR-0014); reading 0.467, sized +/// like [`FLOOR_MATE_MRR`] (~2 losses at n = 14). Where everything relates, relabelling +/// toward the model always looks plausible: a red reading argues about the notes. pub const FLOOR_DENSE_MATE_MRR: f64 = 0.32; -/// How many **labelled negative queries** the shipped evidence bar may still serve -/// (ADR-0015, GH #202). Zero: this is the defect the bar exists to fix. -/// -/// A floor at its measured value rather than below it, which is the deliberate exception to -/// the house sizing method — the "headroom" a floor normally carries would be *permission to -/// serve a nonsense query*. A new negative the bar serves is either a real regression or a -/// mislabelled query; both want a red reading. +/// How many labelled negative queries the shipped evidence bar may serve (ADR-0015, +/// GH #202). Zero: this is the defect the bar exists to fix, so no headroom. pub const MAX_NEGATIVES_SERVED: usize = 0; -/// How many **labelled relevant queries** the bar may cut (GH #202) — the search-side -/// tripwire ADR-0015 asserts at zero with no headroom, and the direction that costs a user -/// something real: a served nonsense row costs a little trust, a cut positive costs the -/// answer. Its precondition was met by GH #208, which labelled the date-shaped query pile. -/// The reading is 0 of 44. A nonzero value is never a calibration nudge: it means the *rule* -/// is wrong for a shape the corpus now carries. +/// How many labelled relevant queries the bar may cut (ADR-0015, GH #202, #208). A cut +/// positive costs the user the answer. A nonzero reading means the rule is wrong, not the +/// constant. pub const MAX_POSITIVES_CUT: usize = 0; -/// How many of the **dense fixture's title-as-query probes** the bar may cut (GH #202). -/// Zero, and this is the assertion that would have caught the losing rule: a note's own -/// title names a note the vault demonstrably holds, so cutting one is indefensible whatever -/// a labelled corpus says. A third row rather than headroom on [`MAX_POSITIVES_CUT`] because -/// it gates a different *geometry*: the labelled corpus minimizes shared vocabulary by -/// construction, so topical concentration is only expressible here. Titles need no labels, -/// so nothing in this reading can be relabelled to clear it. +/// How many of the dense fixture's title-as-query probes the bar may cut (GH #202). A note's +/// own title names a note the vault holds, and topical concentration is only expressible in +/// this fixture. Titles carry no labels, so nothing here can be relabelled to clear it. pub const MAX_DENSE_TITLES_CUT: usize = 0; -/// Whether the default config clears every exit-gate row, printing a `[warn]` naming the -/// first row that fails (so a red run says which reading to argue with). +/// Whether the default config clears every exit-gate row; warns naming the first failure. pub fn passes( hybrid: &Pass, similar: &SimilarPass, @@ -76,8 +48,6 @@ pub fn passes( evidence: &SearchEvidence, model_id: &str, ) -> bool { - // The soft floors, on the DEFAULT config's passes — so this can double as - // a manual quality gate. Not a CI test. if hybrid.note.hit1() < FLOOR_HIT1 { eprintln!( "\n[warn] hybrid hit@1 {:.2} is below the {FLOOR_HIT1} reference floor — inspect the misses above.", @@ -85,16 +55,9 @@ pub fn passes( ); return false; } - // The negatives' suppression assertion RETIRED with the gate it watched (ADR-0014): - // under always-serve a loner anchor serves its ranked nearest — that is the ruling, not - // a regression. The anchors stay labelled, the strangers instrument keeps counting, and - // what the served cards *claim* is the calibration block's band readout. (The - // pass-vs-pass suppression assertion retired later, for a different reason: it compared - // a call against itself and could never fire — GH #217. A returning existence gate is - // the dense zero-empty-panes assertion's and the per-mate floors' to catch.) - // - // Discovery **rank** (GH #188). The rank floor sits below its measured reading (corpus - // drift headroom, run noise being zero). + // No suppression assertion on negative anchors: under always-serve (ADR-0014) a loner + // serves its nearest by ruling. A returning existence gate is caught by the dense + // empty-pane check and the per-mate floors (GH #217). if similar.mate.mrr() < FLOOR_MATE_MRR { eprintln!( "\n[warn] per-mate MRR@{SIM_K} {:.3} is below the {FLOOR_MATE_MRR:.2} floor — discovery ranking regressed \ @@ -103,10 +66,7 @@ pub fn passes( ); return false; } - // The dense fixture's existence assertion (GH #196/#197): every note in a - // corpus where everything relates must serve candidates. An empty pane here - // can only come from an anchor-local statistic claiming "nothing relates" - // on a vault where that is false — the exact failure GH #196 measured. + // Every note in a corpus where everything relates must serve candidates (GH #196/#197). if !dense.empty_panes.is_empty() { eprintln!( "\n[warn] {} of {} dense-fixture notes serve an EMPTY pane ({}) — an existence \ @@ -117,7 +77,6 @@ pub fn passes( ); return false; } - // …and its rank floor, the dense sibling of FLOOR_MATE_MRR. if dense.mate.mrr() < FLOOR_DENSE_MATE_MRR { eprintln!( "\n[warn] dense per-mate MRR@{SIM_K} {:.3} is below the {FLOOR_DENSE_MATE_MRR:.2} floor — \ @@ -126,15 +85,8 @@ pub fn passes( ); return false; } - // The search-evidence rows (ADR-0015, GH #202), landed with the surfaces that consume - // the verdict. Everything above is discovery's and is deliberately UNCHANGED: search's - // bar moves no discovery rank and no reachability, so movement up there is a bug. - // - // All three sit at their structural zeros with no headroom — the exception to the house - // sizing method rather than an oversight, since headroom here would read as permission - // to serve a nonsense query or cut a real one. Skipped entirely when the model has no - // calibrated bar (ADR-0007): asserting the absence of a verdict would fail every run on - // a model the harness has simply not measured yet. + // The search-evidence rows (ADR-0015, GH #202). Search's bar moves no discovery rank, so + // movement above is a bug. Skipped when the model has no calibrated bar (ADR-0007). match read_shipped_bar(evidence, model_id) { None => eprintln!( "\n[note] no calibrated evidence bar for {model_id} — D2's exit-gate rows are not \ @@ -152,9 +104,6 @@ pub fn passes( ); return false; } - // The tripwire, and the direction that costs a user the answer rather - // than a little trust. Its precondition is GH #208's date-shaped pile: - // the assertion is only worth what the query shapes behind it are. if reading.pos_cut > MAX_POSITIVES_CUT { eprintln!( "\n[warn] the shipped evidence bar CUTS {} of {} labelled relevant queries where D2 \ @@ -167,11 +116,8 @@ pub fn passes( } } } - // The same two directions on the dense fixture — a different geometry, not a - // different threshold. Topical concentration is what killed the losing rule - // and is structurally inexpressible on the orthogonal corpus (process rule 2's - // token audit minimizes shared vocabulary), so these are their own rows. - // Titles carry no labels, so nothing here can be relabelled to clear it. + // The same two directions on the dense fixture: a different geometry, not a different + // threshold. let titles_cut = dense .search .titles diff --git a/crates/b2-embed/examples/eval/instrument.rs b/crates/b2-embed/examples/eval/instrument.rs index c5fe193..d961ef8 100644 --- a/crates/b2-embed/examples/eval/instrument.rs +++ b/crates/b2-embed/examples/eval/instrument.rs @@ -9,16 +9,10 @@ use std::ops::ControlFlow; use std::rc::Rc; use std::time::Instant; -/// One loaded model serving every throwaway vault of the run. +/// One loaded model serving every throwaway vault of the run, instead of a load per vault. /// -/// `Vault::open_with_embedder` takes its embedder by value, so without this each vault -/// (the orthogonal corpus's, the dense fixture's) loaded its own copy of the same -/// weights — a full model load apiece. The model is read-only and deterministic -/// (`&self` throughout), so sharing it costs the fixture none of its isolation: that -/// is a property of the *vault*, which stays its own. Every [`Embedder`] method -/// forwards, including the ones with trait defaults `LocalEmbedder` overrides (the -/// query prefix, the batched forward pass) — a method added to the trait later must -/// be forwarded here too, or the shared model silently runs the default instead. +/// Forwards every [`Embedder`] method, including the defaults `LocalEmbedder` overrides: a +/// method added to the trait must be forwarded here too, or the default silently runs. #[derive(Clone)] pub struct SharedEmbedder(Rc); @@ -46,16 +40,11 @@ impl Embedder for SharedEmbedder { } } -/// `LocalEmbedder::embed_batch` must be a faithful map of `embed`: right-padding short rows -/// to the batch's longest and masking them out has to leave each row's CLS vector unchanged. -/// The reindex path batches freely, so a regression here would silently corrupt every stored -/// vector — and every score this eval prints. -/// -/// It lives in the eval rather than `cargo test` because it needs the provisioned model, -/// which the fast suite deliberately never touches (ADR-0013). Running it here means it -/// actually runs, instead of sitting behind an `#[ignore]` nobody passes `--ignored` to. +/// `LocalEmbedder::embed_batch` must equal `embed` row by row: padding must not move a CLS +/// vector, or every stored vector and every score here is silently wrong. Lives here, not in +/// `cargo test`, because it needs the real model (ADR-0013). pub fn check_batch_matches_single(model: &dyn Embedder) -> Result<(), Box> { - // Deliberately varied lengths, so batching pads the short rows to the longest. + // Varied lengths, so batching pads. let texts = [ "Spaced repetition schedules reviews at increasing intervals.", "Sleep consolidates memory.", @@ -78,11 +67,8 @@ pub fn check_batch_matches_single(model: &dyn Embedder) -> Result<(), Box Result<(usize, f64), Box, - /// A short verbatim phrase from the target passage; when present the query is - /// also scored at chunk level (does a top-K chunk of a relevant note contain - /// it?). See queries.json's description for the labelling rules. + /// A verbatim phrase from the target passage; when present the query is also scored at + /// chunk level. Labelling rules are in queries.json. #[serde(default)] pub passage: Option, - /// Notes beyond `relevant` that are honest evidence for the query (GH #206) — - /// the per-hit tail depth. The judgement is **exhaustive** for every positive - /// query: a served note in neither `relevant` nor here is irrelevant *by - /// label*, which is the statement the tail bake-off is judged on. Never enters - /// a rank aggregate — `relevant` alone says what should rank first. + /// Other notes that are honest evidence for the query (GH #206). Exhaustive: a served + /// note in neither list is irrelevant by label. Never enters a rank aggregate. #[serde(default)] pub tail_relevant: Vec, } @@ -43,15 +35,13 @@ pub struct SimilarSet { #[derive(Deserialize)] pub struct SimilarLabel { pub anchor: String, - /// Corpus notes a human says belong next to `anchor`. **Empty = a negative - /// anchor**: the labelled answer is "nothing relates", so the right result is - /// zero candidates and everything surfaced is junk by label (similar.json). + /// Corpus notes a human says belong next to `anchor`. Empty means a negative anchor: + /// everything surfaced is junk by label. pub expected: Vec, } -/// Every note in one corpus dir, as `file name → lowercased content` — the -/// ground the label lint checks against. Lowercased once, so the passage check -/// matches the way [`score_pass`](crate::retrieval::score_pass) will (case-insensitive containment). +/// Every note in one corpus dir as `file name → lowercased content`, lowercased to match +/// [`score_pass`](crate::retrieval::score_pass)'s case-insensitive containment. pub fn corpus_texts(dir: &Path) -> Result, Box> { let mut out = HashMap::new(); for entry in std::fs::read_dir(dir)? { @@ -66,15 +56,9 @@ pub fn corpus_texts(dir: &Path) -> Result, Box Result> { - // A bare `--` is dropped rather than refused (`make stability`'s posture: a pasted - // `cargo run … -- --sweep` must not fail on its own separator); anything else - // unrecognised is refused, since a typo'd flag would otherwise run the default - // measurement while the reader believes an A/B ran. + // A bare `--` is dropped so a pasted `cargo run … -- --sweep` works. let args: Vec = std::env::args().skip(1).filter(|a| a != "--").collect(); reject_unknown_flags(&args, &["--sweep", "--stemmer"], &[])?; let sweep = has_flag(&args, "--sweep"); @@ -144,10 +98,8 @@ fn run() -> Result> { let corpus_dir = evals_dir.join("corpus"); let results_path = evals_dir.join("results.jsonl"); - // Load the labelled sets. Queries split on their labels: positives carry - // rank labels; an empty `relevant` is a negative query (invariants.md D2, - // GH #201), scored only by the search evidence calibration so its presence - // moves no rank aggregate. + // An empty `relevant` is a negative query (D2, GH #201), scored only by the search + // evidence calibration so it moves no rank aggregate. let set: QuerySet = serde_json::from_str(&std::fs::read_to_string(evals_dir.join("queries.json"))?)?; let (negatives, positives): (Vec, Vec) = @@ -158,12 +110,7 @@ fn run() -> Result> { evals_dir.join("similar-dense.json"), )?)?; - // Lint the labels against the corpora they claim to describe, before - // anything expensive runs. A typo'd label path fails nothing on its own — - // it just reads as a permanent miss (a rank of `None`, a served row - // downgraded to filler by label) and gets chased as an engine regression — - // so the run refuses to score against labels the corpus cannot honour - // (exit 1: the run broke, not the gate). + // A typo'd label path reads as a permanent miss, not an error, so lint first (exit 1). lint_labels( &corpus_dir, &evals_dir.join("corpus-dense"), @@ -173,27 +120,22 @@ fn run() -> Result> { &dense_set, )?; - // Ensure the model is available and load it — once for the whole run: both - // corpora's vaults share this one copy (a missing model is fetched here first). + // Loaded once; both corpora's vaults share it. let config = EmbedConfig::load()?; let embedder = SharedEmbedder::new(load_or_provision(&config)?); let model_id = embedder.model_id().to_string(); let dim = embedder.dim(); eprintln!("[eval] model = {model_id} (dim {dim})\n"); - // A correctness gate, not a score: every number below is computed from batched - // embeddings, so they only mean anything if batching is faithful. + // Every number below comes from batched embeddings, so check batching first. check_batch_matches_single(&embedder)?; - // Build a throwaway vault from the corpus. let scratch = ScratchVault::copy_flat(&corpus_dir)?; let vault_root = scratch.root(); let mut vault = Vault::open_with_embedder(vault_root, Box::new(embedder.clone()))?; // ---- Phase 1: projection only → the BM25-only baseline. ------------------ - // The vector space does not exist yet, so `search`/`search_chunks` run - // keyword-only (index-engine.md) — the ablation costs nothing - // extra: it is the same vault, paused between the two passes. + // No vectors yet, so search runs keyword-only (index-engine.md). let report = vault.project(false)?; let bm25 = score_pass(&vault, &positives, Retrieval::Fused)?; eprintln!( @@ -201,10 +143,7 @@ fn run() -> Result> { report.indexed ); - // The stemmer instrument's lexical arm is scored HERE, while the vault is - // still projected-but-unembedded, so `search` is honestly BM25-only under - // both tokenizers; the vault is handed back to the shipped default before - // anything embeds. + // Scored here, before embedding, so both tokenizers are BM25-only; restored after. let bm25_unstemmed = if stemmer { vault.rebuild_fts(FtsTokenizer::Unicode61)?; let pass = score_pass(&vault, &positives, Retrieval::Fused)?; @@ -219,15 +158,11 @@ fn run() -> Result> { let vector = score_pass(&vault, &positives, Retrieval::VectorOnly)?; let hybrid = score_pass(&vault, &positives, Retrieval::Fused)?; let similar = score_similar(&vault, &sim_set)?; - // The fold bake-off (GH #200, Phase B) — the candidate default-disclosure - // rules judged on the same served lists `similar` above was scored from, so - // no rule is compared against a surface the others did not see. + // The fold bake-off (GH #200), on the same served lists as `similar`. let fold = score_fold(&vault, &sim_set, "orthogonal", false)?; - // The z calibration dump (GH #187) — the same shipped surface read deep, - // since the z travels ungated on it (GH #197). + // The z calibration dump (GH #187, #197). let floor_z = score_floor_z(&vault, &sim_set)?; - // The search evidence dump (invariants.md D2, GH #201) — the query-side - // sibling of the z calibration, read over the same built vault. + // The search evidence dump (D2, GH #201). let evidence = score_search_evidence(vault_root, &vault, &positives, &negatives)?; eprintln!( "[eval] embedded {chunks} chunks in {embed_secs:.1}s ({} candidates per signal at K={K}, \ @@ -241,8 +176,7 @@ fn run() -> Result> { print_fold_bench(&fold); print_search_evidence(&evidence); print_search_bakeoff(&evidence, &bake_off(&evidence), &model_id); - // The per-hit tail bake-off (GH #206) — judged from the same served lists - // the evidence dump above recorded, against the tail_relevant keep-set. + // The per-hit tail bake-off (GH #206), from the evidence dump's served lists. let tail = score_search_tail(&evidence); print_search_tail(&tail); @@ -279,15 +213,13 @@ fn run() -> Result> { )?; // ---- Phase 3: the dense single-domain fixture (GH #196/#197, Phase 0b). -- - // Its own throwaway vault (sharing the loaded model), its own results row (corpus - // id `dense`) — the fixture measures a *vault-level* geometry, so nothing - // about it may share state with the orthogonal corpus's run above. + // Its own vault and results row: it measures vault-level geometry, so it shares no + // state with the run above. let dense = score_dense(&evals_dir, &dense_set, embedder)?; print_dense_report(&dense); print_fold_bench(&dense.fold); append_result(&results_path, &dense_row(run_id, &dense))?; - // The tail bake-off's cross-bench join (GH #206) — printable only here, - // where both corpora's readings exist in one run. + // Needs both corpora's readings (GH #206). print_tail_join(&evidence, &tail, &dense.search.titles); // ---- Optional: the A/Bs, each against the default passes above. ---------- diff --git a/crates/b2-embed/examples/eval/metrics.rs b/crates/b2-embed/examples/eval/metrics.rs index bcfe107..5bcbec5 100644 --- a/crates/b2-embed/examples/eval/metrics.rs +++ b/crates/b2-embed/examples/eval/metrics.rs @@ -3,9 +3,8 @@ use crate::K; -/// Running hit@1 / hit@3 / MRR@K over a set of 1-based ranks. ("hit@k" — each -/// query has essentially one relevant target, so precision@k and recall@k -/// coincide with it.) +/// Running hit@1 / hit@3 / MRR@K over 1-based ranks. With one target per query, +/// precision@k and recall@k coincide with hit@k. #[derive(Default)] pub struct Agg { pub n: usize, @@ -38,20 +37,15 @@ impl Agg { } } -/// The admissible interval for one threshold, read straight off two labelled -/// populations: a bar `t` keeps everything in `keep` iff `t ≤ min(keep)`, and -/// cuts everything in `cut` iff `t > max(cut)`, so every workable constant lies -/// in `(cut_max, keep_min]` — and **no constant works at all** when the two -/// populations overlap, which is a measured result rather than a failure to -/// search harder. +/// The admissible interval `(cut_max, keep_min]` for one threshold, read off two labelled +/// populations. When they overlap no constant works, which is a result, not a failure. pub struct Window { pub cut_max: f64, pub keep_min: f64, } impl Window { - /// Both edges, or `None` while either population is empty (nothing to - /// separate — an honest no-reading, not a wide-open window). + /// `None` while either population is empty: no reading, not a wide-open window. pub fn read(cut: &[f64], keep: &[f64]) -> Option { let cut_max = cut.iter().copied().fold(f64::NEG_INFINITY, f64::max); let keep_min = keep.iter().copied().fold(f64::INFINITY, f64::min); diff --git a/crates/b2-embed/examples/eval/report.rs b/crates/b2-embed/examples/eval/report.rs index e604b4c..73d3b0d 100644 --- a/crates/b2-embed/examples/eval/report.rs +++ b/crates/b2-embed/examples/eval/report.rs @@ -64,11 +64,7 @@ pub fn print_default_report( hybrid.note.mrr(), hybrid.note.hit1() - bm25.note.hit1(), ); - // The standing form of the fusion finding (GH #158): every query - // where fusing the two signals ranked the labelled answer WORSE than the - // dense signal alone would have. RRF's consensus bias makes some of this - // inevitable; the point is that it is counted and named on every run instead - // of rediscovered by hand-decomposing scores. + // Every query that fusion ranks worse than the dense signal alone (GH #158). let demoted: Vec = (0..queries.len()) .filter(|&i| { let Some(v) = vector.scores[i].note else { @@ -116,12 +112,7 @@ pub fn print_default_report( "similar (n={} positive + {} negative, K={SIM_K}):", similar.rank.n, similar.neg_n ); - // The per-anchor metric (first mate found, then stop) is **no longer - // printed** (GH #188): it read 1.000 across every change it was meant to - // judge, and a line that cannot move is a line that trains skimming. It is - // still recorded — `results.jsonl`'s `"similar"` key is unchanged, so rows - // stay comparable back to the first run — so retiring the line costs the - // dataset nothing. + // The saturated per-anchor metric is recorded in the row but not printed (GH #188). println!( " per-mate hit@1={:.2} hit@3={:.2} MRR@{SIM_K}={:.3} (n={} mates, GATED at MRR@{SIM_K} ≥ {FLOOR_MATE_MRR:.2})", similar.mate.hit1(), @@ -129,8 +120,7 @@ pub fn print_default_report( similar.mate.mrr(), similar.mate.n ); - // Discovery's precision side (GH #188) — reported with names, never gated; - // see `SimilarPass::strangers` for why gating it would reward labelling. + // Never gated; see `SimilarPass::strangers`. println!( " strangers {} card{} on {}/{} positive anchors (unlabelled notes served in the top-{SIM_K} — \ a smoke alarm, not a gate: labels aren't exhaustive)", @@ -143,19 +133,14 @@ pub fn print_default_report( println!(" {anchor} → {path}"); } if similar.neg_n > 0 { - // Under always-serve (GH #197) a negative anchor serves its ranked - // nearest like any other — that is the ruling, not a regression. What - // these anchors measure now is what the served cards *claim*: their - // bands (A2's readout, printed per leader in the calibration block). + // Under always-serve (GH #197) a negative anchor serves; its bands say what it claims. println!( " negatives {} loner anchors serve {} cards under always-serve (labels still say \ \"nothing relates\"; the bands carry the honesty — leaders below)", similar.neg_n, similar.neg_cards ); } - // The two calibration piles. If they separate, the gap IS the floor, read - // off measured data; if they overlap, no simple floor can hold and the - // escalation path (a discovery-side pair-scorer) is justified by data. + // If the piles separate, the gap is the floor; if they overlap, no simple floor holds. if let (Some((r_min, r_med, r_max)), Some((j_min, j_med, j_max))) = (pile_stats(&similar.related), pile_stats(&similar.junk)) { @@ -184,20 +169,13 @@ pub fn print_default_report( } ); } - // The same question in the floor's own anchor-relative unit, where an - // answer is actionable: the piles are absolute cosines, and no constant in - // the code is ever compared against one. + // The same question in z, the unit a constant would be stated in. print_floor_windows(floor_z); } -/// The paired per-query diff between two fused passes — what an A/B is actually -/// judged on. At this corpus's n, every aggregate delta is worth 1–2 queries, so -/// "hit@1 +0.05" and "these two queries flipped, this one broke" are the same -/// fact — but only the second form can be argued with, per-query, against the -/// labels (docs/evals.md, the process rules). Prints nothing but a -/// no-moves line when the variant reproduced the reference ranking exactly — -/// which, per the same rules, is itself a claim to verify against a -/// continuous quantity (the piles), never bare proof of "no effect". +/// The paired per-query diff between two fused passes, which an A/B is judged on +/// (docs/evals.md). No moves is a claim to verify against the piles, not proof of no +/// effect. pub fn print_rank_moves(queries: &[Labelled], reference: &Pass, variant: &Pass) { let improved = |a: Option, b: Option| match (a, b) { (None, Some(_)) => true, @@ -244,8 +222,7 @@ pub fn print_rank_moves(queries: &[Labelled], reference: &Pass, variant: &Pass) } } -/// Who scored a run and in which embedding space — the identity every row the run -/// appends carries (the orthogonal corpus's rows and the dense fixture's alike). +/// Who scored a run and in which embedding space; carried by every row the run appends. #[derive(Clone, Copy)] pub struct RunId<'a> { /// The repo's short commit, `None` outside a git checkout. @@ -254,8 +231,7 @@ pub struct RunId<'a> { pub dim: usize, } -/// One scored configuration's inputs to [`result_row`]: a short-lived, read-only view -/// over passes `run` (or an A/B) owns, assembled at the call and consumed there. +/// One scored configuration's inputs to [`result_row`]: a short-lived, read-only view. #[derive(Clone, Copy)] pub struct RowInputs<'a> { /// The config's name in the row (`"default"`, `"unicode61"`, a sweep variant). @@ -266,14 +242,12 @@ pub struct RowInputs<'a> { pub notes: usize, pub chunks: usize, pub embed_secs: f64, - /// The labelled positives every pass here was scored over. pub queries: &'a [Labelled], pub bm25: Option<&'a Pass>, pub vector: Option<&'a Pass>, pub hybrid: &'a Pass, pub similar: Option<&'a SimilarPass>, - /// The calibration blocks, which only the default row re-derives; `None` on the - /// ablation and sweep rows, whose keys then record `null`. + /// Only the default row re-derives these; other rows record `null`. pub calibration: Option>, } @@ -295,6 +269,9 @@ pub fn unix_secs() -> u64 { } /// One appendable JSONL row for a scored configuration. +/// +/// Row convention: a key is added, never redefined, so a mixed file shows a missing field +/// rather than a number whose meaning changed. Dates note when a key first appeared. pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { let RunId { git, model, dim } = run; let RowInputs { @@ -322,10 +299,7 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "git": git, "model": model, "dim": dim, - // NEW key (absent from rows before 2026-08-18): which corpus this row - // scored — the dense single-domain fixture appends its own `"dense"` - // rows (GH #196/#197), and rows must never average across corpora. - // Absent = this corpus, so every older row stays comparable unchanged. + // Since 2026-08-18; absent means orthogonal. Never average across corpora. "corpus": "orthogonal", "config": { "label": label, @@ -335,31 +309,19 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "backscan_tokens": cfg.backscan_tokens, "prepend_heading_path": cfg.prepend_heading_path, }, - // NEW key (absent from rows before 2026-08-11, which were all scored - // under the then-default `unicode61`): the chunks_fts tokenizer this row - // was scored under (GH #157). Top-level rather than inside `config`, - // which stays the ChunkConfig alone. + // Since 2026-08-11; earlier rows are `unicode61` (GH #157). "tokenizer": tokenizer, "notes": notes, "chunks": chunks, - // The caveat travels with the numbers: a row whose corpus fit inside the - // retrieval pool had both candidate lists complete, so no candidate-width - // change could have affected it and comparing it across one proves nothing - // (GH #141). Equality is blind too — a pool exactly the size of the corpus - // truncates nothing either. Both depths are recorded because since #142 the - // two views differ; the flat `pool` key of earlier rows is deliberately - // *not* reused, so a reader of a mixed file sees a missing field rather than - // one number silently meaning something narrower than it used to. + // A `pool_blind` row can't show a candidate-width change (GH #141). Earlier rows' + // flat `pool` key is not reused. "pool_note": note_candidate_pool(K), "pool_chunk": chunk_candidate_pool(K), "pool_blind": chunks <= chunk_candidate_pool(K).min(note_candidate_pool(K)), "embed_secs": embed_secs, "note": { "bm25": bm25.map(|p| agg(&p.note)), - // NEW key (absent from rows before 2026-08-10): the dense ablation — - // `Vault::search_vector_only`, the single-signal baseline fusion is - // judged against (GH #158). Same convention as pool_note/pool_chunk: - // a new key, never a redefined one. + // Since 2026-08-10 (GH #158). "vector": vector.map(|p| agg(&p.note)), "hybrid": agg(&hybrid.note), }, @@ -367,36 +329,17 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "bm25": bm25.map(|p| agg(&p.chunk)), "hybrid": agg(&hybrid.chunk), }, - // "similar" keeps its pre-negative shape (the positive anchors' rank agg) - // so rows stay comparable across the change; the negative-anchor tally and - // the calibration piles are NEW keys, absent from older rows rather than - // redefining an existing one — the same convention as pool_note/pool_chunk. "similar": similar.map(|s| agg(&s.rank)), - // NEW key (absent from rows before 2026-08-17): the per-mate, - // non-saturating companion to "similar" (GH #183). Same convention - // again — a new key, never a redefined one, so every older row stays - // comparable on "similar" itself. + // Since 2026-08-17 (GH #183). "similar_per_mate": similar.map(|s| agg(&s.mate)), - // `similar_per_mate_raw` / `similar_mates_suppressed` retired with the - // pass-vs-pass tripwire (GH #217): under always-serve both passes read - // the one surface through the same call, so the diff was a tautology. - // Absent from newer rows, never redefined — the row conventions. - // NEW key (absent from rows before 2026-08-17): discovery's precision - // side — unlabelled notes served on positive anchors at the ranks' own - // depth (GH #188). Same convention as every key above: new, never a - // redefinition. Recorded with the pairs, because the count alone is - // unarguable and the pairs are what a reader checks against the notes. + // `similar_per_mate_raw` / `similar_mates_suppressed` retired (GH #217). + // Since 2026-08-17 (GH #188), with the pairs so a reader can check them. "similar_strangers": similar.map(|s| serde_json::json!({ "cards": s.strangers.len(), "anchors": s.stranger_anchors, "positives": s.rank.n, - // The depth the count is read at — the ranks' own top-K, so a - // future SIM_K change shows up in the row instead of silently - // redefining the number. + // So a SIM_K change shows in the row rather than redefining the count. "depth": SIM_K, - // The pairs, because a bare count is unarguable: this metric is a - // smoke alarm whose correct answer is sometimes "that label is - // missing", and that argument needs the notes named. "detail": s.strangers.iter().map(|(anchor, path)| serde_json::json!({ "anchor": anchor, "path": path, @@ -405,18 +348,12 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "similar_negatives": similar.map(|s| serde_json::json!({ "n": s.neg_n, "clean": s.neg_clean, "cards": s.neg_cards, })), - // Cosine, 4 decimals: enough to place a floor, short enough to keep rows - // readable. Related = human-labelled matches; junk = everything else - // surfaced (see score_similar). + // Cosine, 4 decimals. "similar_piles": similar.map(|s| serde_json::json!({ "related": s.related.iter().map(|c| (c * 1e4).round() / 1e4).collect::>(), "junk": s.junk.iter().map(|c| (c * 1e4).round() / 1e4).collect::>(), })), - // The same scores with their per-anchor rank order kept: the relative - // drop-off cutoff is judged within one anchor's list, and tracing a pile - // value back to its pair needs the anchor. The piles above are this, - // flattened — kept anyway, because the flat distributions are what a - // quick jq/pandas histogram wants. + // The piles above with per-anchor rank order kept. "similar_detail": similar.map(|s| s.detail.iter().map(|d| serde_json::json!({ "anchor": d.anchor, "negative": d.negative, @@ -426,15 +363,7 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "related": related, })).collect::>(), })).collect::>()), - // NEW key (absent from rows before 2026-08-17): the floor's own - // calibration data in the floor's own unit — every candidate's ungated - // judge z, the populations the two constants answer to, and both - // re-derived windows (GH #187; the unit changed from stage-1 centroid z - // to stage-2 best-passage z with GH #192 — the row's "unit" field is - // what tells the two apart). Same convention as every key above: new, - // never a redefinition. This is the row the next recalibration reads, and - // the reason no window belongs in a doc comment — `null` on the ablation - // rows, which do not re-derive it. + // Since 2026-08-17 (GH #187): the z populations and both re-derived windows. "discovery_z": floor_z.map(|z| { let (mates, strangers) = (z.mate_z(), z.stranger_z()); let (neg_leaders, pos_leaders) = (z.neg_leader_z(), z.pos_leader_z()); @@ -445,12 +374,8 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { })); let round = |v: &[f64]| v.iter().map(|z| (z * 1e4).round() / 1e4).collect::>(); serde_json::json!({ - // The unit these piles/windows are measured in. Rows before - // GH #192 carried no key here and are stage-1 centroid z — a - // different unit; never compare across the flip. Since GH #197 - // the z gates nothing (the "shipped" and "replay_faults" keys - // of earlier rows retired with the gate — absent, per the - // convention, rather than redefined). + // Rows before GH #192 lack this and are stage-1 centroid z: never compare + // across the flip. "unit": "stage2-best-passage", "piles": { "mates": round(&mates), @@ -461,12 +386,9 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "window_leader": win(&neg_leaders, &pos_leaders), "window_member": win(&strangers, &mates), "ungraded_anchors": z.ungraded, - // ~0 is the only trustworthy reading — see FloorZ::recheck_delta. + // ~0 is the only trustworthy reading (FloorZ::recheck_delta). "z_recheck_max_delta": z.recheck_delta(), - // Per-anchor and per-candidate, because a window edge is only - // arguable once you can name the pair that set it. `z` is the - // stage-2 best-passage z, `cos` the same pair's cosine (the - // model-comparable unit). + // Per candidate, so the pair behind a window edge can be named. "detail": z.anchors.iter().map(|a| serde_json::json!({ "anchor": a.anchor, "negative": a.negative, @@ -479,13 +401,7 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { })).collect::>(), }) }), - // NEW key (absent from rows before 2026-08-22): the search evidence - // dump (invariants.md D2, GH #201) — per labelled query, the absolute - // signals RRF discards (BM25 hit count + best score, dense top-1 - // cosine) and the shipped surface's served count, positives and - // negatives apart, with the would-be pure-cosine query window. Same - // convention as every key above: new, never a redefinition. `null` on - // ablation/sweep rows, which do not re-derive it. + // Since 2026-08-22 (D2, GH #201). "search_evidence": evidence.map(|e| { let bar = EvidenceBar::for_model(model); let row = |r: &QueryEvidence| serde_json::json!({ @@ -495,16 +411,9 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "best_cos": r.best_cos.map(|c| (c * 1e4).round() / 1e4), "top": r.top, "served": r.served(), - // NEW keys (absent from rows before 2026-08-22, GH #201 Phase C): - // the per-query evidence the bake-off is swept over, recorded raw - // so any (fraction, coverage, cos) cell is re-derivable from a row - // without re-running the model — the `discovery_fold` convention. + // Raw, so any bake-off cell is re-derivable from the row. "dense_only": r.dense_only(), - // NEW key (absent from rows before GH #206): the served list - // itself, per row — path, per-hit provenance, and relevance by - // label — so any per-hit tail rule is re-derivable from a row - // without re-running the model, the `discovery_fold` convention - // at hit granularity. `keep` reads `relevant` ∪ `tail_relevant`. + // Since GH #206. `keep` reads `relevant` ∪ `tail_relevant`. "rows": r.rows.iter().map(|row| serde_json::json!({ "path": row.path, "bm25_rank": row.bm25_rank, @@ -514,17 +423,12 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "chunk_total": r.chunk_total, "terms": r.terms.iter().map(|(t, df)| serde_json::json!([t, df])) .collect::>(), - // The ENGINE's verdict, matching the dense row's convention and - // what the exit gate asserts — recording the harness's - // restatement here instead would mask exactly the drift the - // [FAULT] check exists to surface (the restatement is - // re-derivable from `terms` + `best_cos` + `bar` regardless). + // The engine's verdict, not the restatement, which would mask drift. "vouched": r.vouched, }); let (pos_cos, neg_cos) = (best_cos_pile(&e.positives), best_cos_pile(&e.negatives)); serde_json::json!({ - // The depth `served` is read at, so a future K change shows up - // in the row instead of silently redefining the number. + // So a K change shows in the row rather than redefining `served`. "k": K, "positives": e.positives.iter().map(row).collect::>(), "negatives": e.negatives.iter().map(row).collect::>(), @@ -533,11 +437,7 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { "keep_min": (w.keep_min * 1e4).round() / 1e4, "open": w.open(), })), - // NEW key (GH #201, Phase C): the whole bake-off grid, so the - // admissible window is re-derivable from the row — including the - // shipped bar's own three constants, which is what makes a later - // reader able to see that a bar has drifted out of the window it - // was read from. + // The whole grid and the shipped bar, so drift out of the window is visible. "bar": bar.map(|b| serde_json::json!({ "min_term_coverage": b.min_term_coverage, "min_cos": b.min_cos, @@ -552,17 +452,9 @@ pub fn result_row(run: RunId, row: RowInputs) -> serde_json::Value { })).collect::>(), }) }), - // The fold bake-off (GH #200, Phase B) — every candidate rule's reading - // on this run, with the per-anchor folds it was read from. Recorded on - // the default row only: the sweep's variants re-chunk the corpus, and a - // disclosure rule judged on a non-shipped chunker is a number about the - // chunker. + // Default row only (GH #200): on a sweep variant it would measure the chunker. "discovery_fold": fold.map(fold_json), - // NEW key (absent from rows before GH #206): the per-hit tail bake-off - // — each family's re-derived constraint and edge payoff. Default row - // only, for the same reason as `discovery_fold`; the served rows it was - // read from are under `search_evidence`, so any other constant is - // re-derivable from the row. + // Since GH #206. Default row only, like `discovery_fold`. "search_tail": tail.map(tail_json), "queries": queries.iter().enumerate().map(|(i, q)| serde_json::json!({ "q": q.query, diff --git a/crates/b2-embed/examples/eval/retrieval.rs b/crates/b2-embed/examples/eval/retrieval.rs index e3fb0da..b988fef 100644 --- a/crates/b2-embed/examples/eval/retrieval.rs +++ b/crates/b2-embed/examples/eval/retrieval.rs @@ -6,40 +6,33 @@ use crate::metrics::{paths_match, Agg}; use crate::K; use b2_core::vault::Vault; -/// One query's ranks in one retrieval mode: 1-based note rank, 1-based chunk rank -/// (only for passage-labelled queries), and the top note hit for display. +/// One query's 1-based ranks in one retrieval mode; `chunk` only for passage-labelled +/// queries. pub struct QueryScore { pub note: Option, pub chunk: Option, pub top: String, } -/// A full pass over the query set in the vault's current state (keyword-only -/// before `embed`, hybrid after): per-query scores plus note- and chunk-level -/// aggregates. +/// A pass over the query set in the vault's current state (keyword-only before `embed`, +/// hybrid after). pub struct Pass { pub scores: Vec, pub note: Agg, pub chunk: Agg, } -/// Which retrieval a pass scores. `Fused` is the shipped path (`Vault::search` — -/// BM25-only before embed, hybrid after) plus chunk-level scoring for -/// passage-labelled queries; `VectorOnly` is the dense ablation -/// (`Vault::search_vector_only`, GH #158), note-level only — its chunk aggregate -/// stays empty. The ablation column is what lets a run say whether fusion paid -/// rent: the finding that RRF demotes dense rank-1 hits was established -/// by hand-decomposing fused scores once; this makes it a standing measurement. +/// Which retrieval a pass scores. `Fused` is the shipped `Vault::search` plus chunk-level +/// scoring; `VectorOnly` is the dense ablation (GH #158), note-level only, which shows +/// whether fusion pays its way. #[derive(Clone, Copy, PartialEq)] pub enum Retrieval { Fused, VectorOnly, } -/// Score every labelled query against the vault's current state: note rank via -/// the selected retrieval, and — for passage-labelled queries on the fused path — -/// chunk rank via `search_chunks` (the first top-K chunk that belongs to a -/// relevant note AND contains the labelled phrase, case-insensitively). +/// Score every labelled query. A chunk hit is the first top-K chunk of a relevant note that +/// contains the labelled phrase, case-insensitively. pub fn score_pass( vault: &Vault, queries: &[Labelled], diff --git a/crates/b2-embed/examples/eval/tail.rs b/crates/b2-embed/examples/eval/tail.rs index d05ea4d..2f57931 100644 --- a/crates/b2-embed/examples/eval/tail.rs +++ b/crates/b2-embed/examples/eval/tail.rs @@ -1,46 +1,27 @@ -//! The per-hit search tail bake-off (GH #206): the candidate prefix-cut families, their -//! constraints re-derived on the labelled corpus and on the dense fixture's title lists, -//! and the cross-bench join. The ruling of record is **no tail fold ships** -//! (docs/evals.md); every constraint is still re-derived each run. +//! The per-hit search tail bake-off (GH #206): candidate prefix-cut families, their +//! constraints on the labelled corpus and the dense fixture, and the cross-bench join. The +//! ruling of record is that no tail fold ships (docs/evals.md). use crate::common::truncate; use crate::dense::SearchProbe; use crate::evidence::{QueryEvidence, SearchEvidence, ServedRow}; -/// A candidate **per-hit tail rule** family (GH #206) — the fold where a real -/// query's evidence runs out, D2's per-hit half. Every family folds as a -/// **prefix cut** (invariants.md D1): the default view ends at the first served -/// row failing the family's test, and every row below folds with it — a passing -/// row under a failing one folds too, because a fold that punched holes in the -/// fused order would let row order and fold visibly disagree. -/// -/// The consequence that does all the work below: a filler row served *above* a -/// keep-labelled row must pass the test too, or the keep row under it folds. So -/// each family's constraint is read over the **keep-prefix** — every row at or -/// above a list's deepest keep row — not over the keep rows alone. +/// A candidate per-hit tail rule family (D2, GH #206). Every family is a prefix cut (D1): +/// the view ends at the first failing row. So a filler row above a keep row must pass too, +/// and constraints are read over the keep-prefix, not the keep rows alone. #[derive(Clone, Copy, PartialEq)] pub enum TailRule { - /// Fold at the first row the lexical half never ranked (`bm25_rank: None`) - /// — the dense-only fold, the signal GH #201 measured (0 of 410 positive - /// rows vs 20 of 50 negative ones). Parameterless, so nothing - /// distributional to place — but **not** scale-free: `bm25_rank` is a rank - /// in a pool-truncated list (`search::pool_size`), so "never ranked" is - /// partly a fact about pool depth against vault size. `make calibrate - /// VAULT= ARGS=--search` measures that rather than assuming it - /// (process rule 5's posture, owed even without a constant). + /// Fold at the first dense-only row (GH #201). Parameterless but not scale-free: "never + /// ranked" depends on `search::pool_size` against vault size, which `make calibrate + /// ARGS=--search` measures. Lexical, - /// Fold at the first row that is dense-only **and** under a per-hit cosine - /// bar — the shipped query rule's shape (lexical OR semantic) read per hit. + /// Fold at the first row that is dense-only and under a cosine bar: the query rule's + /// shape, per hit. LexOrCos, - /// Fold at the first row under a per-hit cosine bar, lexical rank ignored - /// (a row the dense half never ranked fails at any bar). The single-signal - /// baseline the two-signal family must beat — the pure-cosine window at hit - /// granularity, included to be retired the way GH #201 retired its - /// query-level twin. + /// Fold at the first row under a cosine bar, lexical rank ignored: the single-signal + /// baseline to beat. Cos, - /// Fold at the first row whose cosine sits more than δ under the list's own - /// best — the "drop-off" shape, scale-adaptive in the query and still - /// distributional in δ. + /// Fold at the first row more than δ under the list's best cosine. CosDrop, } @@ -61,9 +42,8 @@ impl TailRule { } } - /// Whether `row` passes this family's per-hit test at constant `p` - /// (ignored by `Lexical`; `best` is the row's list's best served cosine, - /// read only by `CosDrop`). + /// Whether `row` passes at constant `p` (ignored by `Lexical`). `best` is read only by + /// `CosDrop`. pub fn passes(self, row: &ServedRow, p: f64, best: f64) -> bool { match self { TailRule::Lexical => row.bm25_rank.is_some(), @@ -73,9 +53,7 @@ impl TailRule { } } - /// Where this family folds `rows` at constant `p`: the index of the first - /// failing row, `rows.len()` when nothing folds. The prefix-cut definition - /// lives here and nowhere else. + /// The index of the first failing row, or `rows.len()`. The one prefix-cut definition. pub fn fold(self, rows: &[ServedRow], p: f64) -> usize { let best = best_served_cos(rows); rows.iter() @@ -84,18 +62,14 @@ impl TailRule { } } -/// The best served cosine of one list — [`TailRule::CosDrop`]'s reference -/// point. Read off the *served* rows, not the vault-wide dense top-1: the drop -/// rule is a claim about a list's own shape. +/// The best served cosine of one list, [`TailRule::CosDrop`]'s reference point. pub fn best_served_cos(rows: &[ServedRow]) -> f64 { rows.iter() .filter_map(|r| r.cos) .fold(f64::NEG_INFINITY, f64::max) } -/// The keep-prefix of one served list: every row at or above its deepest -/// keep-labelled row. Empty when nothing served is keep-labelled — such a list -/// constrains no rule (there is nothing a fold could wrongly hide). +/// Every row at or above the deepest keep row; empty when none is kept. pub fn keep_prefix(rows: &[ServedRow]) -> &[ServedRow] { match rows.iter().rposition(|r| r.keep) { Some(last) => &rows[..=last], @@ -103,35 +77,23 @@ pub fn keep_prefix(rows: &[ServedRow]) -> &[ServedRow] { } } -/// One family's constraint, re-derived from a set of served lists (GH #187's -/// idiom on the per-hit axis): the range of its constant, if any, at which no -/// keep-prefix row anywhere fails. Both edges carry the row that set them, -/// because a window edge is only arguable once the pair is named. +/// One family's constraint: the range of its constant at which no keep-prefix row fails, +/// with the row that set each edge. pub struct TailConstraint { - /// A keep-prefix row that cannot pass at **any** constant — the family is - /// dead on this bench (e.g. a row with no cosine under a cosine family). + /// A keep-prefix row that passes at no constant: the family is dead on this bench. pub dead: Option<(String, String)>, - /// `Cos`/`LexOrCos`: the highest admissible bar (the binding keep-prefix - /// row's own cosine). `CosDrop`: the lowest admissible δ (the binding - /// row's drop from its list's best). `None` = unconstrained — no - /// keep-prefix row engages the family's test at all. + /// `Cos`/`LexOrCos`: the highest admissible bar. `CosDrop`: the lowest admissible δ. + /// `None` when no keep-prefix row engages the test. pub edge: Option, /// The (query-or-title, path) that set `edge`. pub edge_row: Option<(String, String)>, - /// `Lexical` only: keep-prefix rows failing its fixed test. Nonzero = - /// inadmissible, and each failure is a keep row the fold would hide. + /// `Lexical` only: keep-prefix rows failing its test. Nonzero is inadmissible. pub lexical_violations: usize, } -/// Read one family's [`TailConstraint`] over `(list name, full rows, keep-prefix -/// length)` triples, where every keep-prefix row must pass. The dense fixture -/// reuses this with whole lists as keep-prefixes: on a single-domain vault every -/// served row is a real match, so the absolute "truncate nothing" is the same -/// constraint with the keep-set saturated. The full list rides along because -/// [`TailRule::CosDrop`]'s reference is the **list's** best served cosine — -/// reading it off the prefix alone could understate a required δ when the best -/// row sits below the prefix, and the constraint must read exactly what the -/// fold would. +/// Read one family's [`TailConstraint`] over `(list name, full rows, keep-prefix length)`. +/// The dense fixture passes whole lists as keep-prefixes. The full list is needed because +/// [`TailRule::CosDrop`]'s reference is the whole list's best cosine. pub fn tail_constraint<'a>( rule: TailRule, lists: impl Iterator, @@ -154,20 +116,11 @@ pub fn tail_constraint<'a>( } } } - // The cosine arms read the row's cosine through a finiteness - // filter (PR #212 review): a NaN would sail through `c < e` - // comparisons as silently-true-nowhere — worse, `is_none_or` - // admits the FIRST row unconditionally, so a NaN first row - // would seed the edge. A non-finite reading is *no reading*, - // and the honest arm for that is `dead`, with the row named — - // never a silent skip that reports the family unconstrained. + // Filter non-finite cosines (PR #212): `is_none_or` admits the first row + // unconditionally, so a NaN would seed the edge. No reading means `dead`. TailRule::LexOrCos => { if row.bm25_rank.is_none() { match row.cos.filter(|c| c.is_finite()) { - // A served row is in at least one list, so a - // dense-only row carries a cosine unless it is - // non-finite — either way, no finite reading means - // this row can pass at no bar. None => { if out.dead.is_none() { out.dead = Some((name.to_string(), row.path.clone())); @@ -215,11 +168,8 @@ pub fn tail_constraint<'a>( out } -/// What a family buys at constant `p` on the labelled lists: rows cut, split by -/// label. At an admissible constant `kept_cut` is zero **by construction** — -/// the constraint above is exactly "every keep-prefix row passes" — so a -/// nonzero here is an arithmetic fault, printed as such rather than assumed -/// away. +/// Rows a family cuts at constant `p`, by label. At an admissible constant `kept_cut` is +/// zero by construction, so nonzero is printed as a fault. pub struct TailPayoff { pub filler_cut: usize, pub kept_cut: usize, @@ -243,28 +193,19 @@ pub fn tail_payoff(rule: TailRule, lists: &[&QueryEvidence], p: f64) -> TailPayo out } -/// The per-hit tail bake-off's labelled-corpus reading (GH #206): every -/// family's re-derived constraint plus its payoff at the constraint's own edge -/// — the most aggressive admissible point, since payoff is monotone in the -/// constant. Judged over the **positives**: the query-level bar already -/// answers the negatives whole (GH #201), so a tail rule ships riding on a -/// vouched verdict; the negatives' would-be reading is kept as context for -/// what the rule would buy where a query bar had missed. +/// The tail bake-off's labelled-corpus reading (GH #206): each family's constraint and its +/// payoff at the edge. Judged over the positives, since the query bar already cuts the +/// negatives (GH #201). pub struct TailBench { pub keep_rows: usize, pub filler_rows: usize, - /// Positives whose keep-prefix is the whole served list — a fold has - /// nothing to cut there under any admissible rule. + /// Positives whose keep-prefix is the whole served list. pub saturated: usize, - /// The **oracle** ceiling: rows below each list's last keep row — what a - /// perfect prefix fold (one placed by the labels themselves) would cut. - /// Every family's payoff is read against this, because "cuts N rows" means - /// nothing until the reachable maximum is beside it. + /// The oracle ceiling: rows below each list's last keep row, what a label-placed fold + /// would cut. pub oracle: usize, pub families: Vec, - /// Junk rows the parameterless lexical fold would cut on the negatives' - /// served lists, of their total — the GH #201 `dense_only` reading, priced - /// as a fold. + /// Rows the lexical fold would cut on the negatives' lists. pub neg_lexical_cut: usize, pub neg_rows: usize, } @@ -272,9 +213,7 @@ pub struct TailBench { pub struct TailFamilyReading { pub rule: TailRule, pub constraint: TailConstraint, - /// Payoff at the constraint's edge; `None` when the family is dead here or - /// (for the constant families) unconstrained-and-therefore-identical to a - /// simpler family. + /// Payoff at the constraint's edge; `None` when dead or unconstrained. pub payoff: Option, } @@ -421,10 +360,8 @@ pub fn print_search_tail(bench: &TailBench) { ); } -/// The tail bake-off's JSON (`search_tail` in the orthogonal row): each -/// family's re-derived constraint and edge payoff. The served rows it was read -/// from are already in `search_evidence` per query, so any other constant is -/// re-derivable from the row — the `discovery_fold` convention. +/// The tail bake-off's JSON (`search_tail`). The served rows are in `search_evidence`, so +/// any other constant is re-derivable from the row. pub fn tail_json(bench: &TailBench) -> serde_json::Value { let round = |v: f64| (v * 1e4).round() / 1e4; serde_json::json!({ @@ -446,11 +383,8 @@ pub fn tail_json(bench: &TailBench) -> serde_json::Value { }) } -/// One family's dense-fixture reading (GH #206): the same [`TailConstraint`] -/// read over **whole** title lists — every served row is a real match by -/// geometry, so the keep-prefix is the entire list and the constraint *is* the -/// absolute ("truncate nothing"). `lexical_cut` is the parameterless family's -/// visible cost here: rows its fold would remove from title queries' views. +/// One family's dense-fixture reading (GH #206), over whole title lists: every row is a real +/// match, so the constraint is "truncate nothing". `lexical_cut` is the lexical fold's cost. pub struct TailFamilyTransfer { pub rule: TailRule, pub constraint: TailConstraint, @@ -479,9 +413,7 @@ pub fn dense_tail_families(titles: &[SearchProbe]) -> Vec { .collect() } -/// Print the dense fixture's tail-transfer reading (GH #206) — the per-hit -/// sibling of the query bar's `search_transfer` block, and the bench that -/// carries D1's absolute for this bake-off. +/// Print the dense fixture's tail-transfer reading (GH #206). pub fn print_dense_tail(titles: &[SearchProbe]) { println!( " tail the per-hit tail bench on this geometry (GH #206): every served row of a" @@ -530,8 +462,7 @@ pub fn print_dense_tail(titles: &[SearchProbe]) { } } -/// The dense tail-transfer reading as JSON, nested under the dense row's -/// `search_transfer` key (additive subkey, per the row conventions). +/// The dense tail-transfer reading as JSON, under the dense row's `search_transfer`. pub fn dense_tail_json(titles: &[SearchProbe]) -> serde_json::Value { let round = |v: f64| (v * 1e4).round() / 1e4; serde_json::json!(dense_tail_families(titles) @@ -546,11 +477,8 @@ pub fn dense_tail_json(titles: &[SearchProbe]) -> serde_json::Value { .collect::>()) } -/// The tail bake-off's **cross-bench join** (GH #206), printed once both -/// corpora have been read in the same run: a family ships only if some -/// constant is admissible on the labelled corpus *and* folds nothing on the -/// dense fixture *and* still cuts labelled filler there — read at the joint -/// edge, the most aggressive point both benches allow. +/// The tail bake-off's cross-bench join (GH #206): a family survives only if some constant +/// is admissible on both benches and still cuts labelled filler at the joint edge. pub fn print_tail_join(ev: &SearchEvidence, orth: &TailBench, titles: &[SearchProbe]) { println!("\n{}", "=".repeat(78)); println!("search tail — the cross-bench join (GH #206; both corpora, one run)"); @@ -597,8 +525,7 @@ pub fn print_tail_join(ev: &SearchEvidence, orth: &TailBench, titles: &[SearchPr if orth_dead { "labelled" } else { "dense" } ) } else { - // Joint edge: the tighter bench binds. `None` = that bench - // leaves the constant free. + // The tighter bench binds; `None` leaves the constant free. let joint = match rule { TailRule::CosDrop => { match (orth_f.constraint.edge, dense_f.constraint.edge) { diff --git a/crates/b2-embed/examples/stability.rs b/crates/b2-embed/examples/stability.rs index 923f628..76b8096 100644 --- a/crates/b2-embed/examples/stability.rs +++ b/crates/b2-embed/examples/stability.rs @@ -1,29 +1,16 @@ -//! Rank-stability probe — the **large-corpus** half of the eval harness (GH #141). +//! Rank-stability probe: the large-corpus half of the eval harness (GH #141). //! -//! The scored eval's corpus is barely bigger than the candidate pools each signal retrieves -//! (since GH #183 it crosses the narrower passage-view pool by a few chunks; the note view's -//! not at all) — so at that scale a pool is scarcely ever truncated, widening it can add -//! almost nothing, and candidate-width changes are mostly **invisible** there. A change to a -//! hit pool or to `search::pool_size` reads as "no change" on the eval while genuinely -//! reordering a real vault: GH #140's 3x widening moved 5 of 7 probe top-10s on -//! `fixtures/test-vault` and printed bit-identical eval numbers. This probe priced that, and -//! GH #142 reverted on the strength of it. Width is the whole of the gap — `RRF_K` -//! re-weights the *same* lists, which the scored eval already sees. +//! The scored eval's corpus is too small for candidate pools to truncate, so candidate-width +//! changes are invisible there. This runs on a vault big enough for the pool to bind and +//! reports: //! -//! It runs on a vault big enough for the pool to **bind** (~200 notes / ~790 chunks) and -//! reports two things: +//! 1. **Pool sensitivity**: the same query asked at several depths, hence pool widths. RRF +//! over a wider pool need not keep the shallow answer as a prefix; how often it breaks is +//! what candidate width is worth here. +//! 2. **Baseline drift**: the shipped top-K against the committed baseline. `--bless` +//! accepts it. //! -//! 1. **Pool sensitivity** — retrieval width is a function of the ask, so asking the same -//! query at several depths asks it at several pool widths. A pool-invariant retriever -//! would answer a shallow ask with an exact prefix of the deep one; RRF over a widened -//! pool does not, because a candidate ranked in *both* lists can outscore one ranked top -//! of a single list (`2/121 > 1/61` at k = 60). How often the prefix breaks is how much -//! candidate width is worth on this vault. -//! 2. **Baseline drift** — the shipped top-K against the committed baseline, so any future -//! ranking change is visible as movement rather than inferred. `--bless` accepts it. -//! -//! The corpus is **unlabelled**: this scores no relevance and never says *better*, only -//! *different, and by how much*. +//! The corpus is unlabelled: this says different, never better. //! //! ```console //! cargo run -p b2-embed --example stability # the probe (fake embedder) @@ -33,19 +20,11 @@ //! cargo run -p b2-embed --example stability -- --vault path/to/vault //! ``` //! -//! **Why the fake embedder by default.** It is deterministic, so the committed baseline means -//! the same thing on every machine — a real-model baseline would be device-specific -//! (ADR-0007) and could not be committed. The cost is that fake vector ranking is -//! uncorrelated with BM25, which *exaggerates* how much a pool change moves results; use -//! `--model` when the real magnitude is what the decision needs. -//! -//! **The control experiment:** `--vault crates/b2-embed/evals/corpus` runs the same probe on -//! the eval corpus, where every prefix holds at every depth — that *is* #141: not stability, -//! blindness. +//! The fake embedder is the default because it is deterministic, so the baseline can be +//! committed (a real-model one is device-specific, ADR-0007). It exaggerates pool effects; +//! use `--model` for real magnitudes. //! -//! Never a gate, and drift is not failure: a knob change is *supposed* to move ranking, and -//! an unlabelled corpus cannot say whether it improved. Exit status is 0 for any completed -//! measurement. +//! Never a gate: exit status is 0 for any completed measurement. mod common; @@ -61,24 +40,16 @@ use std::time::Instant; /// The vault the committed baseline is defined over, relative to the repo root. const DEFAULT_VAULT: &str = "fixtures/test-vault"; -/// The depths each probe is asked at; each widens the pool it retrieves from, by view since -/// #142, and the table prints each column's own widths. -/// -/// The first pair is what settled #142: at a 10-result ask the passage view's shipped 3x -/// headroom retrieved 150 candidates per signal against the conservative `limit + 2`'s 60 — -/// the same step measured here between depths 4 and 10 under the note view's 3x. It moved 10 -/// of 10 probes' top-4 passages: real movement, unpriced by any relevance eval. +/// The depths each probe is asked at; each widens the pool it retrieves from (GH #142). const DEPTHS: [usize; 3] = [4, 10, 30]; -/// How deep a prefix the depths are compared over — capped by the shallowest ask. +/// How deep a prefix the depths are compared over, capped by the shallowest ask. const PREFIX: usize = DEPTHS[0]; /// The depth the committed baseline records: what an adapter's default search shows. const BASELINE_K: usize = DEPTHS[1]; /// How much of a chunk's text goes into its baseline key (see [`chunk_key`]). const KEY_CHARS: usize = 48; -/// Report column widths: the probe text, then one column per compared view. const PROBE_COL: usize = 44; const CELL_COL: usize = 26; -/// Every flag this probe takes; anything else is refused rather than ignored. const KNOWN_FLAGS: [&str; 4] = ["--bless", "--model", "--verbose", "--vault"]; /// The hand-authored probe set (`evals/stability.json`) — plain queries, no labels. @@ -90,13 +61,12 @@ struct ProbeSet { /// The committed ranking snapshot (`evals/stability-baseline.json`). #[derive(Serialize, Deserialize)] struct Baseline { - /// Kept in the file so the artifact explains itself where it is read. #[serde(rename = "_note")] note: String, vault: String, embedder: String, k: usize, - /// The commit the snapshot was blessed at, best-effort (`None` outside a checkout). + /// The commit the snapshot was blessed at, best-effort. blessed_at: Option, probes: Vec, } @@ -108,8 +78,7 @@ struct BaselineProbe { chunks: Vec, } -/// One probe's answer at one depth: the ranked note paths (`Vault::search`) and the -/// ranked chunk keys (`Vault::search_chunks`). +/// One probe's answer at one depth: ranked note paths and chunk keys. struct Answer { notes: Vec, chunks: Vec, @@ -123,11 +92,7 @@ fn main() { } fn run() -> Result<(), Box> { - // A bare `--` is dropped, not parsed, defensively. `make stability ARGS=--verbose` - // needs no `--` of its own (`cargo run ... -- $(ARGS)` already supplies the one - // separator), but the habit of typing one anyway — e.g. pasting a raw - // `cargo run ... -- --verbose` invocation into ARGS — is common enough that the - // example should not reject its own documented usage on a literal `--`. + // A bare `--` is dropped so a pasted `cargo run ... -- --verbose` in ARGS still works. let args: Vec = std::env::args().skip(1).filter(|a| a != "--").collect(); let bless = has_flag(&args, "--bless"); let real_model = has_flag(&args, "--model"); @@ -135,9 +100,7 @@ fn run() -> Result<(), Box> { let vault_arg = flag_value(&args, "--vault")?; reject_unknown_flags(&args, &KNOWN_FLAGS, &["--vault"])?; - // A baseline is a *deterministic* artifact over a *committed* vault; blessing - // one from a real-model run or a private vault would commit a number nobody - // else can reproduce. + // A baseline must be reproducible: fake embedder, committed vault. if bless && real_model { return Err("--bless needs the deterministic fake embedder; drop --model".into()); } @@ -163,10 +126,7 @@ fn run() -> Result<(), Box> { return Err("stability.json lists no probes".into()); } - // Work on a throwaway copy: indexing writes `.b2/`, and the committed fixtures - // are never ours to mutate — the same isolation the integration tests and - // `make compare-device` use. (Indexing no longer writes to the vault itself at - // all — W1, GH #170 — so the copy is now about `.b2/` alone.) + // A throwaway copy, since indexing writes `.b2/` into the committed fixture. let tmp = tempfile::TempDir::new()?; let root = tmp.path().join("vault"); copy_dir_all(&source, &root)?; @@ -192,11 +152,7 @@ fn run() -> Result<(), Box> { report.indexed, t0.elapsed().as_secs_f64(), ); - // Checked against the *widest* pool a probe reaches (the note view's): a vault between - // the two widths leaves the note half stable by construction while the passage half - // still measures something, which is a partly-blind run worth saying so about. - // Inclusive, like the eval's own blindness check — a pool exactly the size of the corpus - // truncates nothing either. + // Checked against the widest pool (the note view's), so a partly-blind run warns too. if chunks <= note_candidate_pool(BASELINE_K) { eprintln!( "[warn] {chunks} chunks ≤ the {}-candidate pool the note view reaches (the passage view\n\ @@ -209,7 +165,7 @@ fn run() -> Result<(), Box> { } eprintln!(); - // One retrieval per (probe, depth); everything below is computed from these. + // One retrieval per (probe, depth). let mut answers: Vec> = Vec::with_capacity(probes.probes.len()); for query in &probes.probes { let mut per_depth = Vec::with_capacity(DEPTHS.len()); @@ -221,8 +177,6 @@ fn run() -> Result<(), Box> { report_pool_sensitivity(&probes.probes, &answers, verbose); - // The baseline is the deterministic mode's artifact over the committed vault; - // any other run still gets the sensitivity report above, which needs no baseline. let baseline_path = evals_dir.join("stability-baseline.json"); if bless { write_baseline(&baseline_path, &probes.probes, &answers)?; @@ -242,10 +196,8 @@ fn run() -> Result<(), Box> { Ok(()) } -/// Ask one probe at one depth, keeping only what identifies a result: the note path -/// and the chunk key. Scores are deliberately not compared — RRF scores move -/// whenever the pool does, so comparing them would report "different" for changes -/// that reorder nothing, and the question here is what the human *sees*. +/// Ask one probe at one depth, keeping only result identities. Scores aren't compared: they +/// move with the pool even when nothing reorders. fn answer(vault: &Vault, query: &str, depth: usize) -> Result> { Ok(Answer { notes: vault @@ -261,11 +213,8 @@ fn answer(vault: &Vault, query: &str, depth: usize) -> Result String { let head: String = hit .text @@ -282,9 +231,7 @@ fn chunk_key(hit: &ChunkSearchResult) -> String { ) } -/// One label per adjacent depth pair: the per-signal candidate widths that pair -/// spans, for the view whose pool function is passed. A `fn` pointer rather than a -/// generic — two call sites, one line each, and no reason for a type parameter. +/// One label per adjacent depth pair: the per-signal candidate widths it spans for `pool`. fn width_steps(pool: fn(usize) -> usize) -> Vec { DEPTHS .windows(2) @@ -300,8 +247,7 @@ fn report_pool_sensitivity(probes: &[String], answers: &[Vec], verbose: " a cell is how many of the top-{PREFIX} results the two asks agree on, position by position;" ); println!(" `=` means the shallow answer is an exact prefix of the deep one (pool-invariant)."); - // Each view states its *own* widths: the two headroom rules differ (GH #142), so - // one shared pair of numbers would mislabel a column. + // Each view has its own widths (GH #142). let chunk_steps = width_steps(chunk_candidate_pool); let note_steps = width_steps(note_candidate_pool); println!(); @@ -348,11 +294,7 @@ fn report_pool_sensitivity(probes: &[String], answers: &[Vec], verbose: } println!(); - // Denominators are the probes that actually produced something to compare, not - // the probe count: a probe that returned nothing measured nothing, and folding - // it in either direction would misreport (see [`prefix_cell`]). - // Keyed by the *ask*, since the two views no longer span the same widths across - // one depth pair — each view's own candidates/signal step is named beside it. + // Denominators count only measured probes (see [`prefix_cell`]). for s in 0..DEPTHS.len() - 1 { println!( " ask {:>2}→{:<2}: {}/{} probes changed their top-{PREFIX} chunks ({} candidates/signal), \ @@ -391,12 +333,9 @@ fn report_pool_sensitivity(probes: &[String], answers: &[Vec], verbose: } } -/// One depth-pair cell: how much of the shallow answer the deeper ask preserved. -/// -/// Three outcomes, not two. A comparison with **no overlapping prefix** — a probe -/// that matched nothing, so both asks returned empty — is `measured = false`: it is -/// neither stable nor moved, and counting it as "stable" would let a report that -/// measured nothing announce that everything is pool-invariant. +/// One depth-pair cell: how much of the shallow answer the deeper ask preserved. An empty +/// comparison is unmeasured, not stable, so a report that measured nothing can't claim +/// invariance. struct Cell { label: String, moved: bool, @@ -424,16 +363,13 @@ fn prefix_cell(shallow: &[String], deep: &[String]) -> Cell { } } -/// `--verbose`: the two rankings that disagreed, so the movement is inspectable -/// rather than a bare fraction. Only the chunk lists — they are the un-deduped view -/// the fusion actually produces. +/// `--verbose`: the two chunk rankings that disagreed (the un-deduped fusion output). fn print_divergence(per_depth: &[Answer]) { for w in 0..DEPTHS.len() - 1 { let (lo, hi) = (&per_depth[w], &per_depth[w + 1]); if !prefix_cell(&lo.chunks, &hi.chunks).moved { continue; } - // The chunk lists, so the passage view's own widths label them. println!( " pool {} vs {}:", chunk_candidate_pool(DEPTHS[w]), @@ -465,10 +401,7 @@ fn report_baseline_drift( return Ok(()); }; let baseline: Baseline = serde_json::from_str(&raw)?; - // The snapshot records what produced it, and that provenance is checked rather - // than decorative: comparing today's ranking against one blessed from a - // different vault or a different embedder would report drift that is really a - // mismatch, and the reader would have no way to tell the two apart. + // Otherwise drift would really be an instrument mismatch. if baseline.vault != DEFAULT_VAULT || baseline.embedder != "fake" { return Err(format!( "baseline was blessed from {} under the {} embedder, not {DEFAULT_VAULT} under fake — \ @@ -615,8 +548,7 @@ fn write_baseline( Ok(()) } -/// Recursive copy — the fixture has topic subfolders, so the flat copy the scored -/// eval uses for its single-level corpus would silently drop 9 of 10 topics. +/// Recursive copy: the fixture has topic subfolders, which the eval's flat copy would drop. fn copy_dir_all(from: &Path, to: &Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { @@ -632,8 +564,7 @@ fn copy_dir_all(from: &Path, to: &Path) -> std::io::Result<()> { Ok(()) } -/// `--vault ` / `--vault=`, erroring on a flag given without a value -/// rather than silently probing the default vault under a name the user did not mean. +/// `--vault ` / `--vault=`, erroring when the value is missing. fn flag_value(args: &[String], name: &str) -> Result, Box> { for (i, arg) in args.iter().enumerate() { if let Some(rest) = arg.strip_prefix(&format!("{name}=")) { @@ -649,8 +580,7 @@ fn flag_value(args: &[String], name: &str) -> Result, Box bool { match (a.canonicalize(), b.canonicalize()) { (Ok(a), Ok(b)) => a == b, @@ -658,12 +588,8 @@ fn same_dir(a: &Path, b: &Path) -> bool { } } -/// Paths are printed relative to the repo root where possible — an absolute -/// `/Users/…/B2/crates/…` line is noise in a report meant to be pasted into an issue. -/// -/// A path that does not exist yet still resolves: the baseline is *named* before it -/// is written (`--bless` writes crates/…/stability-baseline.json), and canonicalize -/// only works on what exists — so a missing leaf falls back to its parent. +/// `path` relative to the repo root where possible. A missing leaf (a baseline not yet +/// written) resolves through its parent, since canonicalize needs an existing path. fn display_from_repo(path: &Path) -> String { let Ok(root) = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/crates/b2-embed/src/config.rs b/crates/b2-embed/src/config.rs index e0f9e6f..e7ed690 100644 --- a/crates/b2-embed/src/config.rs +++ b/crates/b2-embed/src/config.rs @@ -1,45 +1,34 @@ -//! The global embedder config — where the model comes from and where it's cached. +//! The global embedder config: where the model comes from and where it's cached. //! -//! Resolution order: the TOML at `$XDG_CONFIG_HOME/b2/config.toml` (if present) -//! over compiled defaults; the `HF_ENDPOINT` env var (the standard Hugging Face -//! mirror knob) supplies the endpoint when the file's `source` names none. A vault with no config file gets a -//! working default — zero-config is the happy path (the fail-fast config rule -//! applies to the *model files*, via `b2 init`, not to this file). +//! `$XDG_CONFIG_HOME/b2/config.toml` over compiled defaults; `HF_ENDPOINT` supplies the +//! endpoint when the file's `source` names none. No config file is the happy path. use crate::{EmbedError, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -/// The default model: BAAI/bge-base-en-v1.5 — BERT-family, 768-dim, ungated, and -/// validated in the spike. `dim` is *not* set here; it is read authoritatively from -/// the model's own `config.json` (`hidden_size`) at load, so config can never lie -/// about it. +/// The default model. Its dim is read from the model's own `config.json` at load, never +/// configured. pub const DEFAULT_MODEL: &str = "BAAI/bge-base-en-v1.5"; -/// One embedding model B2 knows how to run, as offered in the desktop's settings -/// picker. [`AVAILABLE_MODELS`] is the **single source of truth** for "which models B2 -/// supports": adding one is a single entry here — no host or UI change, the picker -/// fills automatically. `id` is the Hugging Face repo id, which is exactly what lands -/// in `meta.embed_model_id`, so choosing a different one *is* the model swap that -/// re-embeds on the next `reindex` (index-engine.md §8). +/// One embedding model B2 can run, as offered in the settings picker. `id` lands in +/// `meta.embed_model_id`, so choosing another is a model swap that re-embeds on the next +/// `reindex` (index-engine.md §8). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ModelInfo { /// Repo id == `meta.embed_model_id` (e.g. `BAAI/bge-base-en-v1.5`). pub id: &'static str, /// Human label for the picker (e.g. `BGE Base EN v1.5`). pub label: &'static str, - /// Embedding dimension, for display. Authoritatively re-read from the model's own - /// `config.json` at load; this is only the picker's at-a-glance number. + /// Embedding dimension, for display only (the real one is read at load). pub dim: usize, /// One-line "why pick this" shown under the picker. pub description: &'static str, } -/// Every model the picker can offer. Each is BERT-family + ungated with the three -/// [`REQUIRED_FILES`](crate::model::REQUIRED_FILES), so it drops into the same -/// [`LocalEmbedder`](crate::LocalEmbedder) with no code change — the real dim is read from -/// the model's own `config.json` at load, and the query prefix is shared. `dim` here is -/// display-only. More land here as they're vetted. +/// Every supported model; adding one is one entry here. Each must be BERT-family and +/// ungated with the [`REQUIRED_FILES`](crate::model::REQUIRED_FILES), so it runs in the same +/// [`LocalEmbedder`](crate::LocalEmbedder) with the shared query prefix. pub const AVAILABLE_MODELS: &[ModelInfo] = &[ ModelInfo { id: DEFAULT_MODEL, @@ -56,15 +45,13 @@ pub const AVAILABLE_MODELS: &[ModelInfo] = &[ }, ]; -/// The registry entry for `id`, or `None` if it isn't a model B2 supports — the guard -/// [`EmbedConfig::set_model`] uses to refuse writing a config the loader can't provision. +/// The registry entry for `id`, or `None` if B2 doesn't support it. fn find_model(id: &str) -> Option<&'static ModelInfo> { AVAILABLE_MODELS.iter().find(|m| m.id == id) } -/// A registry model annotated for the settings picker: which one is configured now, and -/// which are already downloaded. Owned (unlike [`ModelInfo`]'s `&'static str`s) because -/// it crosses the IPC boundary as the `list_models` / `set_model` payload. +/// A registry model annotated for the settings picker (current, installed). Owned because +/// it crosses the IPC boundary. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ModelChoice { pub id: String, @@ -73,8 +60,7 @@ pub struct ModelChoice { pub description: String, /// The model this config currently resolves to (from config.toml, else the default). pub current: bool, - /// Already provisioned into the shared cache (all required files present); when - /// false, choosing it needs a `b2 init` before it can embed. + /// All required files are in the cache; otherwise it needs `b2 init`. pub installed: bool, } @@ -82,8 +68,7 @@ pub struct ModelChoice { /// index-engine.md §5). Documents are embedded verbatim. Empty ⇒ symmetric. pub const DEFAULT_QUERY_PREFIX: &str = "Represent this sentence for searching relevant passages: "; -/// The standard Hugging Face mirror knob: the endpoint `b2 init` downloads from when -/// the config's `source` names none. +/// The standard Hugging Face mirror knob, used when the config's `source` names none. const ENV_HF_ENDPOINT: &str = "HF_ENDPOINT"; /// Where the model files are fetched from when `b2 init` provisions them. @@ -101,20 +86,17 @@ pub enum Source { /// The resolved embedder configuration. #[derive(Debug, Clone)] pub struct EmbedConfig { - /// Model identifier — recorded as `meta.embed_model_id`, so changing it is a - /// model swap that re-embeds on the next `reindex` (index-engine.md §8). + /// Recorded as `meta.embed_model_id`; changing it re-embeds (index-engine.md §8). pub model: String, /// Where to fetch the files from. pub source: Source, - /// The shared, machine-level cache dir (XDG data). One copy per machine, *not* - /// per-vault `.b2/` — the model is a runtime dep, not vault data. + /// The machine-level cache dir, not per-vault: the model is not vault data. pub cache_dir: PathBuf, /// The query-side prompt prefix (empty ⇒ symmetric embedding). pub query_prefix: String, } -/// The `[embedder]` table as written in TOML. Every field optional → any subset -/// overrides the defaults. +/// The `[embedder]` table as written in TOML; any subset overrides the defaults. #[derive(Debug, Default, Deserialize)] struct RawFile { #[serde(default)] @@ -131,9 +113,7 @@ struct RawEmbedder { } impl EmbedConfig { - /// Load from the standard config path, falling back to defaults where the file - /// (or any field) is absent. `HF_ENDPOINT` supplies the mirror endpoint when the - /// file's `source` names none. + /// Load from the standard config path, falling back to defaults. pub fn load() -> Result { let raw = match Self::config_path() { Some(p) if p.is_file() => { @@ -149,16 +129,13 @@ impl EmbedConfig { )) } - /// The standard config file location: `$XDG_CONFIG_HOME/b2/config.toml`. Public - /// so an adapter can name the file in a "check your config" message. + /// `$XDG_CONFIG_HOME/b2/config.toml`, public so an adapter can name it in a message. pub fn config_path() -> Option { dirs::config_dir().map(|d| d.join("b2").join("config.toml")) } - /// The file's fields over the defaults. `hf_endpoint` is the `HF_ENDPOINT` value, - /// passed in rather than read here so resolution is testable without mutating a - /// process-global that parallel tests share (`b2_llm::LlmConfig::resolve`'s reason). - /// Blank reads as unset. + /// The file's fields over the defaults. `hf_endpoint` is passed in so tests needn't + /// mutate the process env. Blank reads as unset. fn from_raw(e: RawEmbedder, hf_endpoint: Option) -> Self { let model = e.model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); let endpoint_env = hf_endpoint.filter(|s| !s.trim().is_empty()); @@ -178,9 +155,8 @@ impl EmbedConfig { } } - /// Interpret the free-form `source` string (locked as one field): a URL is a - /// mirror endpoint; an existing filesystem path is a local install; anything - /// else is an alternate repo id. Absent ⇒ the default HF repo == `model`. + /// Interpret the free-form `source`: a URL is a mirror endpoint, an existing dir a + /// local install, anything else an alternate repo id. Absent means the repo `model`. fn resolve_source(model: &str, source: Option, endpoint_env: Option) -> Source { match source { Some(s) if is_url(&s) => Source::Hf { @@ -199,31 +175,22 @@ impl EmbedConfig { } } - /// The flat directory the configured model's files live in under the cache — - /// predictable (`/`) so "is it installed?" is a plain - /// file check. + /// `/`, so "is it installed?" is a plain file check. pub(crate) fn model_dir(&self) -> PathBuf { self.model_dir_for(&self.model) } - /// [`model_dir`](Self::model_dir) for any registry id — the one place the cache - /// path is joined, so the loader and the picker's installed flag agree. + /// [`model_dir`](Self::model_dir) for any id; the one place the path is joined. fn model_dir_for(&self, model: &str) -> PathBuf { self.cache_dir.join(sanitize(model)) } - /// Whether the model with repo id `model` is already provisioned in *this* config's - /// cache — all [`REQUIRED_FILES`](crate::model::REQUIRED_FILES) present. A cheap - /// file-existence check (no model load), so the settings picker can flag which - /// choices are installed vs. still need `b2 init`. Uses the config's own `cache_dir` - /// so a custom cache is honored, not just the default. + /// Whether `model`'s files are all in this config's cache. A file check, no load. pub fn is_model_provisioned(&self, model: &str) -> bool { crate::model::files_present(&self.model_dir_for(model)) } - /// The full registry annotated against this config — the data the settings picker - /// renders. Pure (a function of `self` + [`AVAILABLE_MODELS`]), so both the mapping - /// and the current/installed flags are unit-testable without the real config dir. + /// The full registry annotated against this config, for the settings picker. pub fn model_choices(&self) -> Vec { AVAILABLE_MODELS .iter() @@ -238,36 +205,28 @@ impl EmbedConfig { .collect() } - /// Persist `model` as the configured embedder in the standard `config.toml`, - /// preserving every other field and table already there. This is the **one config - /// both adapters read** (`load`), so the CLI and desktop always agree on the model — - /// a divergence would build the vault's vectors with one model and read them with - /// another, which `search` refuses (`ModelMismatch`). Refuses an id not in - /// [`AVAILABLE_MODELS`] rather than write a config the loader can't provision. + /// Persist `model` in the standard `config.toml`, keeping every other field. Both + /// adapters read this one file, so they can't embed with different models. Refuses an + /// id not in [`AVAILABLE_MODELS`]. pub fn set_model(model: &str) -> Result<()> { let path = Self::config_path() .ok_or_else(|| EmbedError::Config("no config directory on this platform".into()))?; Self::write_model(&path, model) } - /// [`set_model`](Self::set_model) against an explicit path — the testable core (a - /// tempfile stands in for the real config). Validates *before* touching the - /// filesystem, so a rejected model leaves no file behind; creates the file (and its - /// parent dir) when absent, giving zero-config users a minimal `[embedder]` table. + /// [`set_model`](Self::set_model) against an explicit path. Validates before touching + /// the filesystem; creates the file and its parent when absent. fn write_model(path: &Path, model: &str) -> Result<()> { if find_model(model).is_none() { return Err(EmbedError::UnknownModel(model.to_string())); } - // Read the existing document (if any) so other keys/tables survive the write. let mut doc: toml::Table = match std::fs::read_to_string(path) { Ok(text) => toml::from_str(&text) .map_err(|e| EmbedError::Config(format!("{}: {e}", path.display())))?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(), Err(e) => return Err(EmbedError::Io(e)), }; - // Set (or create) `[embedder].model`, leaving any sibling fields (source, - // cache_dir, query_prefix) intact. An existing non-table `embedder` is malformed; - // replace it rather than propagate the corruption. + // A non-table `embedder` is malformed; replace it. let embedder = doc .entry("embedder".to_string()) .or_insert_with(|| toml::Value::Table(toml::Table::new())); @@ -287,8 +246,7 @@ impl EmbedConfig { } } -/// Default XDG cache: `~/.local/share/b2/models` (falls back to `./.b2-models` only -/// if no home/data dir is discoverable, which is not expected on a normal machine). +/// `/b2/models`, or `./.b2-models` when no data dir is discoverable. fn default_cache_dir() -> PathBuf { dirs::data_dir() .map(|d| d.join("b2").join("models")) @@ -366,7 +324,6 @@ mod tests { source: Some("BAAI/bge-small-en-v1.5".into()), ..Default::default() }; - // model id stays what `model` says; source repo is the override. let c = EmbedConfig::from_raw(e, None); assert!(matches!(c.source, Source::Hf { repo, .. } if repo == "BAAI/bge-small-en-v1.5")); } @@ -386,7 +343,7 @@ mod tests { "default must be offered" ); assert!(find_model("no/such-model").is_none()); - // No duplicate ids (the picker keys on id, and set_model validates against it). + // No duplicate ids. let mut ids: Vec<_> = AVAILABLE_MODELS.iter().map(|m| m.id).collect(); ids.sort_unstable(); ids.dedup(); @@ -396,7 +353,6 @@ mod tests { #[test] fn model_choices_flag_current_and_installed() { let tmp = tempfile::TempDir::new().unwrap(); - // Default model, cache pointed at an empty temp dir → current but not installed. let mut c = EmbedConfig::from_raw(RawEmbedder::default(), None); c.cache_dir = tmp.path().to_path_buf(); let choices = c.model_choices(); @@ -406,7 +362,6 @@ mod tests { assert_eq!(current[0].id, DEFAULT_MODEL); assert!(!current[0].installed, "empty cache ⇒ not installed"); - // Drop the required files into the model dir → the flag flips to installed. let dir = c.model_dir(); std::fs::create_dir_all(&dir).unwrap(); for f in crate::model::REQUIRED_FILES { @@ -425,7 +380,6 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("b2/config.toml"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - // A pre-existing config with other embedder fields set (and no model yet). std::fs::write( &path, "[embedder]\nsource = \"https://mirror.example\"\nquery_prefix = \"Q: \"\n", @@ -436,7 +390,6 @@ mod tests { let raw: RawFile = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(raw.embedder.model.as_deref(), Some(DEFAULT_MODEL)); - // Siblings survive the write — set_model touches only `model`. assert_eq!( raw.embedder.source.as_deref(), Some("https://mirror.example") @@ -447,7 +400,6 @@ mod tests { #[test] fn write_model_creates_missing_file_and_parent() { let tmp = tempfile::TempDir::new().unwrap(); - // Neither the file nor its parent dir exists yet — write must `mkdir -p`. let path = tmp.path().join("state/b2/config.toml"); EmbedConfig::write_model(&path, DEFAULT_MODEL).unwrap(); let raw: RawFile = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); diff --git a/crates/b2-embed/src/lib.rs b/crates/b2-embed/src/lib.rs index d42ca23..ac39d13 100644 --- a/crates/b2-embed/src/lib.rs +++ b/crates/b2-embed/src/lib.rs @@ -1,14 +1,9 @@ -//! `b2-embed` — B2's real, local embedder: candle + hf-hub producing embeddings inside the -//! single binary, with **BAAI/bge-base-en-v1.5** @ dim 768 as the default model (ADR-0020). +//! `b2-embed`: B2's real local embedder (candle, default BAAI/bge-base-en-v1.5, ADR-0020), +//! behind the [`b2_core::embed::Embedder`] seam (ADR-0005). Only the adapters wire it in. //! -//! It sits **behind the [`b2_core::embed::Embedder`] seam** (ADR-0005), so the store, the -//! flows and the whole `b2-core` suite never see it — they run against the deterministic -//! `FakeEmbedder`. The adapters are the only clients that wire the real model in. -//! -//! The model is **not bundled**: an explicit [`provision`] (`b2 init`) downloads and -//! verifies it into a shared XDG cache, and [`LocalEmbedder::load`] fails fast if it is -//! absent. Configurable via `$XDG_CONFIG_HOME/b2/config.toml`, whose `source` can point at -//! a mirror, an alternate repo, or a local path for a fully-offline install. +//! The model is not bundled: [`provision`] (`b2 init`) fetches it into a shared cache, and +//! [`LocalEmbedder::load`] fails fast if it is absent. `$XDG_CONFIG_HOME/b2/config.toml` +//! can point `source` at a mirror, another repo, or a local path. mod config; mod model; @@ -18,23 +13,17 @@ pub use config::{EmbedConfig, ModelChoice, ModelInfo, Source, AVAILABLE_MODELS, pub use model::{active_device_label, LocalEmbedder}; pub use provision::{provision, ProvisionReport}; -/// The environment variable that forces the deterministic fake embedder everywhere: -/// `B2_EMBEDDER=fake`, the offline/dev mode both adapters honour and their suites run -/// under. +/// `B2_EMBEDDER=fake` forces the deterministic fake embedder everywhere. pub const ENV_EMBEDDER: &str = "B2_EMBEDDER"; -/// Whether `B2_EMBEDDER=fake` is in force. Read in one place so the CLI and the desktop -/// cannot disagree about what the switch means. +/// Whether `B2_EMBEDDER=fake` is in force; one reader so the adapters can't disagree. pub fn fake_requested() -> bool { std::env::var_os(ENV_EMBEDDER).is_some_and(|v| v == "fake") } -/// The embedder a command should open its vault with — the adapters' one wiring rule. -/// -/// A command that embeds (`needs_semantic`) gets the real, configured [`LocalEmbedder`], -/// failing fast with [`EmbedError::NotProvisioned`] ("run `b2 init`") when it is absent. -/// Everything else — and every command under [`fake_requested`] — gets `None`, which the -/// caller reads as "open with the core's fake", so no model is needed just to read. +/// The embedder a command should open its vault with. A command that embeds gets the real +/// [`LocalEmbedder`] (or [`EmbedError::NotProvisioned`]); everything else, and everything +/// under [`fake_requested`], gets `None`: open with the core's fake. pub fn embedder_for(needs_semantic: bool) -> Result> { if !needs_semantic || fake_requested() { return Ok(None); @@ -42,9 +31,8 @@ pub fn embedder_for(needs_semantic: bool) -> Result> { LocalEmbedder::load(&EmbedConfig::load()?).map(Some) } -/// Errors from provisioning/loading the local model. Embed-*time* failures map into -/// [`b2_core::Error::Embed`] so the index path surfaces one error type; the -/// setup-time errors here carry the actionable "run `b2 init`" guidance. +/// Errors from provisioning or loading the model. Embed-time failures map into +/// [`b2_core::Error::Embed`] instead. #[derive(thiserror::Error, Debug)] pub enum EmbedError { #[error("io error: {0}")] @@ -53,8 +41,7 @@ pub enum EmbedError { #[error("config error: {0}")] Config(String), - /// The model is not in the cache yet — the fail-fast the CLI turns into - /// "run `b2 init`". Carries the model id and the directory that was checked. + /// The model is not in the cache yet. #[error("embedding model '{model}' is not installed (looked in {dir}); run `b2 init`")] NotProvisioned { model: String, dir: String }, @@ -64,10 +51,8 @@ pub enum EmbedError { #[error("model load failed: {0}")] Load(String), - /// A model id that isn't in [`AVAILABLE_MODELS`] was passed to - /// [`EmbedConfig::set_model`] — refuse rather than write a config the loader could - /// never provision. Reachable only from the desktop settings picker (the CLI never - /// sets the model), so it maps to a generic "pick one from the list" message there. + /// [`EmbedConfig::set_model`] got an id not in [`AVAILABLE_MODELS`]. Reachable only + /// from the desktop settings picker. #[error("unknown embedding model '{0}'")] UnknownModel(String), } diff --git a/crates/b2-embed/src/model.rs b/crates/b2-embed/src/model.rs index f2f8b64..767d1f6 100644 --- a/crates/b2-embed/src/model.rs +++ b/crates/b2-embed/src/model.rs @@ -1,6 +1,6 @@ -//! [`LocalEmbedder`] — the candle-backed BERT sentence embedder that satisfies the -//! [`b2_core::embed::Embedder`] seam. Loaded from the provisioned cache; a missing -//! model is a fail-fast "run `b2 init`", never a surprise mid-command download. +//! [`LocalEmbedder`]: the candle BERT sentence embedder behind the +//! [`b2_core::embed::Embedder`] seam. A missing model fails fast ("run `b2 init`"); the read +//! path never downloads. use crate::config::EmbedConfig; use crate::{EmbedError, Result}; @@ -13,29 +13,24 @@ use tokenizers::{ TruncationStrategy, }; -/// The three files a BERT sentence model needs. Presence of all three in the flat -/// model dir *is* the "installed" check (fail-fast surface) — [`files_present`]. +/// The three files a BERT sentence model needs. pub const REQUIRED_FILES: [&str; 3] = ["config.json", "tokenizer.json", "model.safetensors"]; -/// Whether every [`REQUIRED_FILES`] entry sits in `dir` — **the** "installed" -/// check, shared by [`LocalEmbedder::load`]'s fail-fast, the provision fast path, -/// and the settings picker's installed flag, so the three can never drift. +/// Whether every [`REQUIRED_FILES`] entry is in `dir`: the one "installed" check, shared +/// by `load`, provisioning and the settings picker. pub fn files_present(dir: &std::path::Path) -> bool { REQUIRED_FILES.iter().all(|f| dir.join(f).is_file()) } -/// BERT's positional limit; longer chunks are truncated so position embeddings are -/// never indexed out of range. Capped again by the model's own config. +/// BERT's positional limit, capped again by the model's own config. const MAX_TOKENS: usize = 512; -/// A loaded local embedding model. Cheap to embed with once loaded; loading (which -/// mmaps the weights) is the one-time cost. +/// A loaded local embedding model. Loading (which mmaps the weights) is the one-time cost. pub struct LocalEmbedder { model: BertModel, tokenizer: Tokenizer, device: Device, - /// The configured repo id (`config.model`), untagged by device — what the - /// adapters attribute embedding cost to. + /// The configured repo id, without the device tag. configured_model: String, /// The id recorded as `meta.embed_model_id`: the repo id, tagged by device. model_id: String, @@ -44,9 +39,7 @@ pub struct LocalEmbedder { } impl LocalEmbedder { - /// Load the provisioned model named by `config` from its cache dir. Fails fast - /// with [`EmbedError::NotProvisioned`] if the files are absent — the read path - /// never downloads. + /// Load the provisioned model named by `config`, or [`EmbedError::NotProvisioned`]. pub fn load(config: &EmbedConfig) -> Result { let dir = config.model_dir(); if !files_present(&dir) { @@ -64,7 +57,6 @@ impl LocalEmbedder { let mut tokenizer = Tokenizer::from_file(dir.join("tokenizer.json")) .map_err(|e| EmbedError::Load(format!("tokenizer.json: {e}")))?; - // Truncate long chunks so position embeddings stay in range. tokenizer .with_truncation(Some(TruncationParams { max_length: max_len, @@ -73,11 +65,9 @@ impl LocalEmbedder { direction: TruncationDirection::Right, })) .map_err(|e| EmbedError::Load(format!("truncation: {e}")))?; - // Pad a batch to its longest member so `embed_batch` can stack sequences of - // differing lengths into one tensor; the attention mask zeroes the pad - // positions, so a padded row's CLS vector equals its single-encode vector. - // `BatchLongest` leaves a single `encode` (batch of one) unpadded, so the - // `embed`/`embed_query` path is unchanged. bge/BERT's `[PAD]` id is 0. + // Pad a batch to its longest member so `embed_batch` can stack it; the attention + // mask makes a padded row's CLS vector equal its single-encode vector. A single + // `encode` stays unpadded. BERT's `[PAD]` id is 0. tokenizer.with_padding(Some(PaddingParams { strategy: PaddingStrategy::BatchLongest, pad_id: 0, @@ -86,16 +76,12 @@ impl LocalEmbedder { ..Default::default() })); - // Pick the compute device (GH #40). Default build → CPU (with Accelerate BLAS, see - // Cargo.toml); a `--features metal` build → the Apple-Silicon GPU, with a graceful CPU - // fallback. The *resolved* device tags the recorded model id (`@metal`), so a device - // switch is a model swap: `ensure_embedding_space` re-embeds and `search` fails fast - // rather than mixing CPU and GPU vectors in one space. + // The resolved device tags the model id (GH #40), so a device switch is a model + // swap and CPU and GPU vectors never mix in one space. let (device, device_tag) = select_device(); let model_id = tagged_model_id(&config.model, device_tag); - // SAFETY: memory-maps the safetensors weights. Sound as long as the file is - // not mutated while mapped; it is a read-only file in our XDG cache, written - // once by `b2 init` (provision) and never touched again for the process's life. + // SAFETY: memory-maps the safetensors weights. Sound as long as the file is not + // mutated while mapped; it is written once by `b2 init` and never touched again. let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[dir.join("model.safetensors")], DTYPE, &device) .map_err(|e| EmbedError::Load(format!("weights: {e}")))? @@ -114,16 +100,13 @@ impl LocalEmbedder { }) } - /// The configured model's repo id, **without** the device tag - /// [`model_id`](Embedder::model_id) carries — the id the config and the settings - /// picker speak, so an adapter's per-model bookkeeping keys on the same name. + /// The configured repo id, without the device tag [`model_id`](Embedder::model_id) + /// carries: the id the config and settings picker use. pub fn configured_model(&self) -> &str { &self.configured_model } - /// The pooled, L2-normalized embedding of one `text`: a single, unpadded encode - /// through [`forward_cls`](Self::forward_cls). Deliberately not a batch of one — a - /// query takes the plain single-sequence path. + /// The L2-normalized embedding of one `text`, from a single unpadded encode. fn embed_inner(&self, text: &str) -> candle_core::Result> { let enc = self .tokenizer @@ -134,10 +117,8 @@ impl LocalEmbedder { .ok_or_else(|| candle_core::Error::Msg("the model returned no embedding".into())) } - /// Embed a batch in one forward pass: tokenize+pad to the batch's longest, then - /// [`forward_cls`](Self::forward_cls). One matmul over `B` texts is far cheaper on - /// CPU than `B` single passes — the reindex win. Equivalent, per row, to - /// [`embed_inner`](Self::embed_inner). + /// Embed a batch in one forward pass, far cheaper than `B` single passes. Equal, per + /// row, to [`embed_inner`](Self::embed_inner). fn embed_batch_inner(&self, texts: &[&str]) -> candle_core::Result>> { if texts.is_empty() { return Ok(Vec::new()); @@ -149,14 +130,11 @@ impl LocalEmbedder { self.forward_cls(&encs) } - /// The step both paths share: stack same-length encodings into `[B, L]`, run them - /// through BERT, take each row's CLS token (position 0 — what bge is trained for, and - /// unaffected by right-padding), and L2-normalize so the index's L2 distance ranks by - /// cosine. + /// Run same-length encodings through BERT and take each row's CLS token (what bge is + /// trained for), L2-normalized so the index's L2 distance ranks by cosine. fn forward_cls(&self, encs: &[Encoding]) -> candle_core::Result>> { let batch = encs.len(); - // A single encode is unpadded; a batch was padded to its longest. Either way - // every encoding here has the same length. + // Every encoding here has the same length (a batch was padded). let seq = encs.first().map_or(0, |e| e.get_ids().len()); let mut ids = Vec::with_capacity(batch * seq); let mut mask = Vec::with_capacity(batch * seq); @@ -200,8 +178,7 @@ impl Embedder for LocalEmbedder { } } -/// A candle error crossing the [`Embedder`] seam, as the core's error type. A free -/// fn rather than a `From` impl — both types are foreign here (orphan rule). +/// A candle error as the core's error type. Not a `From` impl: both types are foreign. fn embed_err(e: candle_core::Error) -> b2_core::Error { b2_core::Error::Embed(e.to_string()) } @@ -211,14 +188,10 @@ fn l2_normalize(v: &[f32]) -> Vec { v.iter().map(|x| x / norm).collect() } -/// Try to open the Metal GPU — only when compiled `--features metal` (candle's -/// `metal_is_available()` is literally `cfg!(feature = "metal")`), and never a hard -/// requirement: any failure returns `None` and the caller uses the CPU (GH #40). `announce` -/// gates the fallback notice so the load path can say it while the cheap capability probe -/// ([`active_device_label`]) stays silent. The notice goes to stderr rather than a log -/// event on purpose: it explains a user-visible consequence (a CPU-tagged embedding space, -/// so a Metal-built index reads as a model change), and a log is off unless asked for. No `unwrap`: a failed `new_metal` is a soft -/// degrade (no-panic rule), not a load error. +/// Try to open the Metal GPU (only in a `--features metal` build); any failure returns +/// `None` and the caller uses the CPU (GH #40). `announce` prints the fallback to stderr, +/// not a log, since it has a user-visible consequence (a Metal-built index reads as a +/// model change). fn open_metal(announce: bool) -> Option { if candle_core::utils::metal_is_available() { match Device::new_metal(0) { @@ -232,8 +205,7 @@ fn open_metal(announce: bool) -> Option { None } -/// Pick the inference device and a short tag (`"cpu"`/`"metal"`) describing what we *actually* -/// got — so the recorded model id reflects the resolved device, and a fallback build honestly +/// The inference device and a tag for what was actually resolved, so a fallback build /// records CPU vectors. fn select_device() -> (Device, &'static str) { match open_metal(true) { @@ -242,10 +214,7 @@ fn select_device() -> (Device, &'static str) { } } -/// Human label for the compute device this build embeds on — `"Metal"` on a `--features metal` -/// build with a working GPU, else `"CPU"` (GH #40). The desktop Settings badge renders it. -/// Same resolution as [`select_device`] but silent; cheap — the compile-time gate -/// short-circuits on a CPU build before any GPU probe. +/// `"Metal"` or `"CPU"` for the Settings badge (GH #40): [`select_device`], but silent. pub fn active_device_label() -> &'static str { match open_metal(false) { Some(_) => "Metal", @@ -253,11 +222,9 @@ pub fn active_device_label() -> &'static str { } } -/// The id recorded as `meta.embed_model_id`, tagged by the resolved device. CPU keeps the -/// bare repo id (so existing indexes need no migration); any non-CPU device appends `@` -/// so its vectors live in a distinct embedding space — the swap that forces a re-embed and -/// makes `search` fail fast rather than mix devices. The tag is only in this id, never in -/// `config.model` (model-file lookup is unaffected). +/// The id recorded as `meta.embed_model_id`. CPU keeps the bare repo id (existing indexes +/// need no migration); other devices append `@`, a distinct embedding space. Never +/// in `config.model`. fn tagged_model_id(base: &str, device_tag: &str) -> String { if device_tag == "cpu" { base.to_string() @@ -272,12 +239,10 @@ mod tests { #[test] fn cpu_id_is_untagged_others_are_suffixed() { - // CPU keeps the bare repo id — existing indexes must not be seen as a model swap. assert_eq!( tagged_model_id("BAAI/bge-base-en-v1.5", "cpu"), "BAAI/bge-base-en-v1.5" ); - // A non-CPU device tags a distinct embedding space, so a switch re-embeds + fails fast. assert_eq!( tagged_model_id("BAAI/bge-base-en-v1.5", "metal"), "BAAI/bge-base-en-v1.5@metal" @@ -286,10 +251,8 @@ mod tests { #[test] fn files_present_is_loads_own_precondition() { - // What the desktop's `semantic` probe rests on (GH #133): the cheap file check - // and `load`'s fail-fast ask the same question, so answering with the former - // never overstates the model — and where they *do* part company (a corrupt - // model), the failure is `load`'s, which is fail-fast and actionable. + // The desktop's `semantic` probe relies on this (GH #133): the file check never + // overstates the model, and a corrupt model still fails in `load`. let tmp = tempfile::TempDir::new().unwrap(); let config = EmbedConfig { model: "acme/not-a-real-model".to_string(), @@ -298,17 +261,13 @@ mod tests { query_prefix: String::new(), }; - // Empty cache: the probe says "not installed" and `load` refuses for exactly - // that reason — the two agree, which is what lets the probe stand in. assert!(!config.is_model_provisioned(&config.model)); assert!(matches!( LocalEmbedder::load(&config), Err(EmbedError::NotProvisioned { .. }) )); - // Present-but-corrupt: the probe reports installed (it is a *file* check), and - // the refusal moves to `load` — no longer `NotProvisioned`, so the file gate - // demonstrably passed and only the deeper parse failed. + // Present but corrupt: the file check passes and `load` fails deeper. let dir = config.model_dir(); std::fs::create_dir_all(&dir).unwrap(); for f in REQUIRED_FILES { @@ -323,14 +282,10 @@ mod tests { #[test] fn select_device_falls_back_to_cpu_without_the_metal_feature() { - // The default (no-feature) test build has `metal_is_available() == false`, so selection - // resolves to CPU and the tag is "cpu" — this keeps the whole test suite on the CPU path. - // (A `--features metal` build exercises the GPU branch out-of-CI; see the eval recipe.) if !candle_core::utils::metal_is_available() { let (device, tag) = select_device(); assert_eq!(tag, "cpu"); assert!(matches!(device, Device::Cpu)); - // The Settings-badge label agrees with the resolved device. assert_eq!(active_device_label(), "CPU"); } } diff --git a/crates/b2-embed/src/provision.rs b/crates/b2-embed/src/provision.rs index ac97f1a..6ce4b63 100644 --- a/crates/b2-embed/src/provision.rs +++ b/crates/b2-embed/src/provision.rs @@ -1,7 +1,5 @@ -//! Model provisioning — the work behind `b2 init`. Downloads (or copies, for a -//! local source) the model files into the shared XDG cache and verifies them by -//! actually loading the model and embedding once. Idempotent: a already-installed, -//! loadable model is a no-op. +//! Model provisioning, the work behind `b2 init`: fetch the model into the shared cache and +//! verify it by loading and embedding once. Idempotent. use crate::config::{EmbedConfig, Source}; use crate::model::{files_present, LocalEmbedder, REQUIRED_FILES}; @@ -21,14 +19,12 @@ pub struct ProvisionReport { pub already_present: bool, } -/// Provision the model named by `config` into its cache dir. Prints progress via -/// `log` (a simple line sink so the CLI owns all stdout formatting). Verifies the -/// result by loading + embedding once, so a half-downloaded or corrupt model is -/// caught here rather than at first `search`. +/// Provision the model named by `config` into its cache dir, reporting progress through +/// `log` (the CLI owns stdout). Verifying here catches a corrupt download before the +/// first `search`. pub fn provision(config: &EmbedConfig, mut log: impl FnMut(&str)) -> Result { let model_dir = config.model_dir(); - // Idempotent fast path: present + loadable ⇒ done. if files_present(&model_dir) { if let Ok(e) = LocalEmbedder::load(config) { log(&format!("Model '{}' already installed.", config.model)); @@ -50,7 +46,6 @@ pub fn provision(config: &EmbedConfig, mut log: impl FnMut(&str)) -> Result, } @@ -76,11 +58,9 @@ impl Question { struct Scored { question: String, unanswerable: bool, - /// Did retrieval hand the model a passage from a labelled note? retrieval_hit: bool, passages: usize, citations: usize, - /// Did the answer cite a labelled note? cited_expected: bool, /// `[n]` markers naming no passage — invisible in the citation list. hallucinated_markers: usize, @@ -108,17 +88,14 @@ fn main() { fn run() -> Result> { let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); let evals_dir = manifest.join("evals"); - // Deliberately the *retrieval* eval's corpus: chat is scored over the notes - // whose retrieval behaviour is already characterised, so a surprise here can - // be attributed rather than guessed at. + // The retrieval eval's corpus, whose retrieval behaviour is already characterised. let corpus_dir = manifest.join("../b2-embed/evals/corpus"); let results_path = evals_dir.join("results.jsonl"); let set: QuestionSet = serde_json::from_str(&std::fs::read_to_string(evals_dir.join("questions.json"))?)?; - // The chat provider first: it is the one that needs a server running, and - // finding that out after a model download and an embed pass would be rude. + // Probe the chat server first, before a model download and an embed pass. let llm_config = LlmConfig::from_env(); let llm = OpenAiCompatProvider::new(llm_config.clone()); llm.probe()?; @@ -132,7 +109,6 @@ fn run() -> Result> { let embed_model = embedder.model_id().to_string(); eprintln!("[eval] embedder = {embed_model}"); - // A throwaway vault from the corpus — nothing here touches a real one. let scratch = ScratchVault::copy_flat(&corpus_dir)?; let vault = Vault::open_with_embedder(scratch.root(), Box::new(embedder))?; let report = vault.reindex()?; @@ -153,11 +129,8 @@ fn run() -> Result> { append_result(&results_path, &row)?; eprintln!("\n[eval] appended one row to {}", results_path.display()); - // The floor is a liveness check, not a quality bar (see the module doc): fail - // only when retrieval *did* hand the model labelled notes and nothing cited - // one — a broken pipeline. When retrieval reached nothing there is no chat - // result to judge at all; that is `--example eval`'s finding, printed above as - // a retrieval reach of 0, and failing here would blame the wrong half. + // A liveness check, not a quality bar. Reaching nothing is retrieval's finding, not + // chat's, so it doesn't fail here. let reached = scored .iter() .filter(|s| !s.unanswerable && s.retrieval_hit) @@ -171,10 +144,7 @@ fn run() -> Result> { /// Ask one question through the real flow ④ and score what came back. fn ask_one(vault: &Vault, llm: &dyn LlmProvider, q: &Question) -> Result> { - // Retrieval, scored separately *before* the ask: `Vault::ask` retrieves the - // same way for a single-turn question (no condensation), so this is the same - // passage set the model is about to see — and it is the ceiling the citation - // score is judged against. + // A single-turn `Vault::ask` retrieves exactly this passage set (no condensation). let passages = vault.search_chunks(&q.question, ASK_PASSAGES)?; let retrieval_hit = passages.iter().any(|p| q.expect.contains(&p.path)); @@ -188,9 +158,7 @@ fn ask_one(vault: &Vault, llm: &dyn LlmProvider, q: &Question) -> Result Result Result serde_json::Value { let answerable: Vec<&Scored> = scored.iter().filter(|s| !s.unanswerable).collect(); let negatives: Vec<&Scored> = scored.iter().filter(|s| s.unanswerable).collect(); @@ -277,7 +239,6 @@ fn summarize(scored: &[Scored], llm: &LlmConfig, embed_model: &str) -> serde_jso "chat_endpoint": llm.base_url, "embed_model": embed_model, "questions": scored.len(), - // The ceiling: chat can only be scored where retrieval delivered. "retrieval_reach": share(reached.len(), answerable.len()), "citation_accuracy": share( reached.iter().filter(|s| s.cited_expected).count(), diff --git a/crates/b2-llm/src/lib.rs b/crates/b2-llm/src/lib.rs index 97c3bd8..204a446 100644 --- a/crates/b2-llm/src/lib.rs +++ b/crates/b2-llm/src/lib.rs @@ -1,23 +1,10 @@ -//! `b2-llm` — B2's real chat provider: the wire half of the second AI seam (ADR-0005), a -//! hand-rolled **sync** OpenAI-compatible streaming client behind -//! [`b2_core::llm::LlmProvider`], exactly as `b2-embed` sits behind `Embedder`. The engine -//! never sees it — flow ④ is built and tested against `FakeLlm`. +//! `b2-llm`: B2's real chat provider, a hand-rolled sync OpenAI-compatible streaming client +//! behind [`b2_core::llm::LlmProvider`] (ADR-0005, GH #151, GH #154). The wire is one +//! endpoint shape, so a client library would buy a dependency tree, not leverage. Sync over +//! `ureq` (ADR-0011): cancellation is an early return from the read loop. //! -//! Decisions (GH #151, cut as GH #154): -//! - **Build our own.** The wire surface is *one* endpoint shape — -//! `POST {base}/chat/completions` with `stream: true`, SSE frames until `[DONE]` — so a -//! client library would buy a dependency tree, not leverage. Tool calls (ADR-0022) rode -//! in on that same stream as `delta.tool_calls`, so they did not change the answer. -//! - **Sync end-to-end, over `ureq`** (ADR-0011): no runtime, no async-to-sync bridge, and -//! **cancellation is just returning early from a blocking read loop**. -//! - **Any OpenAI-compatible URL.** Ollama is the *guided* default; LM Studio, llama.cpp, -//! vLLM and the cloud compat endpoints are a URL change. Note content leaves the machine -//! only by explicit configuration (M5), and nothing here defaults to one. -//! - **The wire quirks we own are the ones we handle**, unit-tested over canned frames in -//! [`sse`] — the wire is the whole reason this crate exists, so it is the part with tests. -//! -//! Chat carries **no index identity** (contrast ADR-0007): nothing is recorded in `meta`, -//! nothing is cached, and swapping models never touches the index. +//! Ollama is the guided default; any compatible URL works. Note content leaves the machine +//! only by explicit configuration (M5). Chat carries no index identity (contrast ADR-0007). mod provider; mod setup; @@ -33,24 +20,20 @@ pub use setup::{ OLLAMA_INSTALL_URL, }; -/// The environment variable that swaps in the deterministic [`FakeLlm`]: `B2_LLM=fake`, -/// `B2_EMBEDDER=fake`'s sibling for the chat seam. +/// `B2_LLM=fake` swaps in the deterministic [`FakeLlm`]. pub const ENV_LLM: &str = "B2_LLM"; -/// What an adapter says when [`fake_requested`] is in force — never overstate what -/// answered. One sentence, so the CLI's stderr note and the desktop's setup card agree. +/// What both adapters say when [`fake_requested`] is in force. pub const FAKE_NOTICE: &str = "The fake chat provider is in use (B2_LLM=fake) — answers are \ deterministic test scaffolding, not a model."; -/// Whether `B2_LLM=fake` is in force. Read in one place so the two adapters cannot -/// disagree about what the switch means. +/// Whether `B2_LLM=fake` is in force; one reader so the adapters can't disagree. pub fn fake_requested() -> bool { std::env::var_os(ENV_LLM).is_some_and(|v| v == "fake") } /// The provider a chat command talks to: the fake under [`fake_requested`], else the real -/// client over `config`. **No probe** — for a caller that has already checked the endpoint -/// (the desktop probes once, when its chat surface opens) or wants none. +/// client. Not probed; see [`probed_provider`]. pub fn provider(config: LlmConfig) -> Box { if fake_requested() { Box::new(FakeLlm) @@ -59,9 +42,7 @@ pub fn provider(config: LlmConfig) -> Box { } } -/// [`provider`], **probed** before it is returned — the `b2 init` posture applied to chat: -/// a stopped server is an [`LlmError`] before the question, never a surprise after it. The -/// fake has nothing to probe. +/// [`provider`], probed first, so a stopped server fails before the question. pub fn probed_provider(config: LlmConfig) -> Result, LlmError> { if fake_requested() { return Ok(Box::new(FakeLlm)); @@ -71,45 +52,33 @@ pub fn probed_provider(config: LlmConfig) -> Result, LlmErr Ok(Box::new(provider)) } -/// Where the Ollama daemon serves its OpenAI-compatible surface. The *guided* -/// default per GH #151: local-first, no key, and the runtime B2's onboarding -/// speaks natively — while the chat path itself stays generic `/v1`. +/// Ollama's OpenAI-compatible surface, the guided default (GH #151). pub const DEFAULT_BASE_URL: &str = "http://localhost:11434/v1"; -/// The default chat model id. A 3B-class local model: the spec's picker -/// heuristic puts an 8 GB machine at "3–4B q4 or cloud opt-in", and a default -/// that runs on the smallest supported machine is the one that can be a -/// default. It is **not bundled or downloaded** — `ollama pull` is the user's, -/// and an absent model is an error the adapters phrase (E4). +/// The default chat model: a 3B-class model that runs on the smallest supported (8 GB) +/// machine. Never downloaded by B2; an absent model is an error the adapters phrase (E4). pub const DEFAULT_MODEL: &str = "llama3.2"; /// Environment variable naming the OpenAI-compatible base URL. pub const ENV_URL: &str = "B2_LLM_URL"; /// Environment variable naming the chat model id. pub const ENV_MODEL: &str = "B2_LLM_MODEL"; -/// Environment variable carrying the bearer token for a **cloud** endpoint. -/// Never a CLI flag: a key passed as a flag is a key in `ps` output and in shell -/// history. It is also the **override** — see [`ApiKeySource::Environment`]. +/// Environment variable carrying the bearer token for a cloud endpoint. Never a CLI flag +/// (it would show in `ps` and shell history). Outranks a stored key. pub const ENV_API_KEY: &str = "B2_LLM_API_KEY"; -/// Environment variable capping the tool calls one model reply may make — see -/// [`LlmConfig::max_tool_calls`]. +/// Environment variable for [`LlmConfig::max_tool_calls`]. pub const ENV_MAX_TOOL_CALLS: &str = "B2_LLM_MAX_TOOL_CALLS"; -/// The default for [`LlmConfig::max_tool_calls`]. Far above what a real reply asks for -/// (B2 runs at most a handful per round), and small enough that the table a reply can -/// make B2 allocate stays a few kilobytes. +/// The default for [`LlmConfig::max_tool_calls`]: far above a real reply, and a few KB. pub const DEFAULT_MAX_TOOL_CALLS: usize = 64; -/// The highest value [`LlmConfig::max_tool_calls`] accepts, from any source. The cap -/// exists to bound memory a server can make B2 allocate, so it needs a bound of its own: -/// at this size the table is a few hundred kilobytes, and no real reply comes near it. +/// The highest [`LlmConfig::max_tool_calls`] from any source: a memory bound needs its own +/// bound. pub const MAX_TOOL_CALLS_CEILING: usize = 4096; -/// Judge a typed tool-call cap — the **one** parser, shared by [`ENV_MAX_TOOL_CALLS`] and -/// an adapter's Settings field so the two can never accept different things. A whole -/// number from 1 (zero would refuse every call) to [`MAX_TOOL_CALLS_CEILING`]; anything -/// else is `None`. +/// Parse a tool-call cap, for both [`ENV_MAX_TOOL_CALLS`] and the Settings field: a whole +/// number from 1 to [`MAX_TOOL_CALLS_CEILING`]. pub fn parse_max_tool_calls(raw: &str) -> Option { raw.trim() .parse::() @@ -117,73 +86,46 @@ pub fn parse_max_tool_calls(raw: &str) -> Option { .filter(|&n| is_valid_tool_call_cap(n)) } -/// The range every tool-call cap must fall in, from any source: 1 (zero would refuse every -/// call) to [`MAX_TOOL_CALLS_CEILING`]. Shared by the parser and -/// [`LlmConfig::with_max_tool_calls`], so the two can never accept different values. +/// 1 (zero would refuse every call) to [`MAX_TOOL_CALLS_CEILING`]. fn is_valid_tool_call_cap(n: usize) -> bool { (1..=MAX_TOOL_CALLS_CEILING).contains(&n) } -/// Where the bearer token in force came from. A **fact about the key, never the key** — -/// the half of a cloud configuration that may safely be shown, logged and sent to a -/// webview, which is why it replaced a bare "is one set?" boolean (GH #176). -/// -/// The order below is the resolution order, and the one interesting rule is at the top of -/// it: [`Environment`](Self::Environment) **wins**. An adapter that remembers a key for the -/// user still yields to `B2_LLM_API_KEY`, so a shell that exports one gets the key it -/// named. [`LlmConfig::with_api_key`] enforces that in one place. +/// Where the bearer token in force came from: a fact about the key, safe to show and log +/// (GH #176). [`Environment`](Self::Environment) outranks a remembered key +/// ([`LlmConfig::with_api_key`]). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ApiKeySource { - /// No key at all — the **Local** configuration, and the default. + /// No key: the Local configuration. #[default] None, - /// [`ENV_API_KEY`], and therefore the key in force whatever else is - /// configured. The CLI's only source; the desktop's override. + /// [`ENV_API_KEY`]: the CLI's only source, the desktop's override. Environment, - /// Remembered by the adapter in the platform's own secret store — the - /// desktop's macOS Keychain (GH #176). Survives quit, encrypted at rest. + /// Remembered in the desktop's macOS Keychain (GH #176). Stored, - /// Held in memory for this run only. What the desktop degrades to when the - /// Keychain is unavailable or refuses: chat still works with the key the - /// user typed, it just won't be there next launch. + /// Held for this run only, when the Keychain refuses. Session, } -/// Which model to chat with, and where it lives. **Adapter-level state, never vault or -/// index state**: nothing here is recorded in the vault, so a config change costs no -/// reindex. The spec's two named configurations are just two values of this type — Local -/// (a localhost endpoint, no key) and Cloud models (a provider endpoint + `api_key`, -/// explicit opt-in only). +/// Which model to chat with, and where. Adapter state, never vault or index state. Local +/// (localhost, no key) and Cloud (endpoint + `api_key`, opt-in) are two values of it. #[derive(Clone, PartialEq, Eq)] pub struct LlmConfig { /// The OpenAI-compatible base URL, e.g. `http://localhost:11434/v1`. - /// A trailing slash is tolerated (see [`LlmConfig::endpoint`]). pub base_url: String, /// The model id as the server names it, e.g. `llama3.2`. pub model: String, - /// Bearer token for a cloud endpoint; `None` for a local runtime, which - /// wants no auth (and where sending one would be noise). + /// Bearer token for a cloud endpoint; `None` for a local runtime. pub api_key: Option, - /// Where [`api_key`](Self::api_key) came from, and `None` when there is no - /// key. Kept beside the secret rather than derived from it because only the - /// *resolver* knows: an adapter that remembers keys has to be told which of - /// its own sources answered, and the environment's answer outranks it. + /// Where [`api_key`](Self::api_key) came from; only the resolver knows. pub api_key_source: ApiKeySource, - /// The most tool calls one model reply may make, which is also the highest call - /// `index` it may name. A reply past it fails with [`LlmError::TooManyToolCalls`] - /// rather than being trimmed. [`DEFAULT_MAX_TOOL_CALLS`] unless - /// [`ENV_MAX_TOOL_CALLS`] says otherwise; it bounds memory a server can make B2 - /// allocate, so it is a ceiling to raise for an unusual model, not a tuning knob. + /// The most tool calls (and highest call `index`) one reply may make; past it, + /// [`LlmError::TooManyToolCalls`]. A memory bound, not a tuning knob. pub max_tool_calls: usize, } -/// Hand-written so the key **cannot** be logged. `Debug` is what an adapter -/// reaches for when something is wrong — a `tracing` field, a panic message, an -/// error report — and a derived one would put a live bearer token in whichever -/// of those the user then pastes into an issue. Only its presence is ever -/// printed, which is the part that helps diagnose "the cloud endpoint rejects -/// me". The repo's logging policy in one impl (root `CLAUDE.md`). +/// Hand-written so the key can never be logged; only its presence is printed. impl std::fmt::Debug for LlmConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LlmConfig") @@ -196,8 +138,6 @@ impl std::fmt::Debug for LlmConfig { None => "None", }, ) - // Safe, and the useful half: "a key is set, from the environment" - // is what turns a rejected cloud call into a diagnosis. .field("api_key_source", &self.api_key_source) .field("max_tool_calls", &self.max_tool_calls) .finish() @@ -205,8 +145,7 @@ impl std::fmt::Debug for LlmConfig { } impl Default for LlmConfig { - /// The **Local** configuration: Ollama's compat endpoint, the default - /// model, no key. Nothing leaves the machine under it (M5). + /// The Local configuration; nothing leaves the machine (M5). fn default() -> Self { Self { base_url: DEFAULT_BASE_URL.to_string(), @@ -220,19 +159,13 @@ impl Default for LlmConfig { impl LlmConfig { /// The defaults overlaid with [`ENV_URL`] / [`ENV_MODEL`] / [`ENV_API_KEY`] / - /// [`ENV_MAX_TOOL_CALLS`]. - /// Environment resolution lives **here**, in one place, so both adapters - /// (the CLI's flags, the desktop's settings) layer their own overrides over - /// the same base rather than each spelling the variable names themselves. - /// Blank values are ignored — an exported-but-empty var means "unset" to a - /// shell user, and honoring it literally would produce an unusable URL. + /// [`ENV_MAX_TOOL_CALLS`], the one base both adapters layer over. Blank reads as unset. pub fn from_env() -> Self { Self::resolve(|key| std::env::var(key).ok()) } - /// [`from_env`](Self::from_env) over any variable source — the process environment - /// in production, a closure in tests, so resolution is testable without mutating a - /// process-global that parallel tests share. + /// [`from_env`](Self::from_env) over any variable source, so tests needn't mutate the + /// process env. fn resolve(var: impl Fn(&str) -> Option) -> Self { let read = |key: &str| { var(key) @@ -241,9 +174,7 @@ impl LlmConfig { }; let base = Self::default(); let api_key = read(ENV_API_KEY); - // A cap that doesn't parse, or parses to zero (which would refuse every tool - // call), is a typo rather than a wish: keep the default and say so, since a - // silently ignored setting is one nobody can debug. + // An invalid cap is a typo: keep the default, and say so. let max_tool_calls = match read(ENV_MAX_TOOL_CALLS) { None => base.max_tool_calls, Some(raw) => parse_max_tool_calls(&raw).unwrap_or_else(|| { @@ -268,10 +199,8 @@ impl LlmConfig { } } - /// Lay an adapter's **explicit** choices over this config — the - /// `B2_VAULT_PATH` convention (a flag beats the environment beats the - /// default). `None` keeps what's already there, so a caller passes its - /// parsed flags straight through. + /// Lay an adapter's explicit choices over this config (a flag beats the environment). + /// `None` keeps what's there. #[must_use] pub fn with_overrides(mut self, base_url: Option<&str>, model: Option<&str>) -> Self { if let Some(url) = base_url { @@ -283,11 +212,9 @@ impl LlmConfig { self } - /// Lay an adapter's own tool-call cap over this config — the - /// [`with_overrides`](Self::with_overrides) rule: an explicit choice beats the - /// environment, `None` keeps what's there. A value outside what - /// [`parse_max_tool_calls`] accepts is ignored rather than installed, so a - /// hand-edited settings file cannot lift the ceiling the parser enforces. + /// Lay an adapter's tool-call cap over this config, as + /// [`with_overrides`](Self::with_overrides) does. An invalid value is ignored, so a + /// hand-edited settings file can't lift the ceiling. #[must_use] pub fn with_max_tool_calls(mut self, max_tool_calls: Option) -> Self { if let Some(n) = max_tool_calls.filter(|&n| is_valid_tool_call_cap(n)) { @@ -296,19 +223,9 @@ impl LlmConfig { self } - /// Lay an adapter's own bearer token over this config — - /// [`with_overrides`](Self::with_overrides)'s sibling, and deliberately **not** the - /// same rule. - /// - /// For the URL and the model, an adapter's explicit choice beats the environment. A key - /// inverts that: `B2_LLM_API_KEY` wins, and a key an adapter merely *remembered* yields - /// to it (GH #176). A shell that exports a key is making a per-run statement — the only - /// way to point one launch at a different provider — and a user who would rather B2 - /// kept no secret at all needs a way to say so that a stored key cannot outrank. - /// - /// `source` is where the caller's key came from, recorded so the configuration can - /// *say* which one answered. `None` keeps whatever the environment supplied, so "I - /// didn't type a key" never *clears* `B2_LLM_API_KEY`. + /// Lay an adapter's bearer token over this config. Unlike + /// [`with_overrides`](Self::with_overrides), the environment wins: `B2_LLM_API_KEY` is a + /// per-run statement a stored key must not outrank (GH #176). `None` never clears a key. #[must_use] pub fn with_api_key(mut self, api_key: Option<&str>, source: ApiKeySource) -> Self { if self.api_key_source == ApiKeySource::Environment { @@ -321,58 +238,37 @@ impl LlmConfig { self } - /// Absolute URL for one API path (`"/chat/completions"`), tolerating a - /// trailing slash on the configured base so `…/v1` and `…/v1/` behave the - /// same — a difference nobody should have to debug from a 404. + /// Absolute URL for one API path, tolerating a trailing slash on the base. pub fn endpoint(&self, path: &str) -> String { format!("{}{}", self.base_url.trim_end_matches('/'), path) } } -/// What can go wrong talking to a model server. Structured, and **the adapters match on -/// it** — which is why it isn't a string: the "can't reach the model server at …" message -/// is [`LlmError::Unreachable`] rendered, not a substring test on someone's `io::Error`. -/// -/// Note the seam boundary: these are the failures an adapter can *see*, because it -/// constructs the provider and probes it itself. Failures *inside* a `complete` call cross -/// `b2-core` as [`b2_core::Error::Llm`] — a message, by the seam's design — and surface as -/// the generic "the model call failed", with this type's `Display` as the `B2_DEBUG` detail. +/// What can go wrong talking to a model server; the adapters match on it. Failures inside +/// a `complete` call cross `b2-core` as a [`b2_core::Error::Llm`] message instead. #[derive(Debug, thiserror::Error)] pub enum LlmError { - /// Nothing is listening: connection refused, DNS failure, TLS failure, a - /// timeout. The E4 case — "is Ollama running?" — and the reason - /// [`OpenAiCompatProvider::probe`] exists. + /// Nothing is listening (refused, DNS, TLS, timeout): E4's "is Ollama running?". #[error("can't reach the model server at {endpoint}: {detail}")] Unreachable { endpoint: String, detail: String }, - /// The server answered, and said no: an HTTP error status. `message` is the - /// provider's own explanation when it sent one (OpenAI-shaped errors carry - /// `error.message`), else the raw body, truncated. + /// An HTTP error status mid-answer, with the server's own message when it sent one. #[error("the model server rejected the request (HTTP {status}): {message}")] Http { status: u16, message: String }, - /// A **probe** was refused: something is listening at `endpoint`, but it did not serve - /// the OpenAI-compatible `/models` route. Its own variant rather than [`LlmError::Http`] - /// because the *same status means different things in the two places it can arrive*: a - /// 404 here is a base URL that isn't a chat API (the `…/v1X` typo), while a 404 - /// mid-answer is the server's own "model not found". An adapter that had to guess would - /// give the wrong fix half the time, so the distinction is carried in the type — and - /// turned into a sentence once, by [`crate::refusal_message`]. + /// A probe of `/models` was refused. Not [`LlmError::Http`]: a 404 here is a wrong base + /// URL, while mid-answer it is "model not found" ([`crate::refusal_message`]). #[error("the model server at {endpoint} refused a probe (HTTP {status}): {message}")] Refused { endpoint: String, status: u16, - /// The server's own explanation, when it sent one. It rides in `Display` because - /// that *is* the `B2_DEBUG` line — `user_message` prints `err.to_string()` and - /// nothing else, so a field left out of the format is one no adapter can show. The - /// user-facing sentence omits it: a 404 body says nothing a human can act on. + /// The server's own explanation. In `Display` because that is the `B2_DEBUG` line; + /// the user-facing sentence omits it. message: String, }, - /// The server is up but doesn't serve the configured model — the most - /// common local-setup mistake (an un-pulled Ollama model), caught at probe - /// time rather than as a 404 mid-answer. `available` is what it *does* - /// serve, so an adapter can show the list. + /// The server doesn't serve the configured model (usually an un-pulled Ollama model). + /// `available` is what it does serve. #[error("model '{model}' is not available at {endpoint}")] ModelMissing { model: String, @@ -380,24 +276,19 @@ pub enum LlmError { available: Vec, }, - /// The stream itself was malformed — a `data:` frame that isn't JSON, or an - /// I/O failure mid-read. Distinct from a *truncated* stream, which is not an - /// error: that ends the completion honestly as cancelled (the partial text - /// is real, and the seam has a marker for exactly this). + /// A malformed stream (a non-JSON frame, an I/O failure). A truncated stream is not + /// an error: it ends as cancelled. #[error("malformed model stream: {0}")] Stream(String), - /// One reply asked for more tool calls than [`LlmConfig::max_tool_calls`] allows — - /// or named a call `index` past it, which costs the same memory. Refused rather than - /// trimmed: a reply this far outside the protocol is a broken or hostile server, and - /// running the first N of its calls would pass that off as a normal turn. + /// One reply exceeded [`LlmConfig::max_tool_calls`]. Refused, not trimmed: running the + /// first N calls would pass a broken server off as a normal turn. #[error( "the model asked for more than {limit} tool calls in one reply; raise {ENV_MAX_TOOL_CALLS} if that is expected" )] TooManyToolCalls { limit: usize }, - /// The model server reported an error **inside** the stream, after the - /// headers said 200 — the wire quirk a hand-rolled client has to own. + /// The server reported an error inside a 200 stream. #[error("the model server failed mid-answer: {0}")] Provider(String), } @@ -423,8 +314,6 @@ mod tests { 256, "surrounding whitespace is a shell's, not a value" ); - // Zero would refuse every tool call, and the rest aren't numbers: a typo keeps - // the default rather than configuring an unusable client. for bad in ["0", "-1", "lots", "6.4", ""] { assert_eq!(with(Some(bad)), DEFAULT_MAX_TOOL_CALLS, "{bad:?}"); } @@ -438,8 +327,6 @@ mod tests { parse_max_tool_calls(&MAX_TOOL_CALLS_CEILING.to_string()), Some(MAX_TOOL_CALLS_CEILING) ); - // The cap exists to bound memory, so it has a ceiling of its own: a setting - // that can be typed up to a billion is not a bound. assert_eq!( parse_max_tool_calls(&(MAX_TOOL_CALLS_CEILING + 1).to_string()), None @@ -453,13 +340,11 @@ mod tests { fn an_adapters_cap_beats_the_environments_and_none_keeps_it() { let env = LlmConfig::resolve(|key| (key == ENV_MAX_TOOL_CALLS).then(|| "8".to_string())); assert_eq!(env.max_tool_calls, 8); - // The `with_overrides` convention: an explicit adapter choice beats the env. assert_eq!( env.clone().with_max_tool_calls(Some(128)).max_tool_calls, 128 ); assert_eq!(env.clone().with_max_tool_calls(None).max_tool_calls, 8); - // A value no parser would have accepted is not installed by the back door. assert_eq!(env.clone().with_max_tool_calls(Some(0)).max_tool_calls, 8); assert_eq!( env.with_max_tool_calls(Some(MAX_TOOL_CALLS_CEILING + 1)) @@ -482,10 +367,7 @@ mod tests { !rendered.contains("sk-live-do-not-log-me"), "a bearer token must never reach a log: {rendered}" ); - // Presence still shows: "is a key configured at all" is the question a - // rejected cloud call actually needs answered — and now *which* key, - // which is the follow-up question when the answer is "yes, and it's - // being rejected". + // Presence and source still show. assert!(rendered.contains("redacted"), "{rendered}"); assert!(rendered.contains("api.example.com"), "{rendered}"); assert!(rendered.contains("Stored"), "{rendered}"); @@ -495,9 +377,7 @@ mod tests { ); } - /// The one inversion of the layering rule (GH #176): an adapter's *remembered* - /// key yields to `B2_LLM_API_KEY`, so a shell that exports one gets the key it - /// named — the escape hatch a stored secret must never be able to shadow. + /// GH #176. #[test] fn the_environments_key_outranks_a_remembered_one() { let from_env = LlmConfig { @@ -510,8 +390,6 @@ mod tests { assert_eq!(resolved.api_key_source, ApiKeySource::Environment); } - /// With no key in the environment, a remembered one is simply the key — and - /// it says where it came from, which is what the Settings copy reads. #[test] fn a_remembered_key_is_used_and_named_when_the_environment_is_silent() { let keyless = LlmConfig::default(); @@ -523,14 +401,13 @@ mod tests { assert_eq!(stored.api_key.as_deref(), Some("sk-from-the-keychain")); assert_eq!(stored.api_key_source, ApiKeySource::Stored); - // The Keychain refused, so the same key is in force for this run only — - // chat works, and the configuration is honest about how long it lasts. + // The Keychain refused: in force for this run only. let session = keyless .clone() .with_api_key(Some("sk-typed-just-now"), ApiKeySource::Session); assert_eq!(session.api_key_source, ApiKeySource::Session); - // And `None` is "I didn't type one", which must never *clear* a key. + // `None` never clears a key. assert_eq!( stored.with_api_key(None, ApiKeySource::None).api_key_source, ApiKeySource::Stored diff --git a/crates/b2-llm/src/provider.rs b/crates/b2-llm/src/provider.rs index 318ec98..ee140c3 100644 --- a/crates/b2-llm/src/provider.rs +++ b/crates/b2-llm/src/provider.rs @@ -1,9 +1,8 @@ //! The provider itself: [`OpenAiCompatProvider`], one `POST {base}/chat/completions` with //! `stream: true` per call, its SSE response read by [`crate::sse`]. //! -//! Everything here is blocking. That is the design, not a limitation: the seam hands us a -//! `FnMut` callback and asks us to stop when it says `Break`, which over a blocking reader -//! is `return` — no runtime, no channel, no cancellation token (ADR-0011). +//! Everything here is blocking: stopping on the callback's `Break` is a `return`, with no +//! runtime or cancellation token (ADR-0011). use crate::sse; use crate::{LlmConfig, LlmError}; @@ -13,52 +12,36 @@ use std::io::{BufReader, Read}; use std::ops::ControlFlow; use std::time::Duration; -/// How long to wait for the TCP/TLS handshake. Generous for a cloud endpoint, -/// still fast enough that a dead localhost port fails immediately (it refuses -/// rather than hangs). +/// How long to wait for the TCP/TLS handshake (a dead localhost port refuses at once). const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// How long a single socket read may block. This is a *gap* budget, not a -/// generation budget: tokens keep arriving, so what it actually bounds is a -/// server that accepted the request and then went silent. Wide enough that a -/// large model's first token on a cold CPU is never mistaken for a hang. +/// How long a single socket read may block: a gap budget, not a generation budget. Wide +/// enough for a large model's first token on a cold CPU. const READ_TIMEOUT: Duration = Duration::from_secs(120); -/// Overall deadline for the [`OpenAiCompatProvider::probe`] round-trip. Short by -/// design: the probe exists to answer "is anything there?" *before* the human -/// waits on an answer, so it must never itself become the wait. +/// Overall deadline for [`OpenAiCompatProvider::probe`]; the probe must never become the +/// wait it exists to prevent. const PROBE_TIMEOUT: Duration = Duration::from_secs(10); -/// How many times a chat request may be *sent* before giving up — one retry, and -/// only while no response exists (see the loop in -/// [`OpenAiCompatProvider::stream`]). +/// How many times a chat request may be sent: one retry, only while no response exists. const SEND_ATTEMPTS: usize = 2; -/// How much of an error response body to keep when the server didn't send a -/// structured message — enough to be diagnostic, bounded so a stray HTML error -/// page can't become the log. +/// How much of an unstructured error body to keep. const MAX_ERROR_BODY: usize = 500; -/// The largest streamed answer this crate will read. Far past any real -/// completion (a 16 MiB answer is not an answer), and small enough that a server -/// sending bytes without end cannot exhaust memory — nothing else bounds it. +/// The largest streamed answer this crate will read, so an endless server can't exhaust +/// memory. Nothing else bounds it. const MAX_STREAM_BYTES: u64 = 16 * 1024 * 1024; -/// B2's real chat provider: any OpenAI-compatible endpoint, streamed. -/// -/// Construct it, optionally [`probe`](Self::probe) it (the adapters do, so an -/// unreachable server is an error *before* the human types a question), then -/// hand it to `Vault::ask` as a `&dyn LlmProvider`. +/// B2's real chat provider: any OpenAI-compatible endpoint, streamed. The adapters +/// [`probe`](Self::probe) it before handing it to `Vault::ask`. pub struct OpenAiCompatProvider { config: LlmConfig, agent: ureq::Agent, } impl OpenAiCompatProvider { - /// Wire a provider to `config`. Cheap and infallible — it opens no - /// connection, so a bad URL or a stopped daemon surfaces at - /// [`probe`](Self::probe) or at the first call, never from a constructor - /// nobody expects to fail. + /// Wire a provider to `config`. Opens no connection, so it can't fail. pub fn new(config: LlmConfig) -> Self { let agent = ureq::AgentBuilder::new() .timeout_connect(CONNECT_TIMEOUT) @@ -67,24 +50,13 @@ impl OpenAiCompatProvider { Self { config, agent } } - /// Fail fast, before a human waits: `GET {base}/models` — the `b2 init` posture applied - /// to the second seam (ADR-0020's "never a surprise mid-command"). It answers two - /// questions and deliberately no others: + /// Fail fast before a human waits (ADR-0020): `GET {base}/models`. /// - /// 1. **Is this a chat endpoint?** A *transport* failure is unreachable; an **HTTP - /// refusal is a refusal**, reported with its status. It used to be tolerated — any - /// answer, 404 included, read as "a server is there, it just doesn't implement - /// `/models`" — and that is the one reading this probe may not take, because it - /// cannot be told apart from the far commoner mistake it silently blessed: a wrong - /// path (`…:11434/v1X` came back as *Connected* and failed at the first question). - /// The tolerance survives where the evidence supports it — a **2xx** whose body isn't - /// a model list — and [`crate::refusal_message`] is where a refusal becomes advice. - /// 2. **Does it serve the configured model?** Checked only when the response *parsed* - /// as a non-empty model list, and leniently: the check may only ever catch a real - /// mistake, never invent one, because a false refusal would block a working setup. - /// - /// The cost is one round trip per process, paid to turn the most common setup mistakes - /// into sentences instead of a failed answer. + /// 1. Is this a chat endpoint? A transport failure is unreachable; any HTTP refusal is + /// a refusal, since a 404 can't be told apart from a wrong path. A 2xx whose body + /// isn't a model list is tolerated. + /// 2. Does it serve the configured model? Checked only against a parsed, non-empty + /// list, and leniently: a false refusal would block a working setup. pub fn probe(&self) -> Result<(), LlmError> { let url = self.config.endpoint("/models"); let response = match self @@ -93,8 +65,7 @@ impl OpenAiCompatProvider { .call() { Ok(r) => r, - // Something answered, and said no. Which *no* it was is the caller's to - // interpret (`setup::probe_setup`): a 401 is a key, a 404 is a path. + // The caller interprets the status (`setup::probe_setup`). Err(ureq::Error::Status(status, response)) => { tracing::debug!(target: "b2::llm", status, url, "the model list was refused"); return Err(LlmError::Refused { @@ -105,17 +76,13 @@ impl OpenAiCompatProvider { } Err(ureq::Error::Transport(t)) => return Err(self.unreachable(&t)), }; - // `into_string` over `ureq`'s optional `json` feature: this crate already - // owns serde_json, and the feature would pull a second copy of the same - // decision into the dependency list. let listed: Vec = match response .into_string() .map_err(|e| e.to_string()) .and_then(|b| serde_json::from_str::(&b).map_err(|e| e.to_string())) { Ok(list) => list.data.into_iter().map(|m| m.id).collect(), - // A 200 whose body isn't an OpenAI model list: still reachable, and - // there is nothing to check the model against. + // Reachable, but nothing to check the model against. Err(e) => { tracing::debug!(target: "b2::llm", error = %e, "unparseable model list"); return Ok(()); @@ -131,9 +98,8 @@ impl OpenAiCompatProvider { Ok(()) } - /// One streamed chat completion, from request build to the last token. - /// Separate from the trait method so the whole wire path keeps the typed - /// [`LlmError`]; the trait boundary is where it collapses to a message. + /// One streamed chat completion, keeping the typed [`LlmError`] until the trait + /// boundary. fn stream( &self, req: &ChatRequest, @@ -150,13 +116,9 @@ impl OpenAiCompatProvider { turns = req.turns.len(), "streaming a chat completion" ); - // One retry, in exactly one window. Connections are pooled — the probe's, and the - // previous turn's in a `chat` session — and a server that closed an idle one fails - // the *send*, not the answer. `ureq` won't retry that itself (idempotent methods - // only, and a POST isn't one), but a request that never reached a server generated - // nothing, so re-sending it is invisible. Which failures qualify is - // [`worth_resending`]'s judgement, and the window closes the moment a response - // exists: an error *inside* the stream is never retried. + // One retry: a pooled connection the server closed fails the send, and `ureq` + // won't retry a POST. Only before a response exists ([`worth_resending`]); an + // error inside the stream is never retried. let mut attempt = 0; let response = loop { attempt += 1; @@ -164,10 +126,7 @@ impl OpenAiCompatProvider { .request(self.agent.post(&url)) .set("content-type", "application/json") .set("accept", "text/event-stream") - // Ask for no compression: `ureq` would otherwise advertise gzip, - // and a server that honors it can hold tokens inside a - // compression window — which turns streaming, the contract, back - // into waiting. + // No gzip: a compression window can hold tokens back. .set("accept-encoding", "identity") .send_string(&body); match result { @@ -185,12 +144,8 @@ impl OpenAiCompatProvider { Err(ureq::Error::Transport(t)) => return Err(self.unreachable(&t)), } }; - // `take` is the only bound on the stream: `into_reader` applies none (unlike - // `into_string`), and the SSE reader grows a line until it meets a newline — - // so a broken or hostile endpoint sending an endless run of bytes would - // otherwise be free to exhaust memory. Hitting the cap reads as EOF, which - // this crate already has an honest ending for: the partial text, marked - // cancelled. + // `take` is the only bound on the stream (`into_reader` applies none). Hitting it + // reads as EOF: the partial text, marked cancelled. let completion = sse::stream_completion( BufReader::new(response.into_reader()).take(MAX_STREAM_BYTES), self.config.max_tool_calls, @@ -205,8 +160,7 @@ impl OpenAiCompatProvider { Ok(completion) } - /// Apply the per-call headers every request shares — today just the bearer - /// token, and only when one is configured (a local runtime wants no auth). + /// Apply the bearer token, when one is configured. fn request(&self, request: ureq::Request) -> ureq::Request { match &self.config.api_key { Some(key) => request.set("authorization", &format!("Bearer {key}")), @@ -214,8 +168,7 @@ impl OpenAiCompatProvider { } } - /// The transport failure, named by the endpoint the human configured — the - /// input to E4's "is Ollama running?" message. + /// The transport failure, named by the configured endpoint (E4). fn unreachable(&self, detail: &ureq::Transport) -> LlmError { LlmError::Unreachable { endpoint: self.config.base_url.clone(), @@ -225,43 +178,31 @@ impl OpenAiCompatProvider { } impl LlmProvider for OpenAiCompatProvider { - /// The configured model id — display and logging only, like every - /// provider's (chat carries no index identity; contrast M2). + /// The configured model id, for display and logging only (contrast M2). fn model_id(&self) -> &str { &self.config.model } - /// Stream a completion, collapsing the typed [`LlmError`] into the seam's - /// [`b2_core::Error::Llm`] message. The type isn't lost so much as *scoped*: - /// `b2-core` stays free of this crate's types (the `Error::Embed` - /// precedent), and the failures an adapter needs to act on — unreachable, - /// missing model — are the ones it can already see through - /// [`probe`](Self::probe), which it calls itself. + /// Stream a completion, collapsing [`LlmError`] into [`b2_core::Error::Llm`] so + /// `b2-core` stays free of this crate's types. Adapters see the typed failures through + /// [`probe`](Self::probe). fn complete( &self, req: &ChatRequest, on_token: &mut dyn FnMut(&str) -> ControlFlow<()>, ) -> b2_core::Result { self.stream(req, on_token).map_err(|e| match e { - // The one failure that keeps its type across the seam: a caller that - // degrades on a failed tool round must be able to refuse to hide this one. + // Keeps its type: a caller that degrades on a failed tool round must not hide it. LlmError::TooManyToolCalls { limit } => b2_core::Error::ToolCallLimit { limit }, other => b2_core::Error::Llm(other.to_string()), }) } } -/// Is this transport failure one where re-sending is *invisible* — that is, one where the -/// server cannot already be generating an answer? Two families qualify: -/// -/// - **Never connected** (`Dns`, `ConnectionFailed`): no server saw anything. -/// - **The connection was already dead** (`Io` over a closed socket): the pooled-connection -/// case this retry exists for — measured, it arrives as `Io` + `ConnectionAborted`. -/// -/// A **timeout** must *not* retry, and is the reason this isn't just "any transport error": -/// a server that took the request and went quiet may be loading a model or already -/// generating. `ureq` reports it in the same `Io` kind as a dead socket, so the underlying -/// `io::ErrorKind` is what separates them. +/// Is re-sending invisible, because the server can't be generating yet? Yes when it never +/// connected, or the pooled connection was already dead (`Io` + `ConnectionAborted`). A +/// timeout must not retry (the server may be generating); it shares the `Io` kind, so the +/// `io::ErrorKind` separates them. fn worth_resending(t: &ureq::Transport) -> bool { match t.kind() { ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed => true, @@ -281,17 +222,11 @@ fn worth_resending(t: &ureq::Transport) -> bool { } } -/// Does the server's model list name this model? Lenient on purpose, in the one -/// direction that matters: Ollama accepts `llama3.2` and *lists* -/// `llama3.2:latest`, so an exact comparison would refuse the single most common -/// working configuration. Comparison is case-insensitive with an implicit -/// `:latest` tag on either side; everything else must match, so a genuinely -/// un-pulled model is still caught. +/// Does the server's model list name this model? Case-insensitive with an implicit +/// `:latest` on either side, since Ollama accepts `llama3.2` but lists `llama3.2:latest`. fn model_listed(model: &str, available: &[String]) -> bool { fn normalize(s: &str) -> String { - // Lowercase *before* stripping: a server that lists `Llama3.2:LATEST` - // must normalize to the same thing `llama3.2` does, or the check would - // invent the mistake it exists to catch. + // Lowercase before stripping, so `:LATEST` strips too. let s = s.trim().to_lowercase(); s.strip_suffix(":latest").unwrap_or(&s).to_string() } @@ -299,10 +234,7 @@ fn model_listed(model: &str, available: &[String]) -> bool { available.iter().any(|a| normalize(a) == want) } -/// An HTTP error response turned into a diagnosis: the provider's own -/// `error.message` when it sent one (every OpenAI-compatible server does for -/// the errors that matter — "model not found, try pulling it first"), else a -/// bounded slice of whatever it did send. +/// An HTTP error response as a diagnosis. fn http_error(status: u16, response: ureq::Response) -> LlmError { LlmError::Http { status, @@ -310,9 +242,7 @@ fn http_error(status: u16, response: ureq::Response) -> LlmError { } } -/// The explanation half of a refusal, shared by the two errors that carry one -/// ([`LlmError::Http`] mid-answer, [`LlmError::Refused`] at probe time) so a -/// server's own words reach a human the same way from both. +/// The server's own `error.message`, else a bounded slice of the body. fn error_detail(response: ureq::Response) -> String { let body = response.into_string().unwrap_or_default(); serde_json::from_str::(&body) @@ -334,20 +264,16 @@ fn truncate(s: &str, max: usize) -> String { struct WireRequest<'a> { model: &'a str, messages: Vec>, - /// Always `true` — streaming is the contract, not an option (GH #151). + /// Always `true` (GH #151). stream: bool, - /// The tools offered this round. Omitted entirely when there are none, so a plain - /// ask is byte-for-byte the request it always was — and a server with no tool - /// support never sees the key. + /// Omitted when empty, so a server with no tool support never sees the key. #[serde(skip_serializing_if = "Vec::is_empty")] tools: Vec>, } impl<'a> WireRequest<'a> { - /// Render a [`ChatRequest`] onto the wire: the assembled system message - /// first (prompt + numbered passages — [`ChatRequest::system_message`] owns - /// that rendering, so every provider numbers passages exactly as citation - /// resolution counts them), then the turns, oldest first. + /// Render a [`ChatRequest`]: the system message ([`ChatRequest::system_message`] + /// numbers the passages), then the turns, oldest first. fn from_chat(model: &'a str, req: &'a ChatRequest) -> Self { let mut messages = Vec::with_capacity(req.turns.len() + 1); messages.push(WireMessage { @@ -365,10 +291,8 @@ impl<'a> WireRequest<'a> { tool_calls: Vec::new(), tool_call_id: None, })); - // A tool-using turn's history: each call as the assistant message that made it, - // then its result as a `tool` message answering that id. One call per assistant - // message — valid on every server, and it needs no record of which calls shared - // a round. + // Each call as its own assistant message, then its `tool` result: valid on every + // server. for exchange in &req.exchanges { messages.push(WireMessage { role: "assistant", @@ -432,7 +356,7 @@ struct WireToolCall<'a> { #[derive(Debug, Serialize)] struct WireFunctionCall<'a> { name: &'a str, - /// JSON **text**, as the wire shape has it and as the seam carries it. + /// JSON text. arguments: &'a str, } @@ -470,9 +394,7 @@ pub(crate) struct WireError { pub(crate) error: ErrorDetail, } -/// The `error` field two ways, because servers disagree: OpenAI (and Ollama's -/// compat surface) send an object with a `message`; some send a bare string. -/// Both are the same fact, so both parse. +/// The `error` field: an object with a `message` (OpenAI, Ollama) or a bare string. #[derive(Debug, Deserialize)] #[serde(untagged)] pub(crate) enum ErrorDetail { @@ -495,14 +417,11 @@ mod tests { #[test] fn model_list_match_is_lenient_about_the_latest_tag() { - // The configuration everyone actually types, against what Ollama lists. let available = vec!["llama3.2:latest".to_string(), "qwen3:8b".to_string()]; assert!(model_listed("llama3.2", &available)); assert!(model_listed("llama3.2:latest", &available)); assert!(model_listed("Llama3.2", &available)); assert!(model_listed("qwen3:8b", &available)); - // The tag is case-insensitive too: normalizing has to lowercase before it - // strips, or a server that shouts its tag looks like a missing model. assert!(model_listed( "llama3.2", &["Llama3.2:LATEST".to_string(), "QWEN3:8B".to_string()] @@ -513,8 +432,7 @@ mod tests { #[test] fn model_list_match_still_catches_an_absent_model() { let available = vec!["llama3.2:latest".to_string()]; - // A different tag of the same model is a different model — it has to be - // pulled, so the check must not wave it through. + // A different tag is a different model. assert!(!model_listed("qwen3", &available)); assert!(!model_listed("llama3.2:70b", &available)); assert!(!model_listed("llama3", &available)); @@ -598,8 +516,7 @@ mod tests { .map(|m| m["role"].as_str().unwrap()) .collect(); assert_eq!(roles, ["system", "user", "assistant", "tool"]); - // The ledger's passages reached the model in the tool result, not the system - // message — once, not twice. + // Passages arrive in the tool result, not also in the system message. assert!(!messages[0]["content"] .as_str() .unwrap() diff --git a/crates/b2-llm/src/setup.rs b/crates/b2-llm/src/setup.rs index 3553c5c..56aca02 100644 --- a/crates/b2-llm/src/setup.rs +++ b/crates/b2-llm/src/setup.rs @@ -1,56 +1,36 @@ -//! The **onboarding corner** — deliberately Ollama-native, even though chat is generic -//! (GH #151, cut as GH #155). +//! Chat onboarding (GH #151, GH #155). Chat itself is generic `/v1`, but the setup card +//! speaks Ollama's native API (detect the daemon, list models, suggest a pull): guided +//! setup is a per-runtime feature, so don't generalize it. //! -//! The chat path speaks the generic OpenAI-compatible `/v1` surface and will talk to LM -//! Studio, llama.cpp, vLLM or a cloud provider with a URL change. The *setup card* speaks -//! Ollama's **native** API: detect the daemon, list what is installed, suggest a pull sized -//! to the machine. That asymmetry is by design, so nobody later "generalizes" the card -//! against an abstraction that cannot serve it — **guided setup is a per-runtime feature.** -//! -//! Everything here is a *status*, never an error: [`probe_setup`] cannot fail, because "the -//! daemon isn't running" is the answer the card is asking for. Adapter-level throughout, so -//! nothing here is recorded in the vault or the index. +//! Everything here is a status, never an error: "the daemon isn't running" is the answer +//! [`probe_setup`] is asking for. use crate::{ApiKeySource, LlmConfig, LlmError, OpenAiCompatProvider, FAKE_NOTICE}; use serde::{Deserialize, Serialize}; use std::time::Duration; -/// Ollama's native model inventory — the one non-`/v1` endpoint B2 speaks, and -/// only for onboarding. +/// Ollama's native model inventory, the one non-`/v1` endpoint B2 speaks. const TAGS_PATH: &str = "/api/tags"; -/// Where a human who has *no* Ollama is sent — the quickstart rather than the product -/// page: someone reading this has already found out nothing is listening, so they need the -/// page with the install command on it. One constant, printed by [`unreachable_message`] -/// for both adapters, so the link cannot drift between them. +/// Where someone with no Ollama is sent: the page with the install command on it. pub const OLLAMA_INSTALL_URL: &str = "https://docs.ollama.com/quickstart"; -/// The port Ollama serves on. Recognizing it is what decides whether Ollama's own -/// commands (`ollama serve`, `ollama pull …`) belong in a message — advice about -/// the wrong program is worse than no advice, and `B2_LLM_URL` also points at LM -/// Studio, llama.cpp, vLLM and cloud providers. +/// The port Ollama serves on, which decides whether Ollama's commands belong in a message. const OLLAMA_PORT: &str = ":11434"; -/// How long the onboarding round trip may take. Short for [`PROBE_TIMEOUT`]'s -/// reason: a setup card that hangs is worse than one that says "not running". +/// How long the onboarding round trip may take: a hanging card is worse than "not running". const TAGS_TIMEOUT: Duration = Duration::from_secs(5); -/// Does this endpoint look like the Ollama daemon — its port, or a host that names it? It -/// decides two things: whether Ollama's own commands belong in an error message, and -/// whether [`probe_setup`] asks `/api/tags` at all. Deliberately a *guess about the -/// runtime*, not a security check: being wrong costs one refused request. +/// Does this endpoint look like Ollama (its port, or a host naming it)? A guess, not a +/// security check: being wrong costs one refused request. fn is_ollama(base_url: &str) -> bool { let endpoint = base_url.to_ascii_lowercase(); endpoint.contains(OLLAMA_PORT) || endpoint.contains("ollama") } -/// Is this endpoint on **this machine** — the Local configuration (M5)? Anything else is -/// Cloud models: note passages leave the machine, which is why the desktop shows the -/// privacy copy beside exactly this answer. -/// -/// Permissive about *how* loopback is spelled, because guessing "cloud" for a local URL -/// only shows a warning the user doesn't need, while guessing "local" for a remote one -/// would hide one they do. So it is a **membership** test: only known loopback spellings. +/// Is this endpoint on this machine (Local, M5)? Anything else is Cloud, where passages +/// leave the machine. A membership test of known loopback spellings: calling a remote host +/// local would hide the privacy warning. fn is_local(base_url: &str) -> bool { let host = host_of(base_url); host == "localhost" @@ -61,20 +41,13 @@ fn is_local(base_url: &str) -> bool { || host.ends_with(".localhost") } -/// `127.0.0.0/8`, spelled as a dotted quad — and **nothing that merely begins -/// with `127.`**, because a registrable DNS name may (`127.notes.example.com` -/// resolves to wherever its owner points it). Parsing rather than prefix-matching -/// is the membership test [`is_local`] promises: the one direction that must -/// never happen is a remote host reading as local, which is exactly what the -/// prefix let through. +/// `127.0.0.0/8` as a dotted quad. Parsed, not prefix-matched: `127.notes.example.com` is +/// a DNS name. fn is_loopback_v4(host: &str) -> bool { matches!(host.parse::(), Ok(ip) if ip.is_loopback()) } -/// A URL's scheme (when it names one) and its authority — everything between `://` and -/// the first `/`, `?` or `#`. Hand-rolled rather than a URL crate: this crate's whole -/// posture is one wire shape and no dependency tree, and [`host_of`] and [`origin_of`] -/// need only these two pieces. +/// A URL's scheme (when present) and authority. Hand-rolled to avoid a URL crate. fn scheme_and_authority(url: &str) -> (Option<&str>, &str) { let (scheme, rest) = match url.split_once("://") { Some((s, r)) => (Some(s), r), @@ -95,16 +68,9 @@ fn host_of(url: &str) -> String { } } -/// Ollama's **native** root, derived from the configured compat base URL: the compat -/// surface is mounted under `/v1`, and `/api/tags` is not. Two derivations, in order, -/// because the input is a URL a human typed: -/// -/// 1. Drop a trailing `/v1`. This must come first, since it is the only one that survives a -/// **path-mounted** daemon — `https://gw/ollama/v1` keeps its `/ollama` prefix. -/// 2. Failing that, the **authority root**. What lands here is a base URL that isn't the -/// compat surface at all — most often a typo — and that is precisely when knowing -/// whether the daemon is up is worth most: "Ollama is running, your path is wrong" -/// rather than "is Ollama running?". +/// Ollama's native root from the compat base URL: drop a trailing `/v1` (first, so a +/// path-mounted daemon keeps its prefix), else the authority root. The fallback catches a +/// typo'd path, where "Ollama is running, your path is wrong" is most useful. fn ollama_root(base_url: &str) -> String { let trimmed = base_url.trim_end_matches('/'); match trimmed.strip_suffix("/v1") { @@ -113,8 +79,7 @@ fn ollama_root(base_url: &str) -> String { } } -/// `scheme://authority` — the URL with every path segment dropped. A missing -/// scheme reads as `http`, which is what an Ollama URL without one means. +/// `scheme://authority`; a missing scheme reads as `http`. fn origin_of(url: &str) -> String { let (scheme, authority) = scheme_and_authority(url); format!("{}://{authority}", scheme.unwrap_or("http")) @@ -126,19 +91,12 @@ fn origin_of(url: &str) -> String { pub enum ChatState { /// A server answered and serves the configured model. Chat works. Ready, - /// Nothing usable is at the endpoint: the daemon isn't running, or the URL - /// is wrong. Both readings live here because both are *the endpoint is not - /// serving chat*, and the card's copy is where they part — a refusal - /// ([`LlmError::Refused`]) never gets "is Ollama running?" advice about a - /// daemon that plainly answered. Which is to say: the **state** picks the - /// card, the **message** carries the fix. + /// Nothing usable is at the endpoint: not running, or a wrong URL. The state picks the + /// card; the message carries the fix. Unreachable, - /// A server is there, but doesn't serve the configured model — the most - /// common local-setup mistake (an un-pulled Ollama model). + /// A server is there, but doesn't serve the configured model. ModelMissing, - /// `B2_LLM=fake` is in force: the deterministic provider answers, and no - /// model is involved at all. Surfaced rather than hidden, for the same - /// reason `b2 chat` prints its note — never overstate what answered. + /// `B2_LLM=fake` is in force; surfaced so nobody overstates what answered. Fake, } @@ -147,16 +105,14 @@ pub enum ChatState { pub struct OllamaModel { /// The name to configure, tag included (`llama3.2:latest`). pub name: String, - /// On-disk size in bytes, for "how much did this cost me" in the picker. + /// On-disk size in bytes. pub size: u64, /// The parameter count as Ollama labels it (`"3.2B"`), when it says. pub parameters: Option, } -/// One rung of the picker heuristic (GH #151: *illustrative, non-binding*) — -/// deliberately data rather than prose, so the card can point at the rung the -/// machine actually sits on instead of printing a table and leaving the -/// arithmetic to the reader. +/// One rung of the picker heuristic (GH #151), as data so the card can point at this +/// machine's rung. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub struct ModelTier { /// Inclusive floor, in whole GB of system memory. @@ -165,14 +121,12 @@ pub struct ModelTier { pub ram: &'static str, /// The model size band ("7–8B"). pub size: &'static str, - /// A concrete model at that band — what `ollama pull` would be handed. + /// A concrete model at that band, for `ollama pull`. pub model: &'static str, } -/// The tiers, smallest first. **Illustrative and non-binding** (the spec's own -/// words): they are a starting point for someone who has never pulled a model, -/// not a hardware floor — B2 has none, and any OpenAI-compatible endpoint, -/// including a cloud one, satisfies the contract. +/// The tiers, smallest first. Illustrative and non-binding: a starting point, not a +/// hardware floor. pub const MODEL_TIERS: [ModelTier; 3] = [ ModelTier { min_ram_gb: 0, @@ -194,9 +148,7 @@ pub const MODEL_TIERS: [ModelTier; 3] = [ }, ]; -/// The rung `ram_gb` sits on — the highest tier whose floor it meets. Total -/// installed memory, not free: the question is what the machine can host, and a -/// suggestion that changed with whatever else is open would be noise. +/// The highest tier whose floor `ram_gb` (total, not free) meets. fn tier_for_ram(ram_gb: u64) -> &'static ModelTier { MODEL_TIERS .iter() @@ -206,55 +158,39 @@ fn tier_for_ram(ram_gb: u64) -> &'static ModelTier { } /// The Ollama-native half of the card: what the daemon has, and what to pull. -/// `None` on the [`ChatSetup`] when the endpoint isn't Ollama's — there is -/// nothing honest to say about pulling models into LM Studio. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct OllamaSetup { - /// The daemon's native root (`http://localhost:11434`) — what `/api/tags` - /// was asked, and what a message names. + /// The daemon's native root (`http://localhost:11434`). pub root: String, /// Whether the native API answered at all. pub running: bool, - /// Every installed model, as the daemon lists them. Empty when the daemon is - /// running with nothing pulled — which is the "no model" empty state, and a - /// different card from "no server". + /// Every installed model; empty is the "no model" state, not "no server". pub installed: Vec, /// Total system memory in whole GB, when the platform could be asked. pub ram_gb: Option, /// The whole heuristic, so the card can show the table it chose from. pub tiers: Vec, - /// The rung this machine sits on — `None` when memory couldn't be read, in - /// which case the card shows the table without picking for the user. + /// This machine's rung, or `None` when memory couldn't be read. pub suggested: Option, } -/// Everything an adapter needs to draw the chat surface's setup card and its -/// Settings section: the resolved configuration, the verdict, and — when the -/// runtime is Ollama — the native inventory behind it. -/// -/// A **status object, not a result**: every field is filled on every path, so an -/// unreachable daemon renders a card rather than raising. +/// Everything an adapter needs for the chat setup card and Settings section. A status, +/// not a result: an unreachable daemon renders a card rather than raising. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ChatSetup { /// The OpenAI-compatible base URL in force (env + adapter overrides applied). pub base_url: String, /// The chat model id in force. pub model: String, - /// `false` for the **Local** configuration, `true` for **Cloud models** — - /// which is the flag the privacy copy hangs off (M5). + /// `true` for Cloud models: the flag the privacy copy hangs off (M5). pub cloud: bool, - /// Whether a bearer token is configured and, when one is, which source supplied it. - /// **Never the token itself.** Richer than the boolean it replaced because the Settings - /// copy differs in each case (GH #176) — a key the environment supplies cannot be - /// removed from inside the app, and a key the Keychain refused to take is gone at quit. + /// Which source supplied the bearer token, never the token (GH #176). pub api_key_source: ApiKeySource, pub state: ChatState, - /// A generic, actionable sentence when `state` isn't `Ready` (E4), else - /// `None`. Phrased here rather than in each adapter so the CLI's wording and - /// the app's stay one sentence. + /// A generic, actionable sentence when `state` isn't `Ready` (E4), shared by both + /// adapters. pub message: Option, - /// Models the endpoint says it serves, when it said — the list a "that model - /// isn't there" card offers instead of the one that's missing. + /// Models the endpoint says it serves, when it said. pub available: Vec, /// The Ollama-native onboarding half; `None` for any other runtime. pub ollama: Option, @@ -262,17 +198,15 @@ pub struct ChatSetup { pub tool_calls: ToolCallCap, } -/// [`LlmConfig::max_tool_calls`] for a surface that edits it: the value in force, plus the -/// two numbers the field's copy and its validation quote. Carried rather than mirrored in -/// the frontend, so the panel can never advertise a range the parser would refuse. +/// [`LlmConfig::max_tool_calls`] for a surface that edits it, with its default and ceiling +/// so the frontend never advertises a range the parser would refuse. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub struct ToolCallCap { - /// The cap in force — Settings over the environment over the default. + /// The cap in force: Settings over the environment over the default. pub in_force: usize, - /// [`crate::DEFAULT_MAX_TOOL_CALLS`]: what clearing the field returns to (absent - /// an environment override). + /// [`crate::DEFAULT_MAX_TOOL_CALLS`]. pub default: usize, - /// [`crate::MAX_TOOL_CALLS_CEILING`]: the highest value any source may set. + /// [`crate::MAX_TOOL_CALLS_CEILING`]. pub ceiling: usize, } @@ -288,9 +222,7 @@ impl ToolCallCap { } impl ChatSetup { - /// The setup a **fake** provider is in: `B2_LLM=fake`, no server, nothing to - /// configure. Its own constructor because every other field would otherwise - /// be a lie about a model that isn't answering. + /// The setup under `B2_LLM=fake`: no server, nothing to configure. pub fn fake(config: &LlmConfig) -> Self { Self { base_url: config.base_url.clone(), @@ -306,10 +238,8 @@ impl ChatSetup { } } -/// Ask the configured endpoint what it can do and render the answer as a card's worth of -/// facts: one `GET /models`, plus one `GET /api/tags` when the endpoint looks like Ollama's. -/// Never fails — a dead daemon, a wrong URL, a model nobody pulled: each is a [`ChatState`] -/// with a sentence, because each is a thing the human can fix. +/// Ask the configured endpoint what it can do: `GET /models`, plus `GET /api/tags` when it +/// looks like Ollama. Never fails: each problem is a [`ChatState`] with a sentence. pub fn probe_setup(config: &LlmConfig) -> ChatSetup { let ollama = is_ollama(&config.base_url).then(|| ollama_setup(&config.base_url)); let (state, message, available) = match OpenAiCompatProvider::new(config.clone()).probe() { @@ -321,10 +251,8 @@ pub fn probe_setup(config: &LlmConfig) -> ChatSetup { Some(model_missing_message(&model, &config.base_url)), available, ), - // Something is listening and refused the probe. A different sentence from - // "nothing is listening", and the one place the Ollama half is *evidence* - // rather than onboarding: a daemon that answered `/api/tags` proves the - // server is up, which turns a 404 from a guess into a diagnosis. + // Refused, not unreachable. A daemon that answered `/api/tags` proves the server + // is up, which turns a 404 into a diagnosis. Err(LlmError::Refused { status, message, .. }) => ( @@ -359,11 +287,8 @@ pub fn probe_setup(config: &LlmConfig) -> ChatSetup { } } -/// E4's own sentence — nothing is listening at `base_url` — and the reason [`is_ollama`] -/// exists: `ollama serve` is the fix for Ollama and no help whatever for LM Studio, -/// llama.cpp, vLLM or a cloud endpoint, all of which the same setting points at. Phrased -/// here so both adapters say it the same way; an adapter appends only its own way of -/// changing the endpoint (a CLI flag, a Settings field). +/// E4: nothing is listening at `base_url`. Suggests `ollama serve` only for Ollama. Both +/// adapters use it and append their own way to change the endpoint. pub fn unreachable_message(base_url: &str) -> String { if is_ollama(base_url) { format!( @@ -375,19 +300,9 @@ pub fn unreachable_message(base_url: &str) -> String { } } -/// What to tell a human when the endpoint **answered a probe with a refusal** — the -/// sentence for [`LlmError::Refused`], phrased here so the CLI's wording and the app's stay -/// one sentence. Split by status, because these are different mistakes with different fixes -/// and a single "the server said no" would be useful for none: -/// -/// - **401/403** — the path is right and the credential isn't. -/// - **404/405/410/501** — nothing serves `/models` here; overwhelmingly a base URL that -/// isn't the compat surface, which is why the fix names the path rather than the server. -/// - anything else — a server that is there and failing, whose own words are the most -/// useful thing available. -/// -/// `ollama_root` is `Some` only when the daemon **answered its native API**, which is proof -/// the machine is serving requests: with it, the 404 branch names the URL that would work. +/// The sentence for [`LlmError::Refused`], by status: 401/403 is the credential, 404-ish +/// is the base URL path, anything else quotes the server. `ollama_root` is `Some` only when +/// the daemon answered its native API, so the 404 branch can name the URL that would work. pub fn refusal_message( base_url: &str, status: u16, @@ -404,8 +319,7 @@ pub fn refusal_message( "Something is running at {base_url}, but it isn't an OpenAI-compatible API \ (HTTP {status}). Check the endpoint path — it usually ends in `/v1`." ); - // Only when it would name something *other* than what the user already - // typed: echoing their own URL back as the fix is worse than silence. + // Never echo the user's own URL back as the fix. if let Some(root) = ollama_root { let suggestion = format!("{root}/v1"); if suggestion != base_url.trim_end_matches('/') { @@ -423,10 +337,7 @@ pub fn refusal_message( } } -/// The server at `base_url` is up but doesn't serve `model` ([`LlmError::ModelMissing`]) — -/// the most common local-setup mistake. [`unreachable_message`]'s sibling: Ollama gets its -/// own fix, everything else the general one, and an adapter appends only how *it* picks -/// another model. +/// The sentence for [`LlmError::ModelMissing`]; Ollama gets its own fix. pub fn model_missing_message(model: &str, base_url: &str) -> String { if is_ollama(base_url) { format!( @@ -438,18 +349,13 @@ pub fn model_missing_message(model: &str, base_url: &str) -> String { } } -/// `ollama pull ` — the one command the card tells a human to run, spelled -/// in one place so the button's label, its copy-to-clipboard payload and any -/// message agree. +/// `ollama pull `, spelled once for the button, its clipboard payload and messages. pub fn pull_command(model: &str) -> String { format!("ollama pull {model}") } -/// The native half: `GET {root}/api/tags`, plus the machine's memory and the rung -/// it sits on. A daemon that doesn't answer is `running: false` with an empty -/// inventory — the "no server" card — and a daemon that answers with nothing -/// installed is `running: true` and still empty: the "no model" card. Those are -/// two different sentences, which is why this is a struct and not a bool. +/// The native half: `GET {root}/api/tags`, plus the machine's memory and rung. +/// `running: false` is the "no server" card; running and empty is "no model". fn ollama_setup(base_url: &str) -> OllamaSetup { let root = ollama_root(base_url); let installed = installed_models(&root); @@ -464,8 +370,7 @@ fn ollama_setup(base_url: &str) -> OllamaSetup { } } -/// What `GET {root}/api/tags` returns, of which B2 reads three fields. Ollama's -/// own shape, so it lives beside the one call that speaks it. +/// What `GET {root}/api/tags` returns; B2 reads three fields. #[derive(Debug, Deserialize)] struct TagsResponse { #[serde(default)] @@ -488,10 +393,8 @@ struct TagsDetails { parameter_size: Option, } -/// Every model the daemon at `root` has installed. `None` means the native API didn't -/// answer — not that nothing is installed, which is a different card. Its own agent rather -/// than the provider's: a different API on a different path, wanted at a shorter timeout, -/// and it must never send the bearer token a cloud configuration might carry. +/// Every model the daemon at `root` has installed, or `None` when it didn't answer. Its own +/// agent: a shorter timeout, and it must never send a cloud bearer token. fn installed_models(root: &str) -> Option> { let url = format!("{root}{TAGS_PATH}"); let agent = ureq::AgentBuilder::new() @@ -520,12 +423,8 @@ fn installed_models(root: &str) -> Option> { Some(models_from(parsed)) } -/// Ollama's inventory shape, narrowed to the three fields the card shows. Split -/// from the HTTP call because it is the only real *parsing* in this module, and a -/// parse that can only be exercised through a live daemon is a parse nobody tests: -/// a nameless entry (which the daemon has been known to return for a partial pull) -/// is dropped rather than painted as a blank row, and an entry with no `details` -/// keeps its name instead of being lost with it. +/// Ollama's inventory narrowed to what the card shows; split out so it is testable. A +/// nameless entry (a partial pull) is dropped. fn models_from(parsed: TagsResponse) -> Vec { parsed .models @@ -539,28 +438,19 @@ fn models_from(parsed: TagsResponse) -> Vec { .collect() } -/// Total system memory in whole GB, or `None` where the platform can't be asked. -/// -/// Read from the OS rather than pulled in as a dependency (`sysinfo` and friends -/// bring a tree for one number), and best-effort by construction: the only thing -/// it feeds is a *non-binding suggestion*, so `None` costs the card its -/// highlighted row and nothing else. +/// Total system memory in whole GB, or `None` where the platform can't be asked. Read +/// from the OS rather than a dependency; it only feeds a suggestion. fn system_ram_gb() -> Option { - // Rounded, not truncated. The tiers are floors, and a machine sold as "16 GB" - // does not always report 16 GiB of usable memory — Linux's `MemTotal` excludes - // what the firmware reserved, so it reads ~15.6, and truncation would drop such - // a machine a whole rung. Rounding puts it on the rung it was sold as. + // Rounded, not truncated: Linux's `MemTotal` excludes firmware-reserved memory, so a + // "16 GB" machine reads ~15.6 and would drop a rung. system_ram_bytes().map(|b| (b + GIB / 2) / GIB) } -/// One gibibyte — what "GB" means everywhere in this module (and what `hw.memsize` -/// and `MemTotal` both report in). +/// One gibibyte: what "GB" means in this module. const GIB: u64 = 1_073_741_824; #[cfg(target_os = "macos")] fn system_ram_bytes() -> Option { - // `sysctl` is part of the base system; B2 ships on macOS (crates/b2-desktop), - // so this is the platform's own answer rather than a guess. let out = std::process::Command::new("/usr/sbin/sysctl") .args(["-n", "hw.memsize"]) .output() @@ -570,8 +460,7 @@ fn system_ram_bytes() -> Option { #[cfg(target_os = "linux")] fn system_ram_bytes() -> Option { - // Not a shipping platform, but developers work here — and a suggestion that - // silently vanished off-macOS would look like a bug in the card. + // Not a shipping platform, but developers work here. let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?; let kb: u64 = meminfo .lines() @@ -597,8 +486,6 @@ mod tests { assert!(is_ollama("http://localhost:11434/v1")); assert!(is_ollama("http://127.0.0.1:11434")); assert!(is_ollama("https://ollama.example.com/v1")); - // LM Studio, llama.cpp and the cloud endpoints must not be told to run - // `ollama serve` — advice about the wrong program. assert!(!is_ollama("http://localhost:1234/v1")); assert!(!is_ollama("https://api.openai.com/v1")); } @@ -616,10 +503,7 @@ mod tests { for cloud in [ "https://api.openai.com/v1", "https://api.anthropic.com/v1", - // The failures that matter, both of the same shape: a host that merely - // *looks* loopback must read as cloud, or the privacy copy hides on a - // remote endpoint. `127.notes.example.com` is a perfectly registrable - // name, and a `starts_with("127.")` test took it for the loopback range. + // Hosts that merely look loopback must read as cloud. "https://localhost.evil.example.com/v1", "https://127.notes.example.com/v1", "https://127.0.0.1.example.com/v1", @@ -645,8 +529,6 @@ mod tests { ); } - /// The typo case, and why the fallback exists: `…/v1X` has no `/v1` to strip, - /// and the daemon it needs to ask about is at the authority root. #[test] fn a_root_that_isnt_the_compat_surface_falls_back_to_the_authority() { assert_eq!( @@ -657,17 +539,13 @@ mod tests { ollama_root("http://localhost:11434/api"), "http://localhost:11434" ); - // A path-mounted daemon keeps its prefix — which is the whole reason the - // `/v1` strip comes first rather than always taking the authority. + // A path-mounted daemon keeps its prefix. assert_eq!( ollama_root("https://gw.example.com/ollama/v1"), "https://gw.example.com/ollama" ); } - /// The reported bug, at the layer that phrases it: a base URL that answers but - /// isn't a chat API must produce a sentence about the **path**, never - /// "is Ollama running?" about a daemon that is plainly running. #[test] fn a_refusal_names_the_mistake_it_actually_is() { let path = refusal_message( @@ -678,28 +556,22 @@ mod tests { ); assert!(path.contains("isn't an OpenAI-compatible API"), "{path}"); assert!(path.contains("HTTP 404"), "{path}"); - // The daemon answered its native API, so the fix is a URL, not a guess. assert!(path.contains("try http://localhost:11434/v1"), "{path}"); assert!(!path.contains("ollama serve"), "the daemon is up: {path}"); - // A key is a different fix, and used to read as Connected. let key = refusal_message("https://api.example.com/v1", 401, "invalid api key", None); assert!(key.contains("API key"), "{key}"); assert!(!key.contains("endpoint path"), "one fix, not two: {key}"); - // Nothing to suggest: no Ollama evidence, so no invented advice. let bare = refusal_message("http://localhost:1234/v2", 404, "", None); assert!(bare.contains("endpoint path"), "{bare}"); assert!(!bare.to_lowercase().contains("ollama"), "{bare}"); - // Anything else is the server's own words, which are the useful part. let busy = refusal_message("https://api.example.com/v1", 503, "at capacity", None); assert!(busy.contains("HTTP 503"), "{busy}"); assert!(busy.contains("at capacity"), "{busy}"); } - /// The one sentence that would be silly: echoing back the URL the user typed - /// as the thing to try instead. #[test] fn a_refusal_never_suggests_the_url_it_was_given() { let same = refusal_message( @@ -718,15 +590,9 @@ mod tests { assert_eq!(tier_for_ram(16).model, MODEL_TIERS[1].model); assert_eq!(tier_for_ram(24).model, MODEL_TIERS[1].model); assert_eq!(tier_for_ram(64).model, MODEL_TIERS[2].model); - // Below the smallest rung is still the smallest rung — a 4 GB machine - // gets the smallest suggestion, never no suggestion. assert_eq!(tier_for_ram(0).model, MODEL_TIERS[0].model); } - /// The only real parsing in this module, and the reason `models_from` is split - /// out of the HTTP call: a nameless entry is dropped rather than painted as a - /// blank row, and an entry with no `details` keeps its name instead of going - /// with it. #[test] fn the_inventory_reads_ollamas_own_shape() { let body = r#"{"models":[ @@ -750,13 +616,11 @@ mod tests { }, ] ); - // A body with no `models` key at all is an empty inventory, not a failure: - // "the daemon answered and has nothing" is a card B2 draws. + // No `models` key is an empty inventory, not a failure. assert!(models_from(serde_json::from_str("{}").unwrap()).is_empty()); } - /// The setup view is what crosses to a webview, so the one thing it must - /// never carry is the bearer token — only whether there is one. + /// The setup view crosses to a webview, so it must never carry the bearer token. #[test] fn the_setup_view_reports_a_key_without_carrying_it() { let config = LlmConfig { @@ -779,7 +643,6 @@ mod tests { }; let json = serde_json::to_string(&setup).unwrap(); assert!(!json.contains("sk-live-do-not-serialize-me"), "{json}"); - // The cap rides along with the two numbers the Settings field quotes. assert!( json.contains("\"tool_calls\":{\"in_force\":64,\"default\":64,\"ceiling\":4096}"), "{json}" @@ -788,13 +651,10 @@ mod tests { assert!(json.contains("\"cloud\":true"), "{json}"); } - /// The view's source is the **resolved** one, not a guess about it — which is - /// what lets the Settings copy tell a user that the key in force is their - /// shell's, and that the Remove button therefore cannot reach it (GH #176). + /// GH #176: Settings tells the user when the key in force is their shell's. #[test] fn the_setup_view_names_the_source_the_resolver_chose() { - // `.invalid` is reserved (RFC 2606): the probe fails immediately and this - // never meets a model server a developer happens to be running. + // `.invalid` is reserved (RFC 2606), so the probe fails at once. let stored_under_an_env_key = LlmConfig { base_url: "http://b2-no-such-host.invalid:11434/v1".into(), api_key: Some("sk-from-the-environment".into()), @@ -806,16 +666,12 @@ mod tests { probe_setup(&stored_under_an_env_key).api_key_source, ApiKeySource::Environment ); - // A keyless configuration says so plainly — what hides the Remove button - // and the cloud privacy copy alike. assert_eq!( ChatSetup::fake(&LlmConfig::default()).api_key_source, ApiKeySource::None ); } - /// `B2_LLM=fake` is surfaced, not hidden: the CLI prints a note for the same - /// reason (never overstate what answered). #[test] fn the_fake_setup_says_so() { let setup = ChatSetup::fake(&LlmConfig::default()); @@ -827,8 +683,6 @@ mod tests { fn messages_name_ollamas_own_fix_only_for_ollama() { let ollama = unreachable_message("http://localhost:11434/v1"); assert!(ollama.contains("ollama serve"), "{ollama}"); - // The other half of "nothing is listening": the daemon may not be installed at - // all, and the fix for that is a page, not a command. assert!(ollama.contains(OLLAMA_INSTALL_URL), "{ollama}"); let other = unreachable_message("http://localhost:1234/v1"); assert!(!other.to_lowercase().contains("ollama"), "{other}"); @@ -843,9 +697,7 @@ mod tests { ); } - /// An unreachable endpoint is a *status*, not an error — the whole point of - /// this module. Port 1 on a reserved-`.invalid` host can't resolve, so this - /// stays hermetic and never meets a daemon a developer happens to be running. + /// A reserved `.invalid` host can't resolve, so this stays hermetic. #[test] fn an_unreachable_endpoint_is_a_card_not_a_failure() { let setup = probe_setup(&LlmConfig { @@ -855,8 +707,7 @@ mod tests { }); assert_eq!(setup.state, ChatState::Unreachable); assert!(setup.message.is_some_and(|m| m.contains("ollama serve"))); - // The endpoint names Ollama's port, so the native half was attempted — - // and honestly reports a daemon that isn't there. + // Ollama's port, so the native half was attempted. let ollama = setup .ollama .expect("an Ollama-shaped endpoint gets the card"); diff --git a/crates/b2-llm/src/sse.rs b/crates/b2-llm/src/sse.rs index cc28cd2..b8b3ded 100644 --- a/crates/b2-llm/src/sse.rs +++ b/crates/b2-llm/src/sse.rs @@ -1,27 +1,13 @@ -//! The SSE reader — the wire quirks this crate exists to own. +//! The SSE reader: `data:` JSON chunks ending in `data: [DONE]`, plus the quirks servers +//! vary on (keep-alive comments, CRLF, multi-line `data:`, an error inside a 200). //! -//! Server-Sent Events is a line protocol: `field: value` lines, a blank line ending each -//! event, `:`-leading comments. OpenAI-compatible servers use exactly one field — `data:` — -//! carrying a JSON chunk per event, ending with the literal `data: [DONE]`. What varies -//! between servers, and what the tests below pin, is everything around that: keep-alive -//! comments during a long prefill, CRLF, a `data:` with no space, a spec-legal multi-line -//! `data:`, an error object arriving *inside* a 200, and a stream that just stops. +//! A stream that ends without `[DONE]` is truncated, not broken: the tokens are real, so it +//! returns `cancelled: true`. A garbled frame is an error, since skipping it would turn a +//! protocol mismatch into a silently truncated answer. //! -//! Two endings are deliberately different, because they are different facts: -//! -//! - A stream that ends **without** `[DONE]` is truncated, not broken: the tokens that -//! arrived are real, so the completion comes back `cancelled: true` and the human sees a -//! partial answer rather than a failure with nothing to show. -//! - A **garbled** frame — a `data:` payload that isn't JSON — is an error. Skipping it -//! would turn a protocol mismatch into a quietly truncated answer, the one outcome -//! nobody can debug. -//! -//! A **tool call** arrives on the same stream as `delta.tool_calls`, and servers disagree -//! about how: OpenAI splits one call across frames (the id and name first, then the -//! arguments a few characters at a time, all keyed by `index`), Ollama sends each call -//! whole in one frame, and some send no `id` at all. [`ToolCallParts`] assembles all three -//! into the seam's [`ToolCall`]; the arguments stay JSON *text*, because they are model -//! output and parsing them is the judgement of whoever runs the tool. +//! Tool calls arrive split across frames by `index` (OpenAI) or whole (Ollama), sometimes +//! without an `id`; [`ToolCallParts`] assembles them. Arguments stay JSON text: parsing +//! model output is the tool runner's job. use crate::provider::ErrorDetail; use crate::LlmError; @@ -30,10 +16,9 @@ use serde::Deserialize; use std::io::BufRead; use std::ops::ControlFlow; -/// Read an SSE response to its end, delivering each content delta to `on_token` as it -/// arrives. Returns the accumulated text plus how the stream ended. `on_token` steers it: -/// [`ControlFlow::Break`] stops the loop at once and returns, which drops the reader — and -/// with it the connection — at the next scope exit. That is the whole of cancellation. +/// Read an SSE response to its end, delivering each content delta to `on_token`. A +/// [`ControlFlow::Break`] returns at once, dropping the reader and the connection: that is +/// the whole of cancellation. pub(crate) fn stream_completion( mut reader: R, max_tool_calls: usize, @@ -41,12 +26,10 @@ pub(crate) fn stream_completion( ) -> Result { let mut text = String::new(); let mut calls = ToolCallParts::new(max_tool_calls); - // The current event's `data:` payload — accumulated across lines, since the - // spec allows an event to carry several and joins them with newlines. + // The current event's `data:` payload; an event may span several lines. let mut data = String::new(); let mut line = String::new(); - // Whether the server declared the answer finished (a `finish_reason`), which - // is what separates "ended" from "cut off" when no `[DONE]` follows. + // A `finish_reason` separates "ended" from "cut off" when no `[DONE]` follows. let mut finished = false; loop { @@ -54,12 +37,10 @@ pub(crate) fn stream_completion( let read = reader .read_line(&mut line) .map_err(|e| LlmError::Stream(format!("could not read the stream: {e}")))?; - // EOF closes a pending event exactly as a blank line would: a server that - // stops mid-event has still sent that event. + // EOF closes a pending event as a blank line would. let eof = read == 0; let field = line.trim_end_matches(['\n', '\r']); if eof || field.is_empty() { - // End of event: dispatch what was accumulated, then start the next. match dispatch(&data, &mut text, &mut calls, on_token)? { Step::Done => return Ok(completed(text, calls)), Step::Cancelled => return Ok(cancelled(text)), @@ -74,8 +55,7 @@ pub(crate) fn stream_completion( "the model stream ended without [DONE]; reporting a partial answer" ); } - // Tool calls from a stream that was cut off are not run: half a call's - // arguments is not a call. + // A cut-off stream's tool calls are not run. return Ok(if finished { completed(text, calls) } else { @@ -86,20 +66,17 @@ pub(crate) fn stream_completion( continue; } if field.starts_with(':') { - // A comment — the keep-alive a server sends while it thinks. Nothing - // to deliver, but it *is* the signal that the connection is alive. + // A keep-alive comment. continue; } if let Some(value) = field.strip_prefix("data:") { - // One optional leading space belongs to the framing, not the value. let value = value.strip_prefix(' ').unwrap_or(value); if !data.is_empty() { data.push('\n'); } data.push_str(value); } - // Every other field (`event:`, `id:`, `retry:`) is framing this wire - // shape doesn't use. Ignored, not refused: a server is free to send it. + // Other fields (`event:`, `id:`, `retry:`) are ignored, not refused. } } @@ -107,8 +84,7 @@ pub(crate) fn stream_completion( enum Step { /// Nothing that ends the stream (deltas delivered, or nothing to deliver). Continue, - /// The server declared the answer complete (`finish_reason`), but hasn't - /// sent `[DONE]` yet — so an EOF from here is a clean ending, not a cut one. + /// A `finish_reason` arrived: an EOF from here is a clean ending. Finished, /// `[DONE]`: the stream is over. Done, @@ -116,8 +92,7 @@ enum Step { Cancelled, } -/// Interpret one complete event payload: deliver its content deltas, and report -/// anything that ends the stream. +/// Interpret one complete event payload. fn dispatch( payload: &str, text: &mut String, @@ -126,7 +101,6 @@ fn dispatch( ) -> Result { let payload = payload.trim(); if payload.is_empty() { - // A blank line with no data before it — SSE's own no-op. return Ok(Step::Continue); } if payload == "[DONE]" { @@ -137,9 +111,7 @@ fn dispatch( "a stream frame was not JSON ({e}) — is the configured URL an OpenAI-compatible endpoint?" )) })?; - // An error object inside a 200 response: the server accepted the request, - // then failed — a model unloaded mid-answer, a context overflow, a rate - // limit. It arrives as data, so it can only be caught here. + // An error inside a 200 (model unloaded, context overflow): only catchable here. if let Some(error) = chunk.error { return Err(LlmError::Provider(error.message())); } @@ -179,7 +151,7 @@ fn cancelled(text: String) -> Completion { } } -/// One tool call under assembly: what the fragments so far have said about it. +/// One tool call under assembly. #[derive(Debug, Default)] struct PartialCall { id: Option, @@ -207,18 +179,15 @@ impl ToolCallParts { fn absorb(&mut self, part: ToolCallDelta) -> Result<(), LlmError> { let mut at = part.index.unwrap_or(self.slots.len()); - // A *different* id on a slot that already names a call is a new call, whatever - // the index says: some servers send every whole call as `index: 0`, and merging - // them would run one tool named after two. + // A different id on a named slot is a new call: some servers send every whole + // call as `index: 0`. if let (Some(id), Some(slot)) = (&part.id, self.slots.get(at)) { if slot.id.as_ref().is_some_and(|held| held != id) && !slot.name.is_empty() { at = self.slots.len(); } } - // The cap is absolute, not a per-frame jump: `MAX_STREAM_BYTES` bounds the bytes - // read, not the table a sparse `index` can make of them, and a run of modest - // jumps adds up to the same allocation as one large one. Refused loudly, never - // trimmed — see [`LlmError::TooManyToolCalls`]. + // An absolute cap: `MAX_STREAM_BYTES` doesn't bound the table a sparse `index` + // can allocate. Refused, never trimmed. if at >= self.max { tracing::warn!( target: "b2::llm", @@ -229,7 +198,7 @@ impl ToolCallParts { return Err(LlmError::TooManyToolCalls { limit: self.max }); } if at >= self.slots.len() { - // A sparse index below the cap just leaves empty slots, which `finish` drops. + // Empty slots from a sparse index are dropped by `finish`. self.slots.resize_with(at + 1, Default::default); } let Some(slot) = self.slots.get_mut(at) else { @@ -244,8 +213,7 @@ impl ToolCallParts { } match function.arguments { Some(serde_json::Value::String(text)) => slot.arguments.push_str(&text), - // Arguments sent as an object rather than as JSON text (Ollama's native - // shape, and some `/v1` shims): re-serialize so the seam stays text. + // Arguments sent as an object (Ollama): re-serialize so the seam stays text. Some(other) if !other.is_null() => slot.arguments.push_str(&other.to_string()), _ => {} } @@ -253,9 +221,8 @@ impl ToolCallParts { Ok(()) } - /// The assembled calls, in index order. A slot that never got a name is not a call; - /// a call the server gave no id gets a positional one, since the id is only ever - /// echoed back beside its result. + /// The assembled calls, in index order. Unnamed slots are dropped; a missing id gets a + /// positional one (it is only echoed back beside the result). fn finish(self) -> Vec { self.slots .into_iter() @@ -273,15 +240,12 @@ impl ToolCallParts { } } -/// One `data:` chunk of a streamed chat completion. Only the fields B2 acts on -/// are read; `#[serde(default)]` throughout, since which of them a given server -/// sends on a given frame is not something to be strict about. +/// One `data:` chunk of a streamed chat completion; lenient, since servers vary. #[derive(Debug, Deserialize)] struct StreamChunk { #[serde(default)] choices: Vec, - /// The mid-stream error frame (see [`dispatch`]) — the same shape servers - /// send as an error *body*, which is why the type is shared. + /// The mid-stream error frame, the same shape as an error body. #[serde(default)] error: Option, } @@ -295,8 +259,7 @@ struct StreamChoice { finish_reason: Option, } -/// The incremental payload. A frame may carry role-only (the first), content, or -/// neither (the last) — all three are ordinary. +/// The incremental payload; may carry content, tool calls, or neither. #[derive(Debug, Default, Deserialize)] struct Delta { #[serde(default)] @@ -305,8 +268,7 @@ struct Delta { tool_calls: Vec, } -/// One fragment of a tool call. Everything optional: which fields a given frame carries -/// is exactly what varies between servers. +/// One fragment of a tool call; which fields arrive varies between servers. #[derive(Debug, Deserialize)] struct ToolCallDelta { #[serde(default)] @@ -380,8 +342,6 @@ mod tests { #[test] fn keep_alive_comments_and_blank_lines_are_not_tokens() { - // What a server sends while it prefills a long prompt: comments, and the - // blank lines that frame them. None of it is answer text. let canned = format!( ": ping\n\n: ping\n\n{}\n\ndata: [DONE]\n\n", frame("Grounded").trim_end() @@ -393,7 +353,6 @@ mod tests { #[test] fn crlf_endings_and_a_spaceless_data_field_parse() { - // Two framing variations that are equally legal and appear in the wild. let canned = "data:{\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\r\n\r\ndata:[DONE]\r\n\r\n"; let (result, tokens) = read(canned); @@ -403,8 +362,6 @@ mod tests { #[test] fn a_multi_line_data_field_is_joined_as_the_spec_says() { - // SSE allows an event's payload to span `data:` lines, joined with - // newlines — which is still one JSON object to us. let canned = "data: {\"choices\":[{\"delta\":\ndata: {\"content\":\"split\"}}]}\n\ndata: [DONE]\n\n"; let (result, tokens) = read(canned); @@ -414,7 +371,6 @@ mod tests { #[test] fn a_mid_stream_error_frame_fails_the_call() { - // A 200 that goes wrong afterwards — the quirk a hand-rolled client owns. let canned = format!( "{}data: {{\"error\":{{\"message\":\"context window exceeded\"}}}}\n\n", frame("Groun") @@ -439,9 +395,6 @@ mod tests { #[test] fn a_truncated_stream_returns_the_partial_answer_as_cancelled() { - // The server died, or the connection dropped: no [DONE], no - // finish_reason. The tokens that arrived are real, so they are returned - // — honestly marked as not the whole answer. let canned = format!("{}{}", frame("Grounded"), frame(" in")); let (result, tokens) = read(&canned); let completion = result.expect("a truncated stream is not an error"); @@ -455,8 +408,6 @@ mod tests { #[test] fn a_finish_reason_ends_the_answer_even_without_done() { - // Some servers close the connection right after the final frame instead - // of sending [DONE]. That is a complete answer, not a truncated one. let canned = format!( "{}data: {{\"choices\":[{{\"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\n\n", frame("Grounded") @@ -469,7 +420,6 @@ mod tests { #[test] fn a_last_event_without_its_blank_line_is_still_dispatched() { - // EOF closes the final event even when the server didn't. let canned = "data: [DONE]"; let (result, _) = read(canned); assert!(!result.expect("EOF closes the last event").cancelled); @@ -488,8 +438,7 @@ mod tests { #[test] fn breaking_the_callback_stops_at_that_token() { - // Cooperative cancellation at token granularity: the token that broke is - // part of the answer (it was delivered), and nothing after it is read. + // The token that broke was delivered, so it is part of the answer. let canned = format!( "{}{}{}data: [DONE]\n\n", frame("one"), @@ -513,8 +462,7 @@ mod tests { #[test] fn a_tool_call_split_across_frames_is_assembled_by_index() { - // OpenAI's shape: id + name first, then the arguments in pieces; two calls - // interleaved by `index`. + // OpenAI's shape: two calls interleaved by `index`. let canned = concat!( "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"b2_read\",\"arguments\":\"\"}}]}}]}\n\n", "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_b\",\"function\":{\"name\":\"b2_neighbors\",\"arguments\":\"{}\"}}]}}]}\n\n", @@ -546,7 +494,6 @@ mod tests { #[test] fn a_whole_call_in_one_frame_with_no_id_and_object_arguments_still_parses() { - // The lenient end: no `index`, no `id`, arguments as an object rather than text. let canned = concat!( "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"b2_similar\",\"arguments\":{\"note\":\"a.md\"}}}]}}]}\n\n", "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", @@ -585,10 +532,8 @@ mod tests { #[test] fn a_sparse_index_past_the_cap_fails_the_call_instead_of_growing_the_table() { - // The memory bound `MAX_STREAM_BYTES` cannot give: a ~100-byte frame naming a - // huge `index` would otherwise allocate a slot for every index below it. Any - // index at or past the cap is refused outright — one frame, or a run of - // modest jumps that adds up to the same thing. + // A ~100-byte frame naming a huge `index` would otherwise allocate every slot + // below it. let err = read_capped(&call_frame(Some(1_000_000), "b2_read"), 64).unwrap_err(); assert!( matches!(err, LlmError::TooManyToolCalls { limit: 64 }), @@ -603,7 +548,6 @@ mod tests { matches!(err, LlmError::TooManyToolCalls { limit: 64 }), "{err:?}" ); - // The error says what to do about it. assert!(err.to_string().contains("64"), "{err}"); assert!(err.to_string().contains(crate::ENV_MAX_TOOL_CALLS), "{err}"); } @@ -617,8 +561,6 @@ mod tests { .collect::() + done }; - // At the cap: fine. One past it: refused — under the default-sized cap and - // under a small configured one alike. assert_eq!(read_capped(&calls(64), 64).unwrap().tool_calls.len(), 64); assert!(matches!( read_capped(&calls(65), 64).unwrap_err(), @@ -633,8 +575,6 @@ mod tests { #[test] fn a_stream_cut_off_mid_call_runs_no_tool() { - // Half a call's arguments is not a call: a truncated stream reports the partial - // text as cancelled, and offers nothing to execute. let canned = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c\",\"function\":{\"name\":\"b2_read\",\"arguments\":\"{\\\"no\"}}]}}]}\n\n"; let completion = read(canned).0.unwrap(); assert!(completion.cancelled); @@ -643,9 +583,7 @@ mod tests { #[test] fn role_only_and_empty_deltas_deliver_nothing() { - // The opening frame of every OpenAI-shaped stream carries a role and no - // content; an empty-string delta happens too. Neither is a token, and an - // adapter that rendered them would flicker. + // The opening role-only frame and an empty delta are not tokens. let canned = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n\ data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n\ data: [DONE]\n\n"; diff --git a/crates/b2-llm/tests/wire.rs b/crates/b2-llm/tests/wire.rs index 9c047dd..a60ae89 100644 --- a/crates/b2-llm/tests/wire.rs +++ b/crates/b2-llm/tests/wire.rs @@ -1,12 +1,6 @@ -//! The wire path end to end, over a real socket: request building, the HTTP -//! round trip, and the streamed response read back through [`LlmProvider`]. -//! -//! The unit tests in `src/sse.rs` feed the parser canned bytes; these prove the -//! part canned bytes can't — that the request B2 actually sends is the one an -//! OpenAI-compatible server expects, and that the answer survives the socket. -//! The "server" is a scripted [`TcpListener`] on loopback, so this needs **no -//! model, no network, and no new dependency**, and it stays deterministic: the -//! script says how many connections to accept and exactly what to write back. +//! The wire path end to end over a real socket: the request B2 sends, the HTTP round trip, +//! and the streamed answer. The server is a scripted loopback [`TcpListener`], so this is +//! deterministic and needs no model or network. use b2_core::chat::build_request; use b2_core::llm::{ContextPassage, LlmProvider}; @@ -18,15 +12,14 @@ use std::thread::JoinHandle; /// What the scripted server does with one connection. enum Reply { - /// Accept, read the request, then close without answering — a pooled - /// connection the server had already given up on. + /// Close without answering: a pooled connection the server gave up on. Close, /// Write these bytes verbatim (status line, headers, body), then close. Raw(String), } -/// Start a server that handles exactly `script.len()` connections, one per entry. -/// Returns the base URL to configure and a handle yielding the requests it saw. +/// A server handling one connection per script entry. Returns the base URL and a handle +/// yielding the requests it saw. fn serve(script: Vec) -> (String, JoinHandle>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); let port = listener.local_addr().expect("addr").port(); @@ -36,8 +29,7 @@ fn serve(script: Vec) -> (String, JoinHandle>) { let (mut socket, _) = listener.accept().expect("accept"); seen.push(read_request(&mut socket)); if let Reply::Raw(text) = reply { - // A failed write means the client hung up first, which some of - // these cases are *about* — never a reason to fail the thread. + // The client hanging up first is what some cases are about. let _ = socket.write_all(text.as_bytes()); let _ = socket.flush(); } @@ -105,9 +97,6 @@ fn complete(p: &OpenAiCompatProvider, tokens: &mut Vec) -> b2_core::Resu Ok(completion.text) } -/// The happy path: what B2 sends is a streaming chat completion carrying the -/// grounded prompt with its numbered passages, and what comes back is the -/// answer, token by token. #[test] fn a_streamed_answer_makes_the_round_trip() { let (url, server) = serve(vec![sse_response( @@ -144,10 +133,8 @@ fn a_streamed_answer_makes_the_round_trip() { ); } -/// A connection the server had already closed costs a retry, not the answer. -/// `ureq` won't retry a POST itself (non-idempotent), so this is B2's own -/// one-shot resend — safe precisely because no response existed to have produced -/// anything. The scripted server proves it happened: two connections, one answer. +/// A dead pooled connection costs a retry, not the answer. `ureq` won't retry a POST, so +/// this is B2's one-shot resend, safe because no response existed. #[test] fn a_request_that_meets_a_dead_connection_is_sent_once_more() { let (url, server) = serve(vec![ @@ -168,9 +155,7 @@ fn a_request_that_meets_a_dead_connection_is_sent_once_more() { ); } -/// An HTTP refusal carries the server's own explanation ("model not found, try -/// pulling it first" is the one every Ollama user meets), so `B2_DEBUG` shows the -/// fix even though the user-facing line stays generic. +/// A refusal keeps the server's own explanation, so `B2_DEBUG` shows the fix. #[test] fn an_http_refusal_keeps_the_servers_explanation() { let body = r#"{"error":{"message":"model \"test-model\" not found, try pulling it first"}}"#; @@ -188,9 +173,8 @@ fn an_http_refusal_keeps_the_servers_explanation() { server.join().expect("server thread"); } -/// Nothing listening is [`b2_llm::LlmError::Unreachable`] — the typed error the -/// adapters turn into "is Ollama running?" (E4). Probing a port that was just -/// released is the cheapest honest way to have nothing listening. +/// Nothing listening is [`b2_llm::LlmError::Unreachable`], which adapters turn into +/// "is Ollama running?" (E4). #[test] fn probing_a_dead_endpoint_reports_it_unreachable() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); @@ -206,10 +190,8 @@ fn probing_a_dead_endpoint_reports_it_unreachable() { ); } -/// A **200** whose body isn't a model list — an HTML page, an empty object — is -/// still a reachable chat endpoint: it answered on the right path, and there is -/// simply nothing to check the model against. This is the whole of the probe's -/// tolerance, and it is bounded by *evidence* (a 2xx) rather than by hope. +/// A 200 that isn't a model list is still a reachable endpoint, with nothing to check +/// the model against. The probe's tolerance stops at a 2xx. #[test] fn probing_tolerates_a_server_that_serves_no_model_list() { let body = "not a model list"; @@ -223,14 +205,8 @@ fn probing_tolerates_a_server_that_serves_no_model_list() { server.join().expect("server thread"); } -/// The bug this replaced a tolerance to catch: `…:11434/v1X` instead of `/v1`. -/// -/// The old probe read *any* HTTP answer as "something is listening, it just may -/// not implement `/models`", so a wrong base URL came back **Connected** and the -/// configuration failed at the first question instead — the exact surprise the -/// probe exists to prevent. A refusal cannot be told from an unimplemented route -/// on this evidence, so the probe reports what it saw and lets -/// [`b2_llm::refusal_message`] give the fix. +/// A wrong base URL (`/v1X`) must not probe as connected. A refusal can't be told from an +/// unimplemented route, so the probe reports it and [`b2_llm::refusal_message`] gives the fix. #[test] fn probing_refuses_a_url_that_answers_but_isnt_a_chat_api() { let body = "404 page not found"; @@ -246,24 +222,19 @@ fn probing_refuses_a_url_that_answers_but_isnt_a_chat_api() { status, endpoint, .. } => { assert_eq!(*status, 404); - // The endpoint rides along because the sentence names it, and by the - // time an adapter prints one it no longer has the config in hand. + // The sentence names it, and the adapter no longer has the config. assert_eq!(endpoint, &url, "the refusal names what was asked"); } other => panic!("expected a refusal, got {other:?}"), } - // `Display` *is* the `B2_DEBUG` line (b2-cli's `user_message` prints - // `err.to_string()` and nothing else), so the server's own words have to be in - // it — the user-facing sentence deliberately drops them, and a field missing - // from the format is a field no adapter can reach. + // `Display` is the `B2_DEBUG` line, so the server's words must be in it. let debug = err.to_string(); assert!(debug.contains("404 page not found"), "{debug}"); assert!(debug.contains(&url), "{debug}"); server.join().expect("server thread"); } -/// A cloud endpoint with no key: the path is right, the credential isn't. Also -/// previously *Connected*, and also a failure deferred to the first question. +/// A cloud endpoint with no key: the path is right, the credential isn't. #[test] fn probing_catches_an_endpoint_that_refuses_the_key() { let body = r#"{"error":{"message":"invalid api key"}}"#; @@ -279,8 +250,7 @@ fn probing_catches_an_endpoint_that_refuses_the_key() { server.join().expect("server thread"); } -/// When it *does* serve a model list, the configured model has to be in it — -/// caught here rather than as a 404 halfway through the first question. +/// A served model list must include the configured model, caught before the first question. #[test] fn probing_catches_a_model_the_server_does_not_serve() { let body = r#"{"object":"list","data":[{"id":"llama3.2:latest"}]}"#; @@ -299,9 +269,8 @@ fn probing_catches_a_model_the_server_does_not_serve() { server.join().expect("server thread"); } -/// The tool-call cap is the configured one, and hitting it is a **typed** failure at the -/// seam — `Error::ToolCallLimit`, not the generic `Error::Llm` — so a caller that -/// degrades on "the model can't use tools" can tell this apart and refuse to hide it. +/// Hitting the configured cap is typed (`Error::ToolCallLimit`), so a caller that degrades +/// on "no tool support" can tell it apart and refuse to hide it. #[test] fn a_reply_past_the_configured_tool_call_cap_fails_with_a_typed_error() { let call = |i: usize| { @@ -327,7 +296,6 @@ fn a_reply_past_the_configured_tool_call_cap_fails_with_a_typed_error() { matches!(err, b2_core::Error::ToolCallLimit { limit: 2 }), "{err:?}" ); - // The same reply under a cap that fits it is an ordinary completion. assert_eq!(run(&capped(3)).expect("within the cap").tool_calls.len(), 3); server.join().expect("server thread"); } diff --git a/scripts/compare-embed-device.sh b/scripts/compare-embed-device.sh index d72c6db..ef1b7c2 100755 --- a/scripts/compare-embed-device.sh +++ b/scripts/compare-embed-device.sh @@ -1,18 +1,11 @@ #!/usr/bin/env bash # CPU vs Metal embedding A/B (GH #40). # -# Reindexes a vault twice — once on CPU (default build) and once on the Metal GPU -# (`--features metal`) — and reports embed throughput side by side. The `metal` cargo -# feature is a BUILD switch, so this compiles both binaries; the recorded model id gains an -# `@metal` tag on the GPU run, which the report uses to confirm Metal was actually used -# (not a silent CPU fallback). +# Reindexes a vault on CPU and on Metal (`--features metal`, a build switch, so both +# binaries are compiled) and reports embed throughput side by side. The GPU run's model id +# gains an `@metal` tag, which confirms Metal was really used. # -# Hygiene: -# - The committed fixture is NEVER mutated: each run works on an isolated copy in the -# system tempdir (same pattern the integration tests use for fixtures/golden-vault/), -# removed on exit. So no `.b2/` index ever lands in the repo. -# - The two JSONL logs go under the already-gitignored logs/ for post-hoc inspection. -# - `.gitignore` also covers an ad-hoc `b2 reindex` run against the fixture directly. +# Each run works on a tempdir copy of the vault; the JSONL logs go under gitignored logs/. # # Usage: # scripts/compare-embed-device.sh [VAULT] # VAULT defaults to fixtures/test-vault @@ -30,8 +23,7 @@ VAULT="${1:-fixtures/test-vault}" command -v python3 >/dev/null || { echo "error: python3 is required." >&2; exit 1; } [[ -d "$VAULT" ]] || { echo "error: vault not found: $VAULT" >&2; exit 1; } -# Soft check for the default model cache; a custom cache_dir in config.toml is still fine -# (reindex fails fast with the proper "run b2 init" message if the model is truly absent). +# Soft check: a custom cache_dir is fine, and reindex fails fast if the model is absent. MODELDIR="${HOME}/Library/Application Support/b2/models/BAAI_bge-base-en-v1.5" [[ -f "${MODELDIR}/model.safetensors" ]] || \ echo "note: bge model not found at the default cache; if reindex fails, run: cargo run -p b2-cli -- init" >&2 @@ -57,8 +49,7 @@ cp -R "$VAULT" "$RUN/metal" rm -rf "$RUN/cpu/.b2" "$RUN/metal/.b2" # each device does a full, from-scratch embed # --- the two runs ------------------------------------------------------------------------ -# Setting B2_LOG_FILE alone turns on the JSONL debug log (implies B2_LOG=debug); the CLI's -# stdout stays the plain "Indexed N notes" line, the file is pure JSONL. +# B2_LOG_FILE alone turns on the JSONL debug log (implies B2_LOG=debug). echo "→ CPU reindex (this is the slow one)…" B2_LOG_FILE="$CPU_LOG" cargo run -q -p b2-cli -- -C "$RUN/cpu" reindex diff --git a/scripts/doctor.sh b/scripts/doctor.sh index e18da7a..a56e607 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -1,16 +1,9 @@ #!/usr/bin/env bash -# Environment sanity check for local development — "stop 0" before `make app` works. +# Environment sanity check for local development, in the order a fresh clone hits it. +# Each check prints pass/fail/warn with the fix inline. # -# Walks the same order a fresh clone hits in the README's Build-and-run: Rust (needed for -# every recipe) -> Node/npm + the Tauri CLI (desktop app only) -> the platform's native -# webview toolchain -> optional extras (the embedding model; B2_VAULT_PATH is purely FYI — -# `make app` works with or without it via the in-app vault switcher). Each check -# prints pass/fail/warn with the fix inline, so a broken environment becomes a checklist -# instead of a scavenger hunt — this script exists because `make app` failing with -# "error: no such command: `tauri`" gives no hint that the fix is a separate `cargo install`. -# -# Usage: `make doctor` (or run directly: scripts/doctor.sh). Exits 0 if every hard -# requirement passes; warnings (optional extras) don't fail the run. +# Usage: `make doctor` (or scripts/doctor.sh). Exits 0 if every hard requirement passes; +# warnings don't fail the run. set -uo pipefail @@ -20,8 +13,7 @@ cd "$REPO" FAILS=0 WARNS=0 -# ANSI colors, no 3rd-party dep (just raw escapes) — off when stdout isn't a terminal -# (piped/redirected output) or NO_COLOR is set (https://no-color.org), so logs/files stay plain. +# ANSI colors, off when stdout isn't a terminal or NO_COLOR is set. if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m' @@ -46,9 +38,7 @@ else else warn "cargo found but not via rustup — rust-toolchain.toml (pins 1.96) won't auto-apply; if builds complain about MSRV, install rustup instead" fi - # Running cargo here resolves (and, via rustup, auto-installs) the toolchain pinned in - # rust-toolchain.toml — the same thing `make build`/`make app` would do on first run, so - # doing it here just surfaces that cost up front instead of mid-build. + # Resolves (and via rustup installs) the pinned toolchain up front instead of mid-build. if VERSION_OUT="$(cargo --version 2>&1)"; then pass "cargo resolves: $VERSION_OUT" else @@ -57,11 +47,8 @@ else fi # --- make itself (informational — you're running this via `make doctor` most likely) -------- -# No version floor: the Makefile targets GNU Make 3.81, what Apple's Xcode Command Line Tools -# ship (and has for years, since Apple won't bundle GPLv3 Make 4.x) — so it works out of the box -# on macOS with no separate install. The "Platform build toolchain" section below confirms the -# CLT are present; this check just names `make` itself in case a non-CLT `make` is on PATH first -# (e.g. a Homebrew `gmake` aliased over it) with something older or broken. +# No version floor: the Makefile targets GNU Make 3.81, what Apple's CLT ship. This names +# `make` in case a non-CLT one is first on PATH. section "make" if command -v make >/dev/null 2>&1; then pass "make found ($(make --version 2>/dev/null | head -1))" @@ -70,10 +57,8 @@ else fi # --- Node + npm (needed for ui/, the desktop frontend) --------------------------------------- -# Version floor comes from vite (ui/package-lock.json pins vite@6, engines.node = -# "^18.0.0 || ^20.0.0 || >=22.0.0") — the strictest of the frontend's deps. Note that range -# excludes the odd-numbered releases (19, 21, ...): those are Node's short-lived non-LTS -# lines, not vite-incompatible per se, but unsupported here — install an LTS line instead. +# Version floor from vite 6's engines.node: "^18.0.0 || ^20.0.0 || >=22.0.0" (so 19 and 21 +# are unsupported). section "Node.js + npm (desktop app frontend)" nvm_available() { [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]] || [[ -s "$HOME/.nvm/nvm.sh" ]] @@ -161,10 +146,8 @@ else fi # --- Optional: coverage tooling (`make coverage*`) ------------------------------------------ -# Same reason this script exists at all: `make coverage` fails with cargo's bare -# "error: no such command: `llvm-cov`", which names neither of the two things that are -# actually missing. Both are needed — the subcommand *and* the rustup component it drives -# (the component is per-toolchain, so it can be absent even when the binary is installed). +# Needs both the subcommand and its rustup component (per-toolchain, so it can be missing +# even when the binary is installed). section "Coverage tooling (optional — needed by \`make coverage\`)" if cargo llvm-cov --version >/dev/null 2>&1; then pass "cargo-llvm-cov found ($(cargo llvm-cov --version 2>/dev/null))" @@ -180,8 +163,7 @@ if command -v rustup >/dev/null 2>&1; then fi # --- Informational: B2_VAULT_PATH -------------------------------------------------------- -# Not required either way: `make app` opens the in-app vault switcher when unset. This is -# purely FYI, so it never counts as a warning. +# FYI only: `make app` opens the vault switcher when unset. section "Vault (informational)" if [[ -n "${B2_VAULT_PATH:-}" ]]; then info "B2_VAULT_PATH set: $B2_VAULT_PATH — make app will open this vault directly" diff --git a/ui/scripts/gen-icons.ts b/ui/scripts/gen-icons.ts index 8f87d1d..407d3af 100644 --- a/ui/scripts/gen-icons.ts +++ b/ui/scripts/gen-icons.ts @@ -1,29 +1,15 @@ -// The icon vendoring step: Bootstrap Icons (twbs/icons, MIT) → `src/icons.gen.ts`. +// Vendors a named subset of Bootstrap Icons (twbs/icons, MIT) into `src/icons.gen.ts`. // -// Why generate rather than depend at runtime. Three constraints meet here and only one -// shape satisfies all of them: -// -// 1. **The webview CSP is `default-src 'self'`** (crates/b2-desktop/tauri.conf.json), so -// there is no CDN option — whatever we use has to be in the bundle. -// 2. **`npm test` runs off the source through node**, not Vite (`node -// --experimental-strip-types --test src/*.test.ts`). So the obvious Vite-native move — -// `import chevron from "bootstrap-icons/icons/chevron-right.svg?raw"` — would resolve -// under `vite build` and fail under the test runner, taking every test that transitively -// imports render.ts with it. Plain TypeScript is the only form both hosts can read. -// 3. **2078 icons ship in the package; we use ~24.** Vendoring the ones we name keeps the -// bundle honest and the diff reviewable. -// -// So the package is a **devDependency** — the provenance and the upgrade path — and this -// script lifts the path data of the named subset into a checked-in `.ts` file. +// Generated rather than imported at runtime: the webview CSP is `default-src 'self'` (no +// CDN); `npm test` runs the source through node, not Vite, so `?raw` SVG imports would +// break the tests; and we use ~24 of 2078 icons. The package is a devDependency for +// provenance and upgrades. // // node --experimental-strip-types scripts/gen-icons.ts # regenerate (make icons) // node --experimental-strip-types scripts/gen-icons.ts --check # fail if stale // -// `--check` is what makes the generated file un-driftable: it runs first in `npm test` -// (so `make check` and `make ci` both carry it), and it exits non-zero — a check that -// reports a problem and exits 0 is a hole in the gate (CLAUDE.md → "Commands"). Bump the -// dependency and the check fails until you re-run the generator, which is the only -// moment upstream path data can change under us. +// `--check` runs first in `npm test`, so bumping the dependency fails the gate until the +// file is regenerated. import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -34,19 +20,15 @@ const PKG = join(HERE, "..", "node_modules", "bootstrap-icons"); const OUT = join(HERE, "..", "src", "icons.gen.ts"); /** - * The subset, grouped by what asks for it. Adding an icon is: a name here, `make icons`, - * then use it — `IconName` is `keyof typeof ICON_BODIES`, so a typo is a type error and a - * name nobody imports is dead weight the reviewer can see. + * The subset, grouped by what asks for it. To add an icon: name it here, `make icons`, use + * it (`IconName` is `keyof typeof ICON_BODIES`, so a typo is a type error). */ const MANIFEST: Record = { - // Fold state + directional chrome. `chevron-right`/`-down` are the app's one fold - // affordance (file tree, frontmatter drawer, discovery sections and cards); `-left`/`-up` - // are history back and find-previous. + // `chevron-right`/`-down` are the app's one fold affordance; `-left`/`-up` are history + // back and find-previous. chevrons: ["chevron-down", "chevron-left", "chevron-right", "chevron-up"], - // The file tree's two slots: a folder's open/closed state, and one icon per thing a row - // can be. The per-class file icons are the point of the whole change — `▣ ▶ ▤ ◇ ≡ ◆` said - // "not a note" and nothing else. + // The file tree: a folder's open/closed state, and one icon per file class. tree: [ "file-earmark-binary", "file-earmark-code", @@ -59,22 +41,17 @@ const MANIFEST: Record = { "folder2-open", ], - // Create actions (tree head) and the top bar — `chat-dots` is the chat pane's toggle - // (GH #155), which is a top-bar control beside Settings and the vault switcher. + // Create actions (tree head) and the top bar; `chat-dots` is the chat toggle (GH #155). actions: ["chat-dots", "file-earmark-plus", "folder-plus", "gear", "search", "x-lg"], - // Note-bar toggles: the connection graph and the Markdown-source view. They sit side by - // side, so they have to come from the same family. + // Note-bar toggles: the connection graph and the Markdown-source view. toggles: ["code-slash", "diagram-3"], - // Status: the anomaly badge, the graph's dangling node, a broken link's card marker, the - // "no model provisioned yet" banner, and the side pane's caveats about a list (ungraded - // discovery, raw mode) — `info-circle`, not the triangle, because those are notices - // rather than faults. + // Status: faults take the triangle; notices (ungraded discovery, raw mode) take + // `info-circle`. status: ["exclamation-triangle", "info-circle", "stars"], - // The Explain view's "?" (GH #236): the longer account of a figure, one click away - // rather than printed under it. + // The Explain view's "?" (GH #236). help: ["question-circle"], // A connection's direction, on the discovery card that names it. @@ -84,11 +61,8 @@ const MANIFEST: Record = { const NAMES = Object.values(MANIFEST).flat().sort(); /** - * An icon file's *children* — everything between `` and ``, with the - * formatting whitespace collapsed. Children rather than the whole element because the - * caller owns the wrapper: `icon()` sets the size, the class, and `aria-hidden`, and an - * icon that carried its own `` would carry upstream's `width="16"` and `class="bi"` - * into every call site. + * An icon file's children (between `` and ``), whitespace collapsed. The + * wrapper is `icon()`'s, so upstream's `width` and `class` stay out of call sites. */ export function iconBody(svg: string): string { const open = svg.indexOf(">"); @@ -109,23 +83,10 @@ function read(name: string): string { } /** - * The upstream license, reproduced verbatim. - * - * Not a formality, and not satisfied by a pointer: the path data we vendor *is* a portion of - * Bootstrap Icons, and the MIT License requires the copyright notice **and the permission - * notice** to travel with it. An earlier version of this generator cited - * `ui/node_modules/bootstrap-icons/LICENSE`, which is neither committed nor shipped — so - * anyone receiving the generated file received the icons without their terms. - * - * It is emitted **twice**, from this one source, because source and bundle are two different - * distributions and a comment only covers the first: - * - * - as a `/*!` **legal comment**, the marker esbuild (Vite's minifier) keeps when it strips - * every other comment — so the notice survives into `ui/dist` and therefore into the app; - * - as an exported **string**, which is what `icons.test.ts` asserts against. A comment is - * invisible to a test that can't read the filesystem, and `--check` alone can't cover this - * (it compares the file to what the generator emits, so a generator edited to drop the - * notice would pass). The obligation deserves a check that fails. + * The upstream license, verbatim. MIT requires the copyright and permission notice to travel + * with the vendored path data, so it is emitted twice: as a `/*!` legal comment (which + * esbuild keeps, so it reaches `ui/dist`) and as an exported string that `icons.test.ts` + * asserts (`--check` alone would pass a generator edited to drop it). */ const LICENSE_TEXT = `The MIT License (MIT) diff --git a/ui/src/api.ts b/ui/src/api.ts index 599d661..3eabd67 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,8 +1,6 @@ -// THE ONE IPC SEAM (crates/b2-desktop/CLAUDE.md). Every `invoke()` in the frontend -// lives here — the presentation-side mirror of the `Vault` façade. Keeping it in one -// module means the rest of the UI never imports Tauri directly: it can be unit-tested -// by mocking this module, and a future `serve`/HTTP transport swap touches ~this file -// only. Do not call `invoke` anywhere else. +// The one IPC seam (crates/b2-desktop/CLAUDE.md): every `invoke()` lives here, so the rest +// of the UI never imports Tauri and can be tested by mocking this module. Do not call +// `invoke` anywhere else. import { Channel, invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; @@ -38,48 +36,34 @@ import type { WriteReport, } from "./types"; -// A rejected `invoke` resolves to the host's user-facing string (CmdError serializes -// to `user_message`), so surface it directly — it's already generic and actionable. +// A rejected `invoke` carries the host's user-facing string (CmdError's `user_message`). export function errText(e: unknown): string { return typeof e === "string" ? e : e instanceof Error ? e.message : String(e); } /** - * The host's exact `WriteConflict` message — part of the IPC contract - * (crates/b2-desktop/CLAUDE.md): the frontend recognizes a save conflict by matching this - * stable constant. Pinned host-side by the `write_conflict_is_generic_and_recognizable` - * test in `b2-desktop/src/commands.rs` — change them together. + * The host's exact `WriteConflict` message, part of the IPC contract. Pinned host-side by + * `write_conflict_is_generic_and_recognizable` (`b2-desktop/src/commands.rs`); change both. */ export const WRITE_CONFLICT_MESSAGE = "This note changed on disk since it was opened. Reload the note, then reapply your edit."; -/** - * Whether an IPC rejection is the save guard refusing a stale revision. - * `startsWith`, not equality: `B2_DEBUG` appends `\n(debug: …)` to every message. - */ +/** Whether an IPC rejection is a stale-revision save. `startsWith`: `B2_DEBUG` appends detail. */ export function isWriteConflict(e: unknown): boolean { return errText(e).startsWith(WRITE_CONFLICT_MESSAGE); } /** - * The host's filesystem-watch pulse (crates/b2-desktop/CLAUDE.md / #14): the Rust watcher emits - * this event, debounced, whenever the vault's Markdown changes on disk from outside the app - * (an external editor, a `git pull`). Must equal the host's `VAULT_CHANGED_EVENT` - * (`b2-desktop/src/watch.rs`) — pinned by the `vault_changed_event_matches_the_frontend` - * test there; change both together. The pulse carries no payload: it's a bare "reconcile - * now" signal, and the frontend re-reads through the façade to see *what* changed. + * The host's debounced filesystem-watch pulse (#14): the vault's Markdown changed outside the + * app. No payload; the frontend re-reads to see what changed. Must equal the host's + * `VAULT_CHANGED_EVENT` (`b2-desktop/src/watch.rs`, pinned by a test there). */ export const VAULT_CHANGED_EVENT = "vault-changed"; /** - * A menu line of **B2's own** was chosen — the View menu's three sizes, today. The payload - * is the item's id as `crates/b2-desktop/src/menu.rs` spells it (`view.zoom-in`), which is - * the same id `menukeys.ts` mirrors. Must equal the host's `MENU_COMMAND_EVENT`; change - * both together. - * - * The host forwards rather than acts because AppKit is only where the *keystroke* lands: - * a menu accelerator never reaches the webview's keydown handler, so this event is how a - * chord the menu owns reaches the code that owns what it means (`zoom.ts`). + * One of B2's own menu lines was chosen; the payload is its id from `menu.rs` + * (`view.zoom-in`). A menu accelerator never reaches the webview's keydown, so the host + * forwards it. Must equal the host's `MENU_COMMAND_EVENT`. */ export const MENU_COMMAND_EVENT = "menu-command"; @@ -87,8 +71,7 @@ export const api = { /** The active vault root + whether semantic ranking is live (real model). */ vaultInfo: (): Promise => invoke("vault_info"), - /** Open a native folder picker to switch the active vault. Resolves to the new - * `VaultInfo`, or `null` if the user cancelled (the current vault stays put). */ + /** Native folder picker to switch vault; `null` if the user cancelled. */ chooseVault: (): Promise => invoke("choose_vault"), /** A note's body + metadata for the left pane, by vault-relative path. */ @@ -97,12 +80,10 @@ export const api = { /** Every indexed note (path, title; no body) — the file tree's source. */ listNotes: (): Promise => invoke("list_notes"), - /** Every inventoried non-`.md` file — the tree's resource half (slice 1). */ + /** Every inventoried non-`.md` file — the tree's resource half. */ listResources: (): Promise => invoke("list_resources"), - /** Every folder in the vault, empty ones included — the tree's structure half, - * read live off the filesystem (never the index) so the tree is one-to-one - * with disk in both directions. */ + /** Every folder, empty ones included, read live off disk (never the index). */ listDirs: (): Promise => invoke("list_dirs"), /** The fallback card's data: a resource's metadata + backlinks. */ @@ -110,74 +91,49 @@ export const api = { invoke("explain_resource", { path }), /** - * A resource's bytes, base64 — what the in-app viewer shows in place of the - * *No viewer available* card. This carries a copy of the whole file across the IPC, - * so ask only for what will actually be displayed; the size is on - * `explainResource`'s view before this is called. The host re-validates the path - * against the inventory. + * A resource's bytes, base64, for the in-app viewer. Copies the whole file across the IPC, + * so ask only for what will be displayed (the size is on `explainResource`'s view). */ readResource: (path: string): Promise => invoke("read_resource", { path }), - /** - * *Open in system default* — an OS handoff performed host-side (the webview holds - * no opener permission); the host validates the path against the inventory first. - */ + /** *Open in system default*, host-side (the webview holds no opener permission). */ openResource: (path: string): Promise => invoke("open_resource", { path }), /** - * *Open a web link in the system default browser* — `openResource`'s sibling for the - * links **inside** a note. The same OS handoff, and host-side for the same reason: the - * webview holds no opener permission, and following the link in place would replace - * the whole app with a web page. The host re-checks the URL's scheme against its own - * allow-list (http / https / mailto), so `ui/src/links.ts` decides what gets *routed* - * here and the host decides what actually opens. + * Open a web link from inside a note in the default browser. `links.ts` decides what is + * routed here; the host re-checks the scheme (http / https / mailto) and decides what opens. */ openExternal: (url: string): Promise => invoke("open_external", { url }), /** - * The clipboard's plain-text flavor, for the editor's ⌘⇧V (paste as plain text). - * Host-side of necessity: WebKit runs no editing command for a raw ⌘⇧V and gates a - * programmatic `navigator.clipboard` read behind a native confirmation. + * The clipboard's plain text, for ⌘⇧V. Host-side: WebKit gates a programmatic + * `navigator.clipboard` read behind a native confirmation. */ clipboardText: (): Promise => invoke("clipboard_text"), - /** Semantically-near, not-yet-linked candidates for a note — the ranked - * nearest, always served (GH #197). */ + /** Semantically near, not-yet-linked candidates for a note (GH #197). */ similar: (note: string, limit = 10): Promise => invoke("similar", { note, limit }), - /** **Explain** one Similar card (GH #236): where `candidate` stands in `anchor`'s list - * and the passage pairs behind it. Model-free; `limit` is the list length the pane - * asked `similar` for, so the rank is the card's. */ + /** Explain one Similar card (GH #236). `limit` is the pane's `similar` limit, so the + * rank matches the card's. */ explainSimilar: (anchor: string, candidate: string, limit: number): Promise => invoke("explain_similar", { anchor, candidate, limit }), - /** Hybrid keyword+semantic search across the vault, **with its evidence - * reading** (invariants.md D2, GH #202): the same rows in the same order, plus - * the query-level verdict that says whether the vault holds evidence for the - * query at all. The host returns the view whole — what the pane *shows* on - * each of `vouched`'s three states is `doSearch`'s call, in `main.ts`. */ + /** Hybrid search plus the query-level evidence verdict (invariants.md D2, GH #202). */ search: (query: string, limit = 20): Promise => invoke("search", { query, limit }), /** - * Save a note's body — Markdown-first through `Vault::write` - * (crates/b2-desktop/CLAUDE.md): a byte-honest body splice guarded by the `revision` captured at read, then - * a model-free re-projection. Rejects with `WRITE_CONFLICT_MESSAGE` when the file - * changed on disk since. (Tauri v2 maps camelCase keys to the command's snake_case - * params — `baseRevision` → `base_revision` — so no hand-written snake_case here.) + * Save a note's body via `Vault::write`, guarded by the `revision` captured at read. + * Rejects with `WRITE_CONFLICT_MESSAGE` if the file changed on disk since. */ writeNote: (note: string, body: string, baseRevision: string): Promise => invoke("write_note", { note, body, baseRevision }), /** - * Save a note's frontmatter — `writeNote`'s frontmatter sibling (GH #79): - * the raw YAML is spliced verbatim between the fences (`Vault::write_frontmatter`), - * body untouched, model-free, guarded by the same `revision` contract (rejects - * with `WRITE_CONFLICT_MESSAGE` on an external change). The host's one refusal is - * a `---` line that would end the block early and shift bytes into the body; it - * comes back as an actionable message to show inline. Everything else — including - * YAML B2 can't read — saves fine: B2 owns no line inside the block. + * Save a note's raw frontmatter YAML verbatim (GH #79), under the same `revision` guard. + * The host's one refusal is a `---` line that would end the block early. */ writeFrontmatter: ( note: string, @@ -186,55 +142,36 @@ export const api = { ): Promise => invoke("write_frontmatter", { note, frontmatter, baseRevision }), /** - * Create a new, empty note at a vault-relative path (`.md` optional; missing - * parent folders are created, like `b2 add`). Model-free like `writeNote`: the - * note is projected immediately (tree/search/graph) and its vectors fill on - * the next embed pass. + * Create an empty note (`.md` optional; missing parents created, like `b2 add`). + * Model-free: vectors fill on the next embed pass. */ createNote: (path: string): Promise => invoke("create_note", { path }), - /** - * Create a folder — a real on-disk create (missing parents included, an - * occupied target refused). A folder is user-authored vault structure, - * immediately visible to Finder, the CLI, and any sync; no index rows are - * touched. - */ + /** Create a folder on disk (missing parents included, an occupied target refused). */ createDir: (dir: string): Promise => invoke("create_dir", { dir }), /** - * Import a file from outside the vault into the folder `dir` (`""` for the root), - * from its **bytes** — the file tree's drop target. `data` is base64 (see - * `importfiles.ts`): a file dropped on the webview arrives as content, not a path, - * and Tauri's JSON IPC carries no byte array cheaply. Model-free like `createNote`: - * the file is projected immediately and any vectors fill on the next embed pass. + * Import a dropped file's bytes into `dir` (`""` for the root). `data` is base64: a drop + * arrives as content, not a path, and JSON IPC carries no byte array cheaply. */ importFile: (dir: string, name: string, data: string): Promise => invoke("import_file", { dir, name, data }), - /** - * `importFile` from a path instead of bytes — what the Import files… picker's - * selections come back as. Same op, and the bytes never cross the IPC. - */ + /** `importFile` from a path (the Import files… picker); the bytes never cross the IPC. */ importPath: (dir: string, source: string): Promise => invoke("import_path", { dir, source }), - /** - * Open the native multi-select file picker behind Import files… — the keyboard path - * to the drop gesture (K1). Resolves to the chosen absolute paths for `importPath` - * to place, or an empty list if the user cancelled. Host-side like `chooseVault`: - * the webview holds no dialog permission. - */ + /** The native file picker behind Import files… (K1); empty if cancelled. */ pickImportFiles: (): Promise => invoke("pick_import_files"), /** - * Move/rename a note to a new vault-relative path — inbound - * links are rewritten and the index re-projects. Needs the real model (the - * rewritten files re-embed), so it can reject with the "run `b2 init`" state. + * Move/rename a note, rewriting inbound links. Needs the real model (rewritten files + * re-embed), so it can reject with the "run `b2 init`" state. */ moveNote: (note: string, to: string): Promise => invoke("move_note", { note, to }), - /** `moveNote`'s resource sibling — same posture, same report shape. */ + /** `moveNote` for a resource. */ moveResource: (path: string, to: string): Promise => invoke("move_resource", { path, to }), @@ -242,13 +179,10 @@ export const api = { moveDir: (from: string, to: string): Promise => invoke("move_dir", { from, to }), - /** - * Delete a note from the vault *and* the disk. Model-free — - * inbound links dangle (surfacing as unresolved), they are never rewritten. - */ + /** Delete a note from disk. Inbound links dangle; they are never rewritten. */ deleteNote: (note: string): Promise => invoke("delete_note", { note }), - /** `deleteNote`'s resource sibling — same posture, same report shape. */ + /** `deleteNote` for a resource. */ deleteResource: (path: string): Promise => invoke("delete_resource", { path }), @@ -268,19 +202,14 @@ export const api = { ): Promise => invoke("link", { src, dst, relation, explanation }), /** - * Phase 1 of a reindex — the fast, **model-free** projection pass - * (docs/index-engine.md): notes + keyword index + graph, writing nothing - * to the vault. Once it resolves, the tree and keyword search are live; call - * `embed` to fill the vectors behind it. + * Reindex phase 1: the model-free projection (docs/index-engine.md). Once it resolves the + * tree and keyword search are live; `embed` fills the vectors. */ project: (): Promise => invoke("project"), /** - * Phase 2 of a reindex — fill the missing vectors (real model) as a cancellable - * background action. `onProgress` fires per embed batch over a typed Tauri - * `Channel` (docs/index-engine.md), determinate from the first batch; the returned - * Promise resolves with the final report (its `cancelled` flag set if - * `cancelReindex` was called mid-run). + * Reindex phase 2: fill missing vectors, cancellable. `onProgress` fires per batch; the + * report's `cancelled` is set if `cancelReindex` was called mid-run. */ embed: (onProgress: (p: ReindexProgress) => void): Promise => { const channel = new Channel(); @@ -292,13 +221,8 @@ export const api = { cancelReindex: (): Promise => invoke("cancel_reindex"), /** - * **Flow ④ — one grounded answer** (GH #151/#153/#155): condense → retrieve → assemble - * → stream → cite, behind `Vault::ask`. `onToken` fires per token over a typed Tauri - * `Channel` (the `embed` progress precedent) and the Promise resolves with the resolved - * [`AnswerView`] — the same two facts `b2 ask --json` emits as a JSONL event stream. - * - * `history` is the caller's and session-only (S4): the pane holds the conversation and - * hands it back each turn. Nothing about a chat is stored anywhere. + * One grounded answer (flow ④, `Vault::ask`), streamed per token. `history` is the + * caller's and session-only (S4): nothing about a chat is stored. */ ask: ( question: string, @@ -311,12 +235,8 @@ export const api = { }, /** - * **Why was this suggested?** — the chat turn behind a *Similar & unlinked* card's - * **Why?**. The host gathers B2's own discovery evidence for the pair (the matched - * passages, the card's rank and strength, the graph facts) and streams a grounded, - * cited explanation — delivered exactly as `ask` is, and stopped by the same - * `cancelAsk`. `limit` is the list length the pane showed, so the rank the explanation - * quotes is the card's own. + * The Similar card's **Why?**: a grounded, cited explanation streamed like `ask` and + * stopped by `cancelAsk`. `limit` is the pane's list length, so the quoted rank matches. */ whySimilar: ( anchor: string, @@ -330,35 +250,22 @@ export const api = { }, /** - * Stop the streaming answer at its next token — the pane's Esc. Cooperative: the - * in-flight `ask` resolves normally, with `cancelled` set and the partial text intact, - * so a stopped answer renders honestly rather than as a failure. + * Stop the streaming answer (Esc). The in-flight `ask` resolves normally with `cancelled` + * set and the partial text intact. */ cancelAsk: (): Promise => invoke("cancel_ask"), /** - * What the chat surface needs before a question is asked: the endpoint and model in - * force, whether that is the **Local** or the **Cloud models** configuration, and — for - * an Ollama endpoint — the native inventory behind the setup card (is the daemon up, - * what is installed, what to pull on a machine this size). - * - * Never rejects: "the daemon isn't running" is the answer the card is asking for. + * The chat configuration in force and, for Ollama, the daemon's inventory. Never rejects: + * "the daemon isn't running" is an answer the setup card needs. */ chatSetup: (): Promise => invoke("chat_setup"), /** - * Save the chat configuration and re-probe it. Adapter state, never vault or index - * state — a chat model swap costs no reindex (contrast M2). - * - * `apiKey` is held in memory for the run and, when the platform will take it, in the - * macOS Keychain — never in a plain file, and never in a way that comes back here: the - * returned setup carries `api_key_source`, not the key (`b2-desktop/src/keychain.rs` - * argues both halves). Passing `null` keeps whatever is already in force, so re-saving - * the endpoint can't silently sign you out; `""` is the explicit *clear*. - * - * `maxToolCalls` follows the key's three-state rule for the same reason — most callers - * of this save never mention it: `null` keeps the cap, `""` clears it back to the - * environment/default, a number sets it. chat.ts's `toolCapInput` decides which. + * Save the chat configuration and re-probe it (adapter state; no reindex, contrast M2). + * `apiKey` and `maxToolCalls` are three-state: `null` keeps what is in force, `""` clears, + * a value sets. The key never comes back; the setup carries `api_key_source` + * (`b2-desktop/src/keychain.rs`). */ setChatConfig: ( baseUrl: string | null, @@ -372,60 +279,38 @@ export const api = { listModels: (): Promise => invoke("list_models"), /** - * Persist the chosen embedding model into the shared config B2 reads. Returns the - * refreshed list. Selecting a *different* model is a model swap — it takes effect only - * after that model is downloaded (`b2 init`) and the vault is reindexed; this call - * just records the choice. + * Record the chosen embedding model. A different model takes effect only after it is + * downloaded (`b2 init`) and the vault is reindexed. */ setModel: (model: string): Promise => invoke("set_model", { model }), - /** - * Download + verify the currently-selected model into the shared cache — the in-app - * `b2 init`. Idempotent, network-bound (can take minutes). Resolves with the refreshed - * model list, the just-installed model now flagged `installed`. - */ + /** The in-app `b2 init`: download and verify the selected model. Can take minutes. */ provisionModel: (): Promise => invoke("provision_model"), - /** Per-model cumulative embedding time (Settings), accumulated across sessions. */ + /** Per-model cumulative embedding time across sessions (Settings). */ embedStats: (): Promise => invoke("embed_stats"), - /** The shared directory where downloaded model files are saved (shown in Settings). */ + /** Where downloaded model files are saved (Settings). */ modelsDir: (): Promise => invoke("models_dir"), - /** Compute device the embedder runs on for this build — "Metal" or "CPU" (Settings badge). */ + /** This build's embedder device, "Metal" or "CPU" (Settings badge). */ embedDevice: (): Promise => invoke("embed_device"), /** - * Every chord the app's **menu bar** takes (#119) — the host declares the menu - * (b2-desktop `menu.rs`), so this is the authority on chords the webview never sees: - * AppKit dispatches a menu key equivalent before the key window's responder chain. - * Read once, at boot, to check `menukeys.ts`'s mirror against (main.ts - * `checkMenuDrift`); everything else — the recorder's refusal of a menu chord included - * — reads that mirror, never this. + * Every chord the menu bar takes (#119), the authority on chords the webview never sees. + * Read once at boot to check `menukeys.ts`'s mirror (`checkMenuDrift`); all else reads + * the mirror. */ menuChords: (): Promise => invoke("menu_chords"), - /** - * Scale the whole window — WebKit page zoom, what ⌘= / ⌘- / ⌘0 drive (`zoom.ts`). - * The host is a pass-through here: `factor` is already a rung off the ladder, because - * the ladder and its walls are the frontend's rule, not the host's. - */ + /** WebKit page zoom (`zoom.ts`). `factor` is already a rung of the frontend's ladder. */ setZoom: (factor: number): Promise => invoke("set_zoom", { factor }), - /** - * Subscribe to the host's debounced filesystem-watch pulse (#14). `handler` fires once - * per burst of external Markdown changes; the returned promise resolves to an unlisten - * function (unused here — the subscription lives for the window's lifetime). Kept - * behind the seam like every `invoke`. - */ + /** Subscribe to the filesystem-watch pulse (#14), once per burst of external changes. */ onVaultChanged: (handler: () => void): Promise => listen(VAULT_CHANGED_EVENT, () => handler()), - /** - * Subscribe to B2's own menu lines (the View menu's sizes). `handler` gets the item's - * id; an id it doesn't recognize is not an error — the host declares the menu, so the - * honest response to a line this build doesn't know about is to ignore it. - */ + /** Subscribe to B2's own menu lines. `handler` gets the id; ignore unknown ids. */ onMenuCommand: (handler: (id: string) => void): Promise => listen(MENU_COMMAND_EVENT, (e) => handler(e.payload)), }; diff --git a/ui/src/bindings.test.ts b/ui/src/bindings.test.ts index 8512889..2af7c02 100644 --- a/ui/src/bindings.test.ts +++ b/ui/src/bindings.test.ts @@ -1,19 +1,5 @@ -// The keyboard registry (bindings.ts), pinned — and the conflict gate itself. Pure — -// no DOM — so node runs it straight off the source: `npm test`. Dependency-free like the -// others. -// -// Two jobs. The first is that B2's own chords don't conflict: `conflicts(DEFAULT_BINDINGS)` -// must be empty, and because `npm test` runs inside both `make check` and `make ci`, that -// assertion *is* the gate. The second is proving the gate can fail — a checker that has -// only ever seen a clean table is indistinguishable from one that returns `[]` -// unconditionally, so the interesting cases below are synthetic tables built to clash. -// -// The matcher gets the same treatment. What a keyboard layer actually gets wrong is -// dull and invisible: a chord that never fires because the table spells a key the way -// the docs write it rather than the way `KeyboardEvent.key` reports it, a `?` that -// demands ⇧ on top of the ⇧ the browser already applied, a ⌘-chord that also answers to -// ⌥⌘ because nobody checked `altKey`. None of that shows up until a user reports that a -// shortcut "sometimes" doesn't work. +// The keyboard registry (bindings.ts), and the conflict gate itself: `conflicts` on the +// shipped table must be empty. Synthetic tables built to clash prove the gate can fail. import { FORMATS } from "./format.ts"; import { type Binding, @@ -36,16 +22,12 @@ import { shiftDistinguishes, } from "./bindings.ts"; -/** A synthetic row. These tables exist to make a checker *fail*, so the label carries no - * meaning here — it is required by the type and named after the id to keep it honest. */ +/** A synthetic row, labelled after its id. */ function row(b: Omit): Binding { return { label: b.id, ...b }; } -/** The shipped table under its declared type. `DEFAULT_BINDINGS` is `as const` so that - * `BindingId` can be derived from it, which also means reading `.fixed` off a row that - * hasn't got one is a type error rather than `undefined` — the widening is here so the - * checks below can ask the questions a reader would. */ +/** The shipped table widened from `as const`, so optional fields like `.fixed` can be read. */ const SHIPPED: readonly Binding[] = DEFAULT_BINDINGS; let passed = 0; @@ -71,17 +53,13 @@ function press(key: string, mods: Partial = {}): KeyEventLike { // --- the table itself --------------------------------------------------------------- check("every chord in the table parses", () => { - // parseChord throws on an unknown key or modifier, so a typo — "Mod-Delete" for - // "Mod-Backspace", "Cmd+f" for "Mod-f" — fails here rather than shipping a dead chord - // that nothing reports because nothing was ever bound to notice. for (const b of DEFAULT_BINDINGS) { for (const spec of allKeys(b)) parseChord(spec); } }); check("command ids are unique", () => { - // The lookup is a Map, so a duplicated id wouldn't error — the later row would quietly - // win and the earlier command would stop responding. + // The lookup is a Map, so a duplicate would silently win over the earlier row. const seen = new Set(); for (const b of DEFAULT_BINDINGS) { assert(!seen.has(b.id), `duplicate command id: ${b.id}`); @@ -90,9 +68,6 @@ check("command ids are unique", () => { }); check("every command carries a label a human could read", () => { - // The recorder names what it is rebinding and the conflict messages name what they - // clash with, so an empty or id-shaped label is a sentence that reads like a stack - // trace — "⌘F already runs pane.discovery here." for (const b of SHIPPED) { assert(b.label.trim() !== "", `${b.id} has no label`); assert(b.label !== b.id, `${b.id}'s label is just its id`); @@ -100,11 +75,7 @@ check("every command carries a label a human could read", () => { }); check("the chords that can't be rebound are exactly the platform's own reflexes", () => { - // `fixed` is a promise about *why* something can't be moved, not a convenience list, so - // it is pinned rather than left to accumulate: every row here is ⏎/Esc in a text field, - // ⏎ on a dialog's default button, the ⏎/Space a x", arguments: "", seeded: false }]), "Looked up with B2 tools: x", @@ -230,24 +218,19 @@ test("an answer says which B2 tools it was built from", () => { test("the tool-call cap field is untouched, cleared, set, or refused — before it is sent", () => { const cap = { in_force: 64, default: 64, ceiling: 4096 }; - // Untouched: the field paints the cap in force, so saving it back unchanged must not - // *store* it — that would pin today's default over tomorrow's environment variable. + // Untouched must not store the shown cap, or it pins today's default over the env var. assert.deepEqual(toolCapInput("64", cap), { send: null }); assert.deepEqual(toolCapInput(" 64 ", cap), { send: null }); - // Cleared: back to the environment/default, which is the host's `""`. assert.deepEqual(toolCapInput("", cap), { send: "" }); assert.deepEqual(toolCapInput(" ", cap), { send: "" }); - // Set, at both ends of the range the host quoted. assert.deepEqual(toolCapInput("1", cap), { send: "1" }); assert.deepEqual(toolCapInput("4096", cap), { send: "4096" }); - // Refused here, with a sentence, rather than sent to a host that would ignore it and - // leave *Save* looking like it did nothing. + // Refused here with a sentence, not sent to a host that would ignore it. for (const bad of ["0", "4097", "-2", "6.5", "lots", "1e3", "12abc"]) { const verdict = toolCapInput(bad, cap); assert.ok("error" in verdict, `${bad} should be refused`); assert.match(verdict.error, /1 to 4096/); } - // The range is the host's, not a constant here. assert.ok("error" in toolCapInput("200", { in_force: 64, default: 64, ceiling: 100 })); }); diff --git a/ui/src/chat.ts b/ui/src/chat.ts index 4d1f5a8..a0a66d1 100644 --- a/ui/src/chat.ts +++ b/ui/src/chat.ts @@ -1,33 +1,9 @@ -// The chat pane's pure logic — no DOM, no IPC — so node runs its test straight off the -// source (`npm test`), like sidenav.ts / treenav.ts / settingstabs.ts. +// The chat pane's pure logic (no DOM, no IPC), flow ④ in the GUI (GH #155). // -// Chat is flow ④ (GH #151/#153) reaching the GUI (GH #155): ask a question, watch the -// answer stream in, click a citation to open the note it came from. Three things about it -// are decisions rather than details, and they are why this module exists at all. -// -// **Where it lives: the right column.** Chat replaces discovery there rather than taking -// the centre pane, and the reason is the citations. An answer's whole value is that you -// can go *read* the note behind it, and the centre pane is where notes are read — so chat -// beside it means a citation opens the note **without the conversation leaving the -// screen**. Chat in the centre would make every citation a choice between the answer and -// the evidence. (Search takes the column too; opening either closes the other — see -// main.ts. One column, one thing in it.) -// -// **The transcript is a row list, like discovery's.** `chatRows` emits sidenav.ts's own -// `SideRow` shape, so the pane inherits the discovery pane's whole ARIA `tree` walk — -// ↑↓ between rows, Home/End, a roving `tabindex`, and (crucially) focus restoration by row -// key across the `innerHTML` swap every streamed answer causes. Reusing the shape rather -// than the *state* is what keeps that free: `sideRows` delegates here when the pane is in -// chat mode, and everything downstream (the paint, the arrows, `paintSide`'s focus -// capture) carries on not knowing which mode it is in. A turn is a row you can land on; a -// citation is a row *under* it that ⏎ opens — the only actionable rows in the transcript, -// which is exactly K1's "a row you can see is a row you can reach". -// -// **History is session-only** (invariant S4). `chatHistory` is what the next ask carries, -// and it is derived from the transcript in memory — never persisted, not even to -// `localStorage` where the theme and the keymap live. A saved transcript would be -// B2-derived state outside the Markdown; "save this chat as a note" would be an explicit -// human-invoked export, and is not MVP (GH #151's open question 2). +// Chat lives in the right column so a citation opens its note in the centre pane without +// the conversation leaving the screen. The transcript emits sidenav.ts's `SideRow`, so it +// inherits the discovery pane's keyboard walk and focus restoration. History is +// session-only (S4): never persisted, not even to `localStorage`. import type { SideRow } from "./sidenav.ts"; import { coverage } from "./coverage.ts"; @@ -42,26 +18,20 @@ import type { } from "./types"; /** - * One entry in the transcript. A `user` message is what was typed; an `assistant` message - * is what came back — with its citations, whether it was stopped mid-answer, and, when the - * call failed outright, the generic message the host gave instead of an answer. - * - * A **failed** turn is still a turn: it stays on screen (the question is still there to - * retry) but contributes nothing to `chatHistory`, because there is no answer to carry - * forward as context. + * One transcript entry. A failed turn stays on screen but contributes nothing to + * `chatHistory`. */ export interface ChatMessage { role: "user" | "assistant"; - /** The text — the question, the answer, or "" when `error` says why there isn't one. */ + /** The question, the answer, or "" when `error` is set. */ text: string; /** Resolved `[n]` markers; empty for a user message or a failed turn. */ citations: Citation[]; - /** The stream was stopped: the text is an honest prefix, and the pane says so. */ + /** The stream was stopped; the text is a prefix. */ cancelled: boolean; - /** The B2 tools the answer was built from; absent or empty for a plain ask or a user - * message. */ + /** The B2 tools the answer was built from. */ tools?: ToolUse[]; - /** The generic, actionable failure this turn produced instead of an answer. */ + /** The host's failure message, in place of an answer. */ error?: string; } @@ -82,12 +52,8 @@ export function answerMessage(view: AnswerView): ChatMessage { } /** - * The question a click on a *Similar & unlinked* card's **Why?** puts in the transcript. - * - * Display text, and the turn a follow-up is condensed against — not the prompt. What the - * model is actually asked, and the evidence it is handed, is assembled host-side by - * `Vault::why_similar` (b2-core's `chat.rs`); this names the two notes the way the pane - * names them everywhere else, by title with the path as the fallback. + * The question a Similar card's **Why?** puts in the transcript. Display text only: the + * prompt is assembled host-side by `Vault::why_similar`. */ export function whyQuestion( candidate: { path: string; title: string | null }, @@ -98,10 +64,8 @@ export function whyQuestion( } /** - * The line under an answer that names the B2 tools it was built from, or "" when there - * were none. Each tool once, in first-use order, with the `b2_` prefix and underscores - * dropped so it reads as words. **Not escaped here**: a tool name is whatever the model - * sent, so it is untrusted like the answer, and the paint runs it through `escapeHtml`. + * The line naming the B2 tools an answer used, or "". Not escaped here: a tool name is + * untrusted model output, and the paint escapes it. */ export function toolsLine(tools: readonly ToolUse[] = []): string { const names = [...new Set(tools.map((t) => t.name.replace(/^b2_/, "").replaceAll("_", " ")))]; @@ -109,17 +73,9 @@ export function toolsLine(tools: readonly ToolUse[] = []): string { } /** - * What Settings → Chat's **Tool calls per reply** field should send with a save — the - * host's three-state rule (`apply_tool_cap`), decided here so a bad value is a sentence - * beside the field rather than a save that appears to do nothing. - * - * `{ send: null }` untouched — the field still shows the cap in force, and storing - * that would pin today's default over a later environment variable - * `{ send: "" }` cleared — back to `B2_LLM_MAX_TOOL_CALLS` or the default - * `{ send: "128" }` set - * `{ error }` refused: not a whole number in the host's range - * - * The range is `cap`'s, which the host sent; nothing about it is spelled here. + * What the **Tool calls per reply** field sends with a save, per the host's three-state + * rule (`apply_tool_cap`): `null` when untouched (storing the shown value would pin today's + * default over a later env var), `""` when cleared, the number when set, or an `error`. */ export function toolCapInput( raw: string, @@ -135,18 +91,14 @@ export function toolCapInput( return { send: String(n) }; } -/** A turn that failed — the host's generic message stands in for the answer. */ +/** A turn that failed. */ export function errorMessage(error: string): ChatMessage { return { role: "assistant", text: "", citations: [], cancelled: false, tools: [], error }; } /** - * The conversation as the next ask should see it (`b2-core`'s `ChatTurn`), oldest first. - * - * A **cancelled** answer is included, deliberately and for the reason `b2 chat` includes - * it: the human read that text, so a follow-up like "go on" has to be condensed against - * what was actually said, not against a turn we pretend never happened. A **failed** turn - * is excluded — there is nothing it said. + * The conversation as the next ask sees it, oldest first. A cancelled answer is included + * (the human read it, so "go on" must condense against it); a failed turn is not. */ export function chatHistory(messages: readonly ChatMessage[]): ChatTurn[] { return messages @@ -154,38 +106,23 @@ export function chatHistory(messages: readonly ChatMessage[]): ChatTurn[] { .map((m) => ({ role: m.role, content: m.text })); } -/** A transcript row's key — the identity that survives the pane's `innerHTML` swap. - * Position-based because two identical questions are two different rows. */ +/** A transcript row's key. Position-based: two identical questions are two rows. */ export function turnRowKey(index: number): string { return `chat:turn:${index}`; } -/** A citation row's key: its turn, its marker, and what it points at — the same - * belt-and-braces as `cardRowKey` (position makes it unique, target makes a stale key - * fail to match rather than resolve to whatever now sits there). - * - * What the row *opens* is not derived from this key: a citation paints as a `data-open` - * button, so the mouse and ⏎ share the one activation path the rest of the app uses (K1), - * and the note it names lives in the markup rather than in a lookup that could go stale - * between the paint and the keystroke. render.test.ts pins that it is a button and never - * an `href` — in-app navigation, never the webview (E5). */ +/** A citation row's key. Like `cardRowKey`, position makes it unique and the target makes + * a stale key fail to match. What it opens comes from the markup's `data-open` (E5). */ export function citationRowKey(turn: number, marker: number, path: string): string { return `chat:cite:${turn}:${marker}:${path}`; } -/** The row key of the answer currently streaming — a row like any other, so the keyboard - * can sit on it while it fills, and so focus restoration has something to name. */ +/** The streaming answer's row key, so focus can sit on it while it fills. */ export const STREAMING_ROW_KEY = "chat:streaming"; /** - * Every row the chat pane paints, in paint order: each turn, with its citations nested - * under it, then the in-flight answer if one is streaming. - * - * Emits sidenav.ts's `SideRow` so the discovery pane's walk drives this one unchanged. - * Nothing here **folds** (`fold: null`): a turn's citations are always shown with it — a - * conversation is read, not browsed. Folding and having children are different facts, - * though, and only the second one moves →, so an answer with citations still steps into - * them and ← still steps back out to the turn (sidenav.ts's `side.row.in` says so). + * Every row the chat pane paints, in order: each turn with its citations under it, then + * the streaming answer. Nothing folds, but → still steps into a turn's citations. */ export function chatRows(messages: readonly ChatMessage[], streaming: boolean): SideRow[] { const rows: SideRow[] = []; @@ -219,19 +156,8 @@ export function chatRows(messages: readonly ChatMessage[], streaming: boolean): } /** - * Which state the chat pane is in before (or between) questions — the empty-state - * selection the spec asks for, as one pure decision rather than a chain of `if`s spread - * through the paint. - * - * no-vault nothing to ground an answer in - * loading the setup probe hasn't answered yet - * no-server nothing is listening (the daemon isn't running, or the URL is wrong) - * no-model a server is there but doesn't serve the configured model - * ready chat works — the pane shows its prompt, or the conversation - * - * `unembedded` is deliberately **not** one of these: a projected-but-unembedded vault - * still answers, BM25-only (M4), so blocking on it would break a working thing. It is a - * quiet note beside the composer instead — [`retrievalNote`]. + * The chat pane's empty state. An unembedded vault is not one: it still answers, + * keyword-only (M4), so it gets a note instead ([`retrievalNote`]). */ export type ChatEmptyState = "no-vault" | "loading" | "no-server" | "no-model" | "ready"; @@ -246,45 +172,30 @@ export function chatEmptyState(s: { return "no-server"; case "model_missing": return "no-model"; - // The fake provider answers deterministically — chat "works", and the pane says - // what is answering rather than pretending a model is (`b2 chat`'s own note). case "fake": case "ready": return "ready"; } } -/** The app's side of `chatEmptyState`: the vault and the probe, read off the app state. */ +/** `chatEmptyState` read off the app state. */ export function chatStateOf(s: { vaultRoot: string | null; chatSetup: ChatSetup | null }): ChatEmptyState { return chatEmptyState({ hasVault: s.vaultRoot !== null, setup: s.chatSetup }); } -/** Can a question be asked right now? What the composer's presence and *Why?* both wait on. */ +/** Can a question be asked right now? */ export function chatReady(s: { vaultRoot: string | null; chatSetup: ChatSetup | null }): boolean { return chatStateOf(s) === "ready"; } -/** Ollama's OpenAI-compatible endpoint — the **Local** configuration's starting point, and - * the only place the frontend spells it: the Endpoint field's placeholder, and its seed - * when the user presses *Local* after typing a cloud URL. The host's - * `b2_llm::DEFAULT_BASE_URL` is the authority (it is what an unset endpoint resolves - * to) — change them together. */ +/** Ollama's OpenAI-compatible endpoint, the Local configuration's seed. Mirrors + * `b2_llm::DEFAULT_BASE_URL`; change them together. */ export const LOCAL_CHAT_ENDPOINT = "http://localhost:11434/v1"; /** - * The honest note about *retrieval* under the composer, or "" when there is nothing to - * say — the search caveat (#26) applied to chat. - * - * Chat retrieves through the same hybrid search everything else does, so an unembedded - * vault answers from keyword matches alone. That is a real difference in answer quality - * and it must be said, quietly, in place — never as a blocker (E4: chat degrading never - * degrades anything else, and a degraded chat is still chat). - * - * **It reports a state, not an activity.** A partial fraction is not evidence that a run - * is under way: it is equally the residue of a cancelled or crashed reindex, and it sits - * there until someone acts. The note used to read "while this vault embeds", which asked - * the reader to wait for something that might never happen — so it names the gap and the - * gesture that closes it, the way the search caveat and the ghost hint already do. + * The retrieval note under the composer, or "": the search caveat (#26) applied to chat, + * never a blocker (E4). It reports a state, not an activity: a partial fraction may be + * the residue of a cancelled reindex, so it names the gesture that closes the gap. */ export function retrievalNote(s: { semantic: boolean; @@ -305,41 +216,30 @@ export function retrievalNote(s: { } } -/** The one command the setup card tells a human to run, spelled here so the label, the - * copy button's payload and any message agree (`b2-llm`'s `pull_command` mirror). */ +/** The setup card's command, spelled once (mirrors `b2-llm`'s `pull_command`). */ export function pullCommand(model: string): string { return `ollama pull ${model}`; } -/** The placeholder a human substitutes into `ollama pull` — the *shape* of the command, - * shown where there is no particular model to name. Angle brackets, so it can never be - * mistaken for a model that exists; it goes through `escapeHtml` like everything else. */ +/** The `ollama pull` placeholder where no model is named; angle brackets so it can't be + * mistaken for a real model. */ export const PULL_PLACEHOLDER = ""; /** - * Where B2 sends someone who has to go and *do* something in Ollama. - * - * The quickstart, not the product page: a reader of either link has already been told - * that nothing is listening or that a model isn't there, so the page they need is the one - * carrying the install command and `ollama pull`. `b2-llm`'s `OLLAMA_INSTALL_URL` is the - * same link on the host side (it rides inside the probe's own sentence) — **change them - * together**, the way `pullCommand` mirrors `pull_command`. + * Where B2 sends someone who has to install or pull in Ollama. Mirrors `b2-llm`'s + * `OLLAMA_INSTALL_URL`; change them together. */ export const OLLAMA_QUICKSTART_URL = "https://docs.ollama.com/quickstart"; -/** Ollama's hosted models — the **Cloud models** configuration's one concrete example, - * offered where that decision is made. Named rather than defaulted to: picking a - * provider is the explicit act M5 is about, so this is a link, never a pre-filled URL. */ +/** Ollama's hosted models, offered as a link, never a pre-filled URL (M5). */ export const OLLAMA_CLOUD_URL = "https://docs.ollama.com/cloud"; -/** An installed model's one-line detail — its parameter label and size, whichever it - * has ("3.2B · 2.0 GB") — as both the setup card and the Settings picker print it. */ +/** An installed model's detail, e.g. "3.2B · 2.0 GB". */ export function modelDetail(m: OllamaModel): string { return [m.parameters ?? "", formatModelSize(m.size)].filter(Boolean).join(" · "); } -/** A model's on-disk size, for the installed list. Whole GB past a gigabyte, one decimal - * below — an inventory line, not a measurement. */ +/** A model's on-disk size, for the installed list. */ export function formatModelSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return ""; const gb = bytes / 1_073_741_824; diff --git a/ui/src/chatview.ts b/ui/src/chatview.ts index 38a6ffc..181a73c 100644 --- a/ui/src/chatview.ts +++ b/ui/src/chatview.ts @@ -23,23 +23,13 @@ import { sideTab } from "./widgets.ts"; // --- chat (flow ④, GH #151/#153/#155) ----------------------------------------------- // -// The right column's third mode: ask a question, watch the answer stream, click a -// citation to open the note behind it — *without* the conversation leaving the screen, -// which is why chat lives here rather than in the centre pane (chat.ts's header). +// The right column's chat mode, so a clicked citation opens beside the conversation. // -// Three rules this builder holds, all of them invariants rather than styling: -// -// • **E5 — model output is untrusted content.** An answer is a string from a model that -// was itself fed note content (which anyone can author), so it renders through the one -// sanitizing `renderMarkdown` seam like every other document. The *streaming* half is -// stronger still: it is written as `textContent` by `paintChatStream` (main.ts), so a -// half-arrived answer is never parsed as markup at all. -// • **A citation navigates in-app, never the webview.** Each one is a `data-open` button -// — the same delegation search results and discovery cards use — so the mouse and ⏎ -// share one activation path (K1), and no `href` ever exists to be followed. -// • **K1 — the pane is keyboard-complete.** Rows are `role="treeitem"` over chat.ts's -// row order (the same order the arrows walk), with a roving tabstop; every control has -// a stable `id` so `paintSide` can hand focus back across the repaint each token causes. +// • E5: an answer is untrusted, so it renders through the sanitizing `renderMarkdown` +// seam; the streaming half is written as `textContent` (`paintChatStream`), never markup. +// • A citation is a `data-open` button, never an `href`, so it navigates in-app (K1). +// • K1: rows are `role="treeitem"` in chat.ts's row order with a roving tabstop; every +// control has a stable `id` so focus survives the per-token repaint. export function chatPaneHtml(state: AppState, roving: string | null): string { const setup = state.chatSetup; const streaming = state.chatStreaming !== null; @@ -55,14 +45,11 @@ export function chatPaneHtml(state: AppState, roving: string | null): string { state.chatMessages.length === 0 || streaming ? " disabled" : "" } data-chat-new title="Start a new conversation — nothing here is saved">new `; - // No composer until there is something to answer with: a disabled field under a card - // that says why is chrome with nothing behind it, and one more stop for a keyboard user - // to walk past on the way to the fix the card is pointing at. + // No composer until chat is ready: a disabled field is just another Tab stop. return head + chatStageHtml(state, roving) + (chatReady(state) ? chatComposerHtml(state) : ""); } -/** The conversation, or the state that stands in for one (chat.ts's `chatEmptyState` - * makes that choice once, so the paint doesn't re-derive it branch by branch). */ +/** The conversation, or the state that stands in for one (chosen by `chatStateOf`). */ function chatStageHtml(state: AppState, roving: string | null): string { switch (chatStateOf(state)) { case "no-vault": @@ -97,9 +84,8 @@ function chatLogHtml(state: AppState, roving: string | null): string { const turns = state.chatMessages .map((m, i) => chatTurnHtml(m, i, roving)) .join(""); - // The in-flight answer is a row of its own so the keyboard can sit on it while it - // fills. `#chat-stream` is the element `paintChatStream` writes tokens into — as text, - // never markup — which is what keeps a streaming answer off the full-render path. + // The in-flight answer is its own row, so the keyboard can sit on it. `paintChatStream` + // writes tokens into `#chat-stream` as text, off the full-render path. const live = state.chatStreaming === null ? "" @@ -140,8 +126,7 @@ function chatTurnHtml(m: ChatMessage, index: number, roving: string | null): str const stopped = m.cancelled ? `

Stopped — this answer is partial.

` : ""; - // Which B2 tools the answer was built from (a *Why?* turn). Tool names are the - // model's own words, so they are escaped like every other value in the chrome (E5). + // Which B2 tools built the answer (a Why? turn). Model-chosen names, so escaped (E5). const line = toolsLine(m.tools); const used = line ? `

${escapeHtml(line)}

` : ""; const cites = m.citations @@ -168,16 +153,9 @@ function chatTurnHtml(m: ChatMessage, index: number, roving: string | null): str } /** - * The composer. A ` `; } - // Every chip here carries a stable `id`: pressing one *is* a note-pane repaint, and the - // `innerHTML` swap destroys the button under the keyboard — the id is the identity - // `paintNote` puts focus back by (GH #91, crates/b2-desktop/CLAUDE.md). Each is unique - // because the pane paints exactly one of its three modes at a time. + // Every chip carries a stable `id`: pressing one repaints the pane, and `paintNote` + // restores focus by id (GH #91). return `
` : `

No notes link to this file yet.

`; const name = baseName(r.path); - // The alt text is the filename: the `

` right above already says it, so a screen - // reader that reads both is repeating itself rather than being told nothing — and B2 - // has no description of the picture to offer that would be truer than its name. + // Alt text is the filename: B2 has no truer description of the picture. const viewer = image ? `${escapeHtml(name)}` : `

No viewer available for this file type yet.

`; @@ -511,41 +428,28 @@ export function notePaneHtml(state: AppState): string { } /** - * The right column: search results, or the open note's discovery. - * - * Its rows follow the file tree's ARIA `tree` pattern (K1, GH #78) — `role="treeitem"`, - * `aria-level`, and a **roving `tabindex`**, so the pane is one Tab stop and ↑↓→← move - * within it. Like the tree, the DOM is flat: rows are siblings and `aria-level` is what - * tells a screen reader that a card sits under its section head, so the `.cards` - * wrappers are `role="none"` rather than groups. The row keys come from sidenav.ts, - * which both this paint and the arrow keys derive from the same state — the one place - * their order can't drift apart. + * The right column: search results, or the open note's discovery. Follows the tree's ARIA + * pattern (K1, GH #78): flat rows, `aria-level`, roving `tabindex`, `.cards` wrappers as + * `role="none"`. Row keys come from sidenav.ts, shared with the arrow keys. */ export function sidePaneHtml(state: AppState): string { const roving = rovingSideKey(sideRows(state), state.sideFocus); - // Chat owns the column outright when it's open (chat.ts's header says why it lives - // here at all): one column, one thing in it, and opening chat or running a search - // closes the other (main.ts). + // Chat owns the column when open; opening chat or running a search closes the other. if (state.chatOpen) return chatPaneHtml(state, roving); return state.searchQuery ? searchSectionHtml(state, roving) : discoverySectionHtml(state, roving); } -// The honest search-ranking caveat (#26). Search always answers over the keyword (BM25) -// index; this says how much *semantic* ranking is mixed in, so a projected-but-unembedded -// vault never silently under-ranks: -// • no real model → "keyword only (run `b2 init`)" -// • model, nothing embedded → "keyword-only for now (0/M embedded — Reindex)" -// • model, partly embedded → "keyword-first (N/M embedded)" (vector half still filling) -// • model, fully embedded → "" (ranking is fully semantic; no caveat) +// The search-ranking caveat (#26): how much semantic ranking is mixed into keyword search, +// so an unembedded vault never silently under-ranks. function searchCaveat(state: AppState): string { const c = coverage(state); if (!c.model) return " · keyword only (run b2 init for semantic)"; switch (c.embedded) { case "empty": case "all": - return ""; // empty vault, or every note embedded — semantic is live + return ""; case "none": return ` · keyword-only for now (0/${c.m} embedded — Reindex)`; case "partial": @@ -553,16 +457,9 @@ function searchCaveat(state: AppState): string { } } -// The install banner — the prominent, persistent counterpart to the small search caveat -// above (#26). On a fresh install with no model, the vault still gets its keyword index, -// but embedding is silently skipped (`autoIndexOnOpen` bails on `!semantic`), so semantic -// ranking and discovery are off with almost no visible sign — the reported gap. This -// surfaces that state as a dismissible strip under the top bar, pointing at Settings → -// Download. Gating is the pure, tested `shouldPromptEmbedInstall`; the controls are wired -// in main.ts: -// • Open Settings → opens the model picker + Download (the in-app `b2 init`) -// • ✕ → hide for this session (returns next launch — a gentle nag) -// • Don't remind me again → persist the opt-out (a keyword-only user, for good) +// The install banner (#26): with no model installed, semantic search and discovery are off +// with little visible sign, so say so. ✕ hides it for the session; the checkbox opts out +// for good. export function embedBannerHtml(state: AppState): string { const show = shouldPromptEmbedInstall({ hasVault: state.vaultRoot !== null, @@ -591,9 +488,8 @@ export function embedBannerHtml(state: AppState): string {

`; } -// Search mode. `clear` is the one focusable in this pane that is *not* a row, so it carries -// a stable `id` — `paintSide` restores a row by its row key and everything else by id, and -// without one the button was dropped to `` by any repaint (GH #91). +// `clear` is the pane's one non-row focusable, so it needs a stable `id` for `paintSide` +// to restore focus (GH #91). function searchSectionHtml(state: AppState, roving: string | null): string { const head = `

Results

@@ -602,7 +498,6 @@ function searchSectionHtml(state: AppState, roving: string | null): string {

for “${escapeHtml(state.searchQuery)}”${searchCaveat(state)}

`; if (state.loading) return head + `

Searching…

`; if (state.searchResults.length === 0) return head + searchEmptyHtml(state); - // A result card is the whole button, so it *is* the row — no fold, one level. const items = state.searchResults .map((r, i) => { const key = cardRowKey("search", i, r.path); @@ -621,17 +516,9 @@ function searchSectionHtml(state: AppState, roving: string | null): string { ); } -// The search pane's two empty states — the same blank list, two different reasons, -// and saying so is the whole of D2's honesty on this surface (GH #202). -// -// • `searchVouched === false` — the vault holds no evidence for this query: no -// lexical anchor, and nothing near enough by meaning at the active model's -// calibrated bar. The engine had rows and `doSearch` dropped them, strictly, so -// there is nothing to reveal and nothing to count. The copy says "no matches" -// because that is a claim about the *query*, which is the claim we can support. -// • anything else — the list is simply empty (an unbuilt index, a `null` verdict -// with nothing retrieved). That is not a judgment about the query, so the copy -// doesn't make one. +// The two empty states (D2, GH #202): `searchVouched === false` means the vault holds no +// evidence for the query, so the copy says so; otherwise the list is just empty (e.g. an +// unbuilt index) and the copy makes no claim about the query. function searchEmptyHtml(state: AppState): string { return state.searchVouched === false ? `

No matches. Nothing in this vault matches “${escapeHtml( @@ -651,10 +538,8 @@ function discoverySectionHtml(state: AppState, roving: string | null): string { )}${similarSectionHtml(state, roving)}

`; } -// A collapsible discovery-section header (chevron + title + count) — the same fold -// idiom the file tree and the Frontmatter drawer use, so the right column reads the -// same way. Collapsing is a sticky viewing preference (`collapsedSections`); the count -// is shown only when non-zero so an empty section stays quiet. +// A collapsible discovery-section header. Collapsing is sticky (`collapsedSections`); the +// count shows only when non-zero. function sideFoldHead( section: SideSection, label: string, @@ -675,14 +560,9 @@ function sideFoldHead( `; } -// The card's own fold chevron. Cards default expanded (the snippet is the signal you -// link on); this collapses the body (path + snippet) to just the title row. Kept out of -// the `.card-open` button so a click on the chevron folds without opening the note -// (nested buttons aren't allowed — the chevron and the open-region are siblings). -// -// A **mouse** affordance only, hence `tabindex="-1"` + `aria-hidden`: the row it sits on -// is the treeitem, it carries the `aria-expanded` a screen reader reads, and →← is the -// keyboard's fold (K1) — exactly as the file tree's caret is a span, not a tab stop. +// The card's fold chevron, a sibling of `.card-open` so folding doesn't open the note. +// Mouse-only (`tabindex="-1"`, `aria-hidden`): the row carries `aria-expanded` and →← folds +// (K1). function cardFold(key: string, collapsed: boolean): string { return ` `; - // *Why?* hands the pair to chat (main.ts's `askWhy`): the snippet above is the - // passage that matched, and this is the question it raises. `tabindex="-1"` keeps - // the card one Tab stop; the keyboard reaches the same action through the card - // menu's *Why was this suggested?* (⇧F10 — K1). - // - // `data-card-path`/`-title` on the root feed the right-click menu (Open / Link…); - // the whole card is the target now that the inline Link button is gone. The card is - // also the keyboard's *row* (`data-side-row`), which is why the whole box — title, - // path, and snippet — is what a screen reader reads and what the ring wraps. - // - // Draggable **only while the note is being edited** (droplink.ts): the drop lands a - // `[[wikilink]]` in the buffer, so with no buffer open there is nowhere for the drag - // to go, and an affordance that starts a drag nothing can accept is a lie the OS - // cursor has to walk back. The `title` says what the gesture does, since a drag is - // the one affordance with no visible label; the keyboard's half of it is the card - // menu's *Insert link at cursor* (⇧F10 — K1), below. + // The action buttons are `tabindex="-1"` to keep the card one Tab stop; the card + // menu (⇧F10, K1) reaches them. `data-card-path`/`-title` feed the right-click menu. + // Draggable only while editing (droplink.ts): with no buffer, nothing can accept it. return `
{ @@ -898,9 +749,7 @@ function unresolvedCardsHtml(state: AppState, roving: string | null): string { .join(""); } -/** One menu row. `chord` names the direct shortcut where one exists — a menu is where - * a keyboard user *learns* the chord that lets them skip the menu next time — so it - * comes out of the live registry (`displayKeys`), never spelled here. */ +/** One menu row. `chord` is the direct shortcut, from the live registry (`displayKeys`). */ function contextItemHtml(attr: string, label: string, chord = "", danger = false): string { const hint = chord ? `${escapeHtml(chord)}` : ""; return ``; } -// The right-click menu — one overlay, two surfaces (state.ts `ContextMenuState`): -// a discovery card (Open note / Link…, replacing the old inline "Link…" button) or -// the file tree (New note / New folder in the folder under the cursor, named in a -// muted context line). Anchored at the cursor via inline left/top — the coords are -// set + clamped on-screen in main.ts, and are plain numbers, so no escaping is -// needed. Rendered into its own overlay root so it floats above the panes; an -// outside click / Escape / scroll dismisses it (main.ts). +// The right-click menu for a discovery card or the file tree (state.ts `ContextMenuState`), +// anchored at the cursor. The coords are numbers clamped in main.ts, so need no escaping. export function contextMenuHtml(state: AppState): string { const m = state.contextMenu; if (!m) return ""; let items: string; if (m.kind === "tree") { - // Over a concrete row the menu targets that node (Rename / Move… — renaming - // acts on the file path, never a frontmatter title); the create pair keeps - // targeting the folder context either way. - // - // The two copy items are the row's *read* actions, sitting between the ones - // that change the file and the one that destroys it (Delete stays last, where - // a destructive item belongs). They copy the two paths that name this row — - // vault-relative and absolute (copypath.ts says why both) — and they are the - // only way to get either: the context line above shows the vault path but is - // muted text, and the system path is nowhere in the UI at all. + // Over a row the menu targets that node; the create items target the folder context. + // Delete stays last. The copy items are the only way to get either path (copypath.ts). const node = m.node ? `
${escapeHtml(m.node.path)}
${contextItemHtml("data-ctx-rename", "Rename", displayKeys(["tree.rename"]))} @@ -939,36 +775,25 @@ export function contextMenuHtml(state: AppState): string { ${contextItemHtml("data-ctx-delete", "Delete", displayKeys(["delete.focused"]), true)} ` : `
${escapeHtml(m.dir ? `${m.dir}/` : "vault root")}
`; - // Import files… is the drop gesture's keyboard half (K1): dragging a file in from - // Finder is a pointer-only action, so the same import runs from here — via ⇧F10, - // the keyboard's right-click — with an OS picker instead of a drag. It targets the - // folder context like the create pair, so it reads as the third way to put - // something in this folder. + // Import files… is the Finder drop's keyboard half (K1). items = `${node} ${contextItemHtml("data-ctx-new-note", "New note", displayKeys(["tree.new-note"]))} ${contextItemHtml("data-ctx-new-folder", "New folder", displayKeys(["tree.new-folder"]))} ${contextItemHtml("data-ctx-import", "Import files…")}`; } else { - // *Insert link at cursor* is the card drag's keyboard half (K1), the same shape - // *Import files…* is the Finder drop's: dragging a card onto a line is pointer-only, - // so the identical insertion runs from here — via ⇧F10 — aimed at the line the caret - // is already on. Only while editing, because that is the only time there is a buffer - // to insert into; the drag is withheld on the same condition (the card's `draggable`). + // *Insert link at cursor* is the card drag's keyboard half (K1); only while editing. items = `${contextItemHtml("data-ctx-open", "Open note", "⏎")} ${state.editing ? contextItemHtml("data-ctx-insert", "Insert link at cursor") : ""} ${contextItemHtml("data-ctx-link", "Link…")} ${contextItemHtml("data-ctx-explain", "Explain this suggestion")} ${contextItemHtml("data-ctx-why", "Why was this suggested?")}`; } - // `tabindex="-1"` on the menu itself makes the container focusable-by-script but not - // by Tab: main.ts moves focus to the first item on open and traps ↑↓/⏎/Esc inside. + // main.ts focuses the first item on open and traps ↑↓/⏎/Esc inside. return ``; } -/** The Move… modal: pick a destination folder for the targeted tree node. Every - * folder the tree knows renders as a row; an invalid destination (the node's - * current folder, or a folder inside the folder being moved) renders disabled - * with the reason, so the modal teaches the same rule the host enforces. */ +/** The Move… modal. An invalid destination renders disabled with the reason, teaching the + * rule the host enforces. */ function moveModalHtml(state: AppState): string { const t = state.moveTarget; if (!t) return ""; @@ -996,9 +821,7 @@ function moveModalHtml(state: AppState): string {
`; } -/** The folder-delete confirm — the one destructive gesture that asks first: a - * whole subtree (unindexed files included) leaves the disk. Files delete - * without a dialog; the tree gesture itself is the intent. */ +/** The folder-delete confirm, the one delete that asks first: a whole subtree leaves disk. */ function deleteModalHtml(state: AppState): string { const t = state.deleteTarget; if (!t) return ""; @@ -1015,10 +838,8 @@ function deleteModalHtml(state: AppState): string { `; } -// The overlay layer, in precedence order (main.ts's `currentOverlay` ranks them the same -// way). Settings is the odd one out since it went full-window — same `role="dialog"` and -// the same trap, no backdrop and no box — so it is first here for the reason it is first -// there: it paints *over* a Move/Delete/Link target left set behind it. +// The overlay layer, in the same precedence as main.ts's `currentOverlay`. Settings is +// first: it paints over a Move/Delete/Link target left set behind it. export function modalHtml(state: AppState): string { if (state.settingsOpen) return settingsScreenHtml(state); if (state.moveTarget) return moveModalHtml(state); @@ -1029,9 +850,8 @@ export function modalHtml(state: AppState): string { const opts = RELATION_VERBS.map( (v) => ``, ).join(""); - // The backdrop carries no cancel attr (a click on it closes only when it is the - // exact target — see main.ts); the Cancel button uses `data-cancel`. This keeps a - // click *inside* the modal from bubbling into an accidental close. + // The backdrop carries no cancel attr, so a click inside the modal can't bubble into a + // close (main.ts closes only when the backdrop is the exact target). return `