diff --git a/ADRs/0001-design-docs-are-normative.md b/ADRs/0001-design-docs-are-normative.md index 5975c40..a23165c 100644 --- a/ADRs/0001-design-docs-are-normative.md +++ b/ADRs/0001-design-docs-are-normative.md @@ -1,7 +1,7 @@ # ADR-0001 — Design docs are normative; the code is a projection of them - **Status:** Accepted · 2026-06-28 -- **Refs:** `design/invariants.md` +- **Refs:** `docs/invariants.md` ## Context @@ -10,7 +10,7 @@ redefine what the product promises. Prose that merely *describes* the code canno ## Decision -- `design/invariants.md` is the **normative register** — one testable claim per entry, cited +- `docs/invariants.md` is the **normative register** — one testable claim per entry, cited by id (S2, G2, D1…). **On conflict with any other doc or with the code, the register wins** and the other side gets fixed. - `data-model.md` is the *what*, `index-engine.md` the *how*. Code comments cite them by section. diff --git a/ADRs/0013-model-quality-is-measured-out-of-ci.md b/ADRs/0013-model-quality-is-measured-out-of-ci.md index abe902b..eaf14ed 100644 --- a/ADRs/0013-model-quality-is-measured-out-of-ci.md +++ b/ADRs/0013-model-quality-is-measured-out-of-ci.md @@ -1,7 +1,7 @@ # ADR-0013 — Model quality is measured out of CI, by a labelled harness - **Status:** Accepted · 2026-07-13 -- **Refs:** invariants E2 · `crates/b2-embed/evals/README.md` (the process rules) · GH #44, #141, #187 +- **Refs:** invariants E2 · `docs/evals.md` (the process rules) · GH #44, #141, #187 ## Context diff --git a/ADRs/README.md b/ADRs/README.md index 6a47b39..38145c5 100644 --- a/ADRs/README.md +++ b/ADRs/README.md @@ -7,7 +7,7 @@ an ADR. Feature history lives in [GitHub Issues](https://github.com/AlteredCraft **Keep them terse** — Context / Decision / Consequences, a page at most. When a decision is overturned, mark the old ADR `Superseded by ADR-NNNN` and write a new one; never edit history. -Normative detail lives in `design/invariants.md` (the register — it wins on conflict); these +Normative detail lives in `docs/invariants.md` (the register — it wins on conflict); these records say *why* an entry reads the way it does. | # | Decision | diff --git a/CLAUDE.md b/CLAUDE.md index a3ee53d..750604d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,11 @@ constantly (`data-model.md §2`, `index-engine.md §6`, invariant ids like `S2`, | Source | Role | |---|---| -| [`design/invariants.md`](design/invariants.md) | The **invariant register** — the normative list of what must always be true, cited by id. **On conflict with any other doc, it wins.** | -| [`design/data-model.md`](design/data-model.md) | The *what*: note + connection in Markdown, the two storage tiers, the relation vocabulary. | -| [`design/index-engine.md`](design/index-engine.md) | The *how*: the SQLite (FTS5 + in-process vector scan) projection, table DDL, data flows. | +| [`docs/invariants.md`](docs/invariants.md) | The **invariant register** — the normative list of what must always be true, cited by id. **On conflict with any other doc, it wins.** | +| [`docs/data-model.md`](docs/data-model.md) | The *what*: note + connection in Markdown, the two storage tiers, the relation vocabulary. | +| [`docs/index-engine.md`](docs/index-engine.md) | The *how*: the SQLite (FTS5 + in-process vector scan) projection, table DDL, data flows. | | [`ADRs/`](ADRs/README.md) | **Architecture Decision Records** — why each of the above reads the way it does. Key architectural choices only, terse. Add one when a decision is expensive to reverse and its *why* isn't readable off the code; do **not** add one per feature or bug. | -| [`crates/b2-embed/evals/README.md`](crates/b2-embed/evals/README.md) | The **eval suite guide** — every instrument, how to read its output, the exit gate, the verdict record, and the **process rules**; read before touching the corpus, the labels, or the metrics. | +| [`docs/evals.md`](docs/evals.md) | The **eval suite guide** — every instrument, how to read its output, the exit gate, the verdict record, and the **process rules**; read before touching the corpus, the labels, or the metrics. | | [GitHub Issues](https://github.com/AlteredCraft/B2/issues) | Backlog and planned work. Decision history = the issue that drove a verdict + the commit that shipped it. | ## Commands diff --git a/Cargo.toml b/Cargo.toml index 8ab07d2..64fdc5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,9 @@ members = [ ] # B2 — local-first Markdown PKM with AI connection discovery. -# Design lives in design/; this workspace is the index engine -# (design/index-engine.md), built step 0→5 against the -# golden-vault fixtures in fixtures/golden-vault/ (design/data-model.md §8). +# 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). # 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 @@ -19,7 +19,7 @@ members = [ # 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 (design/index-engine.md), so a slow batch made the +# 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. diff --git a/Makefile b/Makefile index 510ed5a..27c14d6 100644 --- a/Makefile +++ b/Makefile @@ -250,7 +250,7 @@ coverage-app: ui-build ## Coverage for the desktop host's own unit tests. # *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). # -# **crates/b2-embed/evals/README.md is the guide**: what each instrument measures, how to +# **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. diff --git a/README.md b/README.md index f09366f..6915e36 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ explained connections between them yourself. > the fake stays the CI default). **Connection discovery** ships as **`b2 similar`** (surface the > nearest *unlinked* notes — local, free, no model call) **+ `b2 link`** (you commit a typed relation > to frontmatter) — the human is the precision gate; there is no LLM in the loop. A tour -> grounded in the test suite: [docs/architecture.html](docs/architecture.html). +> grounded in the test suite: [docs/architecture.md](docs/architecture.md). > > **Grounded chat is live in the CLI** — **`b2 ask "…"`** and **`b2 chat`** answer questions *from your > notes*, streaming, with `[n]` citations back to the notes the answer came from @@ -53,12 +53,12 @@ connected yet** — so the structure of your knowledge grows as you link them, i The files stay plain Markdown on your disk, yours forever; B2 is the **intelligence layer over them, not a container around them**. Humans and AI agents are both first-class users. -Full motivation, scope, and locked decisions: **[design/invariants.md](design/invariants.md)**. +Full motivation, scope, and locked decisions: **[docs/invariants.md](docs/invariants.md)**. ## How we build it Two architectural tenets shape every decision (full text: -[design/invariants.md](design/invariants.md)): +[docs/invariants.md](docs/invariants.md)): - **A volatile vault over a disposable index.** Refactor fearlessly — move, split, merge, compress, trim orphans. The index is a pure projection of your vault (drop it, rebuild it identical); @@ -70,26 +70,26 @@ Two architectural tenets shape every decision (full text: …in service of five product non-negotiables — plain-Markdown source of truth · local-first · zero lock-in · AI-native (not bolted-on) · single binary -([design/invariants.md](design/invariants.md)). +([docs/invariants.md](docs/invariants.md)). ## The docs -### HTML guides — [alteredcraft.github.io/B2](https://alteredcraft.github.io/B2/) - -New here? Start with the **[Quick start](https://alteredcraft.github.io/B2/quickstart.html)** — set up -and work with a vault in about ten minutes. Then go deeper: -[system architecture](https://alteredcraft.github.io/B2/architecture.html) · -[indexing pipeline](https://alteredcraft.github.io/B2/indexing.html) · -[the retrieval deep dive](https://alteredcraft.github.io/B2/retrieval.html) · -[search & similarity, in plain language](https://alteredcraft.github.io/B2/search-and-similarity.html). +Everything lives in [docs/](docs/README.md) — one page per topic, and that page is the map. +New here? Start with the **[Quick start](docs/quickstart.md)** — set up and work with a vault +in about ten minutes. Then go deeper: +[architecture](docs/architecture.md) · +[search & similarity, in plain language](docs/search-and-similarity.md). | Doc | What it owns | |---|---| -| [design/invariants.md](design/invariants.md) | The **invariant register** — the one-page normative list of what must always be true, and the source of *why*, cited by id. On conflict with any other doc, it wins. | -| [design/data-model.md](design/data-model.md) | What a **note** and a **connection** are, in plain Markdown · the two storage tiers · the relation vocabulary · the invariant *definitions*. The canonical *what*. | -| [design/index-engine.md](design/index-engine.md) | How the derived index is *built* — SQLite (FTS5 + an in-process vector scan) as a disposable projection. The canonical *how*. | +| [docs/invariants.md](docs/invariants.md) | The **invariant register** — the one-page normative list of what must always be true, and the source of *why*, cited by id. On conflict with any other doc, it wins. | +| [docs/data-model.md](docs/data-model.md) | What a **note** and a **connection** are, in plain Markdown · the two storage tiers · the relation vocabulary · the invariant *definitions*. The canonical *what*. | +| [docs/index-engine.md](docs/index-engine.md) | How the derived index is *built* and queried — SQLite (FTS5 + an in-process vector scan) as a disposable projection, and the four flows over it. The canonical *how*. | +| [docs/quickstart.md](docs/quickstart.md) | Set up and use B2: the walkthrough, the command reference, config and every environment variable. | +| [docs/architecture.md](docs/architecture.md) | How the system is built: the crates, the flows, the seams, the tests. | +| [docs/search-and-similarity.md](docs/search-and-similarity.md) | What search and the related-notes panel do, in plain language, for everyone who uses B2. | | [ADRs/](ADRs/README.md) | **Architecture Decision Records** — one terse record per key architectural choice: the context, the ruling, and what it costs. The *why* behind the register's entries. | -| [crates/b2-embed/evals/README.md](crates/b2-embed/evals/README.md) | The **eval suite guide** — every instrument and how to read it, corpora, labels, the exit gate, process rules, and the record of every measured verdict. Lives beside the corpus it governs. | +| [docs/evals.md](docs/evals.md) | The **eval suite guide** — every instrument and how to read it, corpora, labels, the exit gate, process rules, and the record of every measured verdict. | Planned work and the backlog live in [GitHub Issues](https://github.com/AlteredCraft/B2/issues); shipped build history lives in git. @@ -172,4 +172,4 @@ Point B2 at a vault with `-C ` (a.k.a. `--vault`) on any command, or set ` every command finds it without the flag (an explicit `-C` wins). Read-only commands (`search`, `neighbors`, …) fall back to the current dir; commands that write (`reindex`, `add`, `mv`, `link`) require an explicit vault and refuse otherwise, so they can't silently touch the wrong place. Full walkthrough: -**[Quick start](https://alteredcraft.github.io/B2/quickstart.html)**. +**[Quick start](docs/quickstart.md)**. diff --git a/crates/b2-desktop/CLAUDE.md b/crates/b2-desktop/CLAUDE.md index 4a3772a..8f8bb89 100644 --- a/crates/b2-desktop/CLAUDE.md +++ b/crates/b2-desktop/CLAUDE.md @@ -40,7 +40,7 @@ what makes that architecture pay off: - **Inherited tests.** A thin host means the façade's existing suite already covers the behavior; this crate needs only a few per-command tests (args in → right façade call → view out). Logic here would need its own parallel tests that the CLI already has. -- **The promise stays true.** [invariants.md](../../design/invariants.md) (E3) says the GUI is "a +- **The promise stays true.** [invariants.md](../../docs/invariants.md) (E3) says the GUI is "a second dumb adapter over the same contract, inheriting every test the CLI bought." That is only true while this crate stays dumb. Thinness is not tidiness; it's the load-bearing property. @@ -108,7 +108,7 @@ add a UI concern to `b2-core`, that's the signal you're putting logic in the wro ## The keyboard contract (invariant K1) -[invariants.md](../../design/invariants.md) **K1** — *B2 is fully operable from the keyboard; the +[invariants.md](../../docs/invariants.md) **K1** — *B2 is fully operable from the keyboard; the mouse is an accelerator, never a requirement* — names this file as its elaboration home. This is it. K1 governs the **GUI**: the `b2` CLI satisfies it by nature, so everything below is about `b2-desktop` + [`ui/`](../../ui) ([#78](https://github.com/AlteredCraft/B2/issues/78)). @@ -293,7 +293,7 @@ Every new surface owes all four. They are cheap while you're building it and exp ## The rendering trust boundary (invariant E5) -[invariants.md](../../design/invariants.md) **E5** — *note content is untrusted input; rendering is a +[invariants.md](../../docs/invariants.md) **E5** — *note content is untrusted input; rendering is a trust boundary* — names this file as its elaboration home, the way K1 does above. E5 governs the **GUI**: the `b2` CLI prints text, so nothing there parses into a document ([#77](https://github.com/AlteredCraft/B2/issues/77)). diff --git a/crates/b2-embed/evals/README.md b/crates/b2-embed/evals/README.md index 217dba8..2e134c8 100644 --- a/crates/b2-embed/evals/README.md +++ b/crates/b2-embed/evals/README.md @@ -1,441 +1,5 @@ -# The eval suite — the guide +# The eval suite -How B2 measures the one thing `cargo test` cannot: whether retrieval, discovery, and grounded -chat are any *good*. A unit test can prove `reindex` is idempotent; only a human-labelled -corpus can say that "how do leaves turn light into food" should rank `photosynthesis.md` -first. This is the one guide to the whole suite: every instrument, how to run it, **how to -read what it prints**, and the process rules that bind anyone editing the corpora, the -labels, or the metrics. - -Everything here runs **out of CI, on demand** (ADR-0013): `cargo test` stays fast, -deterministic, and model-free, so model quality can never flake CI. **Decision history lives -in git and in [GitHub Issues](https://github.com/AlteredCraft/B2/issues)** — every verdict in -the [record below](#the-verdict-record) names the issue that drove it, and the commit that -shipped it is the record of what changed and why. - -> **Not the audience for this page?** [**Search & similarity — the -> explainer**](../../../docs/search-and-similarity.html) is the plain-language tour of -> everything these metrics score, written for people *using* B2 rather than measuring it. - -## The instruments at a glance - -| Command | The question it answers | Model | Deterministic | -|---|---|---|---| -| `make eval` | Is retrieval/discovery *good*? Scores both labelled corpora through the real pipeline and **asserts the exit gate** | real bge | per machine+build¹ | -| `make eval-sweep` | Would a different `ChunkConfig` be better? (the [#44](https://github.com/AlteredCraft/B2/issues/44) chunker A/B, seven variants) | real bge | per machine+build¹ | -| `make eval-stemmer` | Is `porter unicode61` still the right FTS tokenizer? (the [#157](https://github.com/AlteredCraft/B2/issues/157) ablation) | real bge | per machine+build¹ | -| `make eval-metal` | The same eval on the Apple-Silicon GPU — a *different vector space* (`@metal`, ADR-0007); compare against a CPU run, never average with one | real bge | per machine+build¹ | -| `make stability` | Did the *ranking* move — under widening candidate pools, and since the blessed baseline? Says *different*, never *better* ([#141](https://github.com/AlteredCraft/B2/issues/141)) | fake | yes | -| `make calibrate VAULT=…` | Does a corpus-derived constant survive a **real vault**? (process rule 5; [#196](https://github.com/AlteredCraft/B2/issues/196)/[#197](https://github.com/AlteredCraft/B2/issues/197)) | stored vectors (pure read); `--search` loads the model | yes, per vault | -| `make eval-chat` | Does grounded chat cite the right notes and refuse what the vault can't answer? ([#154](https://github.com/AlteredCraft/B2/issues/154)) | real bge + a chat model server | no (LLM output varies) | -| `make compare-device` | CPU vs Metal embed *throughput* (a performance A/B, not a quality one) | real bge | no | - -¹ Measured bit-reproducible run-to-run on an unchanged corpus/model/build -([#188](https://github.com/AlteredCraft/B2/issues/188) — five runs, identical rows), so -"noise floor" means corpus and label drift, not run variance. Across devices the numbers -differ: the device is part of the embedding space's identity (ADR-0007), which is why rows -record the model id and are never averaged across `@metal` and CPU. - -**Why two quality instruments.** `make eval` scores **quality** — it can say *better*. -`make stability` scores **movement** — it can only say *different*, but it sees what the -labelled corpus is structurally near-blind to: **candidate width**. The eval corpus barely -exceeds the passage view's candidate pool and never the note view's, so a pool/width change -prints (nearly) bit-identical eval numbers while genuinely reordering a real vault. The -worked example is [#140](https://github.com/AlteredCraft/B2/issues/140)/[#142](https://github.com/AlteredCraft/B2/issues/142): -the eval saw nothing, the probe saw 10 of 10 probes change, and the change was reverted. -A probe can say *different*; only labels can say *better*. **Run both.** - -## Quick start - -```console -make init # provision bge-base-en-v1.5 (one time) -make eval # ~1min warm; appends rows to results.jsonl; non-zero exit on a gate regression -make stability # model-free, seconds; drift vs the blessed baseline -make calibrate VAULT=$HOME/notes # the real-vault transfer check (any built vault) -make calibrate VAULT=$HOME/notes ARGS=--search # …plus the search evidence bar's half (loads the model) -ollama serve & make eval-chat # grounded-chat scores (or any OpenAI-compatible server) -``` - -Exit codes, everywhere in the suite: **0** = the run completed (and, for `make eval`, every -gate cleared); **2** = a quality gate failed; **1** = the run itself broke (a missing model, -a label lint fault, a server that isn't there). `make stability` never gates — any completed -measurement exits 0, because drift is a signal, not a failure. - -## When to run what - -| You are changing… | Run | Because | -|---|---|---| -| chunking (`ChunkConfig`, boundaries, overlap) | `make eval-sweep`, then `make stability` | the A/B prices the change per query; passage-level ranks are where chunking shows | -| FTS tokenizer / lexical matching | `make eval-stemmer` | isolates the tokenizer over identical chunks + vectors | -| candidate pools, `pool_size`, RRF headroom | `make stability` (+ `make eval`) | the eval is near-blind to width ([#141](https://github.com/AlteredCraft/B2/issues/141)); the probe is its instrument | -| RRF constant / fusion weighting | `make eval` + `make stability` | `RRF_K` re-weights the *same* lists, so the eval does see it | -| a corpus note or a label | the token audit (process rule 2; [#218](https://github.com/AlteredCraft/B2/issues/218)), then `make eval` | a corpus edit is a change to the instrument — own commit, audited | -| any threshold read off a score distribution (a cosine bar, a z window, a band landmark) | `make calibrate` on ≥1 real vault | process rule 5 — the constant is invalid until it transfers | -| discovery ranking / the `similar` surface | `make eval` (both corpora's per-mate blocks) | the dense fixture is the geometry the orthogonal corpus can't express | -| the grounded-chat prompt, condensation, citations | `make eval-chat` | retrieval reach is reported beside it, so a miss is attributed, not guessed | -| the embed device (CPU ↔ Metal) | `make eval-metal` vs a CPU `make eval`; `make compare-device` | a device switch is a model swap; quality and throughput are separate questions | - ---- - -## `make eval` — the scored quality gate - -One run builds **two throwaway vaults** — the orthogonal corpus (`corpus/`, 31 notes) and the -dense single-domain fixture (`corpus-dense/`, 15 beekeeping notes) — and scores everything -through the real `Vault` pipeline. Nothing touches your vault, and nothing here writes to the -repo except the gitignored `results.jsonl`. - -The run refuses to start on labels the corpus cannot honour: a **label lint** checks every -labelled path exists and every `passage` is verbatim in a relevant note (a typo'd label -otherwise scores as a permanent miss and reads as an engine regression). Two **instrument -checks** then print before any score and gate everything after them: the model id (never -compare CPU and `@metal` rows), and `batch ≡ single` embedding faithfulness. - -### Reading the report, block by block - -Rank notation, used everywhere: `✓1` = ranked first; `·3` = ranked third; `✗>10` = not in -the top K=10 (discovery reads at K=5). `[check]` lines are instrument self-verification -passing; `[FAULT]` means *distrust the surrounding numbers* — the harness and engine -disagree; `[warn]` is a gate failure or a measurement caveat; `[note]` is a skipped reading -with its reason. - -1. **The per-query table** — every positive query's note rank under `bm25` (projection only, - the lexical floor), `vec` (dense-only ablation, [#158](https://github.com/AlteredCraft/B2/issues/158)), - `hybrid` (the shipped fusion), and `chunk` (passage-labelled queries only). This paired - list is the primary evidence (process rule 1); the aggregates below it are the smoke - alarm. At n≈44, one aggregate point ≈ one query. -2. **Aggregates + semantic lift** — hit@1/hit@3/MRR per mode; `semantic lift` is hybrid - hit@1 minus BM25-only hit@1, the measured value of the AI seam. The **fusion** lines name - every query hybrid ranks *worse* than the dense signal alone would — RRF's consensus bias, - counted and named on every run instead of rediscovered by hand. -3. **Chunk rank** — the passage-labelled subset (n=20) scored at chunk level; this is where - chunking levers show, and what the sweep is judged on. -4. **Discovery (`similar`)** — **per-mate** ranks (every labelled mate scored on its own, so - a hard mate can't hide behind an anchor's easy one, [#183](https://github.com/AlteredCraft/B2/issues/183)); - the **strangers** list (unlabelled notes served on positive anchors — a smoke alarm with - names attached, deliberately ungated: the cheapest way to shrink it is to label the - stranger, and labels aren't exhaustive); the **negatives** line (loner anchors serve their - ranked nearest under always-serve, ADR-0014 — the honesty rides on the strength bands); - and the **cosine piles** (labelled-related vs everything-else served — if they separate, - the gap is a floor; if they overlap, no simple floor exists and the data says so). -5. **Discovery z calibration** ([#187](https://github.com/AlteredCraft/B2/issues/187)) — - gates nothing; re-derives, every run, the windows a z existence rule *would* need - (leader: negative-anchor leaders vs positive; member: strangers vs mates). `open` means a - constant exists *on this corpus* — a real vault is the other half of any such claim - (process rule 5); `EMPTY` means the populations invert and no constant separates them. - The negatives' leaders print with the band each would paint (`●○○`/`●●○`/`●●●`) — what an - always-served card *claims* to the human whose label says "nothing relates". A `[check]` - line confirms the harness's independent z recomputation matches the engine's. -6. **Discovery fold bake-off** ([#200](https://github.com/AlteredCraft/B2/issues/200)) — - gates nothing; prices the candidate *default-disclosure* rules (mutual-k reciprocity, and - "no fold", the incumbent) on the same served lists. Columns: `cards above` (default-view - size), `mates folded` (labelled relations hidden by default — the fold's own cost, judged - at zero), `strangers above`, `loners empty` (the claim a fold exists to make), `dark - panes` (non-loner views emptied — disqualifying on the dense bench). The swept-`k` table - re-derives the admissible window; the verdict line says which bound failed. The ruling of - record: **no fold ships** — the window is empty and no `k` transfers across vault scales. -7. **Search evidence calibration + bake-off** ([#201](https://github.com/AlteredCraft/B2/issues/201), - ADR-0015) — per labelled query, the absolute signals RRF discards (OR-sanitized BM25 hit - count and best score, dense top-1 cosine) and what the surface serves, positives and - negatives apart. The bake-off sweeps the shipped rule's shape — IDF-weighted term - **coverage** OR a **cosine** bar — over the whole coverage grid; a cell is `✗` the moment - it anchors a labelled negative (no cosine bar can rescue it). The **shipped bar** lines - are gated: positives it would cut and negatives it still serves, both asserted at zero. A - `[FAULT]` here means the engine's verdict and the harness's restatement of the same rule - drifted apart — read the engine's `LexicalEvidence`, not the wording. -8. **Search tail bake-off** ([#206](https://github.com/AlteredCraft/B2/issues/206)) — gates - nothing; prices four per-hit prefix-cut families against the `tail_relevant` keep-set, - each constraint re-derived per run, every payoff read against the **oracle ceiling** (what - a fold placed by the labels themselves would cut). The ruling of record: **no tail fold - ships** — the fused order and the evidence disagree mid-list, so the tail complaint is - *ordering* work (the reranker seam), not disclosure work. -9. **The dense report** — per-mate ranks on `similar-dense.json`, the **zero empty panes** - sweep over *every* note (asserted: a vault where everything relates may never read as - "nothing relates"), the shipped search bar replayed over every note's own **title** as a - query plus built-in nonsense (`search_transfer` — titles need no labels, so nothing here - can be relabelled to clear a number), and the tail families against whole title lists - (every served row there is a real match by geometry). -10. **The cross-bench join** — printed once both corpora are read: a tail family ships only - if some constant is admissible on the labelled corpus *and* folds nothing on the dense - fixture *and* still buys something. Admissibility is not a shipping order — a joint edge - sits at a bench's own binding row with zero headroom, which the sizing method forbids. - -A `pool_blind` warning means the run's chunk count fits inside the narrower candidate pool, -so **candidate-width changes cannot move any number in that run** ([#141](https://github.com/AlteredCraft/B2/issues/141)) -— never read an unmoved number there as "no effect"; `make stability` is the instrument. - -### The exit gate - -`make eval` exits `0` only when the default config clears **all** assertions (the gate at -the end of `run()` in [`eval.rs`](../examples/eval.rs)): - -| Assertion | Constant | Direction | When it goes red, the fix is… | -|---|---|---|---| -| hybrid note hit@1 ≥ 0.75 | `FLOOR_HIT1` | floor, below the 0.95 reading | read the per-query misses above the aggregate; argue with the notes, never the labels | -| per-mate discovery MRR@5 ≥ 0.52 | `FLOOR_MATE_MRR` | floor, below the 0.650 reading | read the per-mate lines — which mate slid, on which anchor | -| dense fixture: zero empty panes | — | absolute | an existence gate is refusing a vault whose every note relates ([#196](https://github.com/AlteredCraft/B2/issues/196)) — remove it | -| dense per-mate MRR@5 ≥ 0.32 | `FLOOR_DENSE_MATE_MRR` | floor, below the 0.467 reading | as above; relabelling toward the model's order *always* looks plausible here, so don't | -| labelled negative queries served = 0 | `MAX_NEGATIVES_SERVED` | structural zero | the evidence bar is serving a query the vault holds nothing for — a rule regression or a mislabel; both want red | -| labelled relevant queries cut = 0 | `MAX_POSITIVES_CUT` | structural zero | change the **rule**, never the constant — the `df` ceiling died exactly here | -| dense titles cut = 0, dense nonsense served = 0 | `MAX_DENSE_TITLES_CUT` | structural zero | the lexical half has gone inert on a single-subject vault — the retired ceiling's failure shape | - -**How the gates are placed is the point.** A rank floor never sits *at* its reading: a gate -pinned to today's number fails on the first legitimate corpus edit, and the cheapest way to -clear a red rank is to **edit a label** — the one habit this harness must never train -(process rule 2). Run-to-run noise is zero (see ¹ above), so each MRR floor sits roughly two -lost-from-rank-1 mates under its reading, and the headroom is for *corpus drift*. The -search-evidence rows are the deliberate exception: they sit **at** their structural zeros -with no headroom, because headroom there would read as permission to serve a nonsense query -or cut a real one ([#202](https://github.com/AlteredCraft/B2/issues/202)). They skip, with a -printed `[note]`, on a model with no calibrated bar (M2) — asserting the absence of a -verdict would fail every run on a model nobody has measured yet. - -Two rows have **retired** from the gate, deliberately: the negatives' suppression assertion -(`neg_clean == neg_n`) went with the existence gate it watched — under always-serve a loner -serves its ranked nearest, and the honesty moved to the band readout -([#197](https://github.com/AlteredCraft/B2/issues/197)); and the pass-vs-pass suppression -tripwire went when it was found to compare a `similar` call against itself — structurally -incapable of firing — with a returning existence gate caught instead by the dense -zero-empty-panes assertion and the per-mate floors -([#217](https://github.com/AlteredCraft/B2/issues/217)). - -### The A/Bs: `--sweep` and `--stemmer` - -The sweep re-chunks and re-embeds the same vault under seven `ChunkConfig` variants; the -stemmer flag rebuilds `chunks_fts` under the unstemmed `unicode61` over **identical** chunks -and vectors, so every rank move is the tokenizer's alone (its dense column doubles as an -instrument check — FTS cannot reach it, so movement there means the harness broke). - -Read the **`Δ vs default` lines, not the scoreboard** (process rule 1): at this n, every -aggregate delta is 1–2 queries, and only the named flips can be argued against the labels. -"No per-query rank moved" is itself a claim to verify against a continuous quantity (the -piles) before believing "no effect" (process rule 4 — the `prepend-heading-path` lesson). -The sweep's variant rows record no calibration blocks: a disclosure rule judged on a -non-shipped chunker would be a number about the chunker. - ---- - -## `make stability` — the rank-movement probe - -Runs on `fixtures/test-vault` (~200 notes / ~780 chunks — big enough for the candidate pools -to **bind**) with the deterministic fake embedder, so the committed baseline means the same -thing on every machine. It scores no relevance: the corpus is unlabelled, so it can only say -*different, and by how much*. - -**Section 1 — pool sensitivity.** Each probe is asked at depths 4/10/30; a cell compares the -top-4 across one widening step, position by position. `=4` = the shallow answer is an exact -prefix of the deep one (pool-invariant); `3/4` = one position changed; `n/a` = the probe -matched nothing (measured probes only enter the denominators). The summary counts probes -whose top-4 moved per step — that is what candidate width is worth on this vault. - -**Section 2 — baseline drift.** The shipped top-10 against the blessed snapshot -(`stability-baseline.json`): `=10` = exact; `kept, reordered`; or `n in, m out`. **Drift is -a signal, not a failure** — a knob change is *supposed* to move ranking. Once the change is -the intended one, `make stability-bless` accepts it (fake embedder + committed vault only — -a real-model or private-vault baseline would commit a number nobody else can reproduce). -Editing `fixtures/test-vault` or `stability.json` invalidates the baseline — re-bless in the -same commit. - -Flags: `ARGS=--verbose` prints the diverging lists; `ARGS=--model` runs real bge (magnitude -only, no baseline); `ARGS="--vault "` probes any vault — -`--vault crates/b2-embed/evals/corpus` is the control experiment where every prefix holds: -that *is* [#141](https://github.com/AlteredCraft/B2/issues/141)'s blindness, demonstrated. - ---- - -## `make calibrate` — the real-vault transfer check - -Process rule 5 made mechanical: run it against **any built vault** — no labels needed, a -pure read over stored vectors, seconds even on a large vault. This is the instrument that -retired the existence gate ([#196](https://github.com/AlteredCraft/B2/issues/196) measured -16 of 17 panes dark on a real single-domain vault) and killed the `df`-ceiling evidence rule; -any future distributional constant answers to it **before** shipping. - -Per-anchor columns: pool size `n`; the pool's cosine `min/med/max`; the leader's cosine and -z; `gate serves` — what the retired z gate would serve (`DARK` = it would empty this pane; a -simulation, the shipped surface gates nothing); `fold` — the mutual-k reciprocity fold's -default view ([#200](https://github.com/AlteredCraft/B2/issues/200)'s candidate 1, replayed); -`e-bar` — the authored-edge reference bar's fold (candidate 2 — this is the **only** -instrument that can price it, since its calibration population is the human's own committed -edges and both eval corpora are link-free); `bands` — the strength-band histogram the pane -would paint. The summary block repeats each as a vault-level reading, plus the engine-z -drift `[check]`. - -Interpretation: a replayed gate darkening panes on a vault you know is inter-related is the -[#196](https://github.com/AlteredCraft/B2/issues/196) failure reproduced; an `e-bar` at 0 or -`UNPRICEABLE` is a *reading about the vault* (no scorable authored edges), not an instrument -failure; a band histogram compressed into `●○○` on a dense vault is the open A6 thread -(band compression), not a bug. A fake-embedded vault warns and prints noise — reindex with -the real model first. - -`ARGS=--search` adds the **search-side** transfer bench ([#201](https://github.com/AlteredCraft/B2/issues/201)): -first the vault's function-word weights beside an absent word's (the lexical anchor's whole -premise, checked per vault rather than assumed of English), then the shipped evidence bar -replayed over every note's own **title** (positives by construction — nothing to relabel) -and built-in nonsense (the vault-independent negatives), the ten lowest-cosine positives -(where a bar placement bites first), and the per-hit tail families priced on the one row a -title query certifies with no label: its own note. `the bar would cut N/…` is the tripwire -direction — D2 permits zero. This is the one part of the instrument that is not a pure read -(judging the cosine half embeds each probe query, so it loads the model); it has no `--json` -form yet ([#219](https://github.com/AlteredCraft/B2/issues/219)). - -Other flags: `--limit N` (pane depth), `--leader-z/--member-z` (replay a different gate; -defaults are the retired constants, so [#196](https://github.com/AlteredCraft/B2/issues/196)'s -dark-vault reading reproduces), `--mutual-k N`, `--json` (the discovery reading as one -object, for scripting sweeps). - ---- - -## `make eval-chat` — the grounded-chat scores - -Needs a model server (`ollama serve` + `ollama pull llama3.2`, or `B2_LLM_URL` / -`B2_LLM_MODEL` at any OpenAI-compatible endpoint). Builds a throwaway vault from the -**retrieval eval's corpus** — chat is scored over notes whose retrieval behaviour is already -characterised, so a surprise is attributable — and asks the labelled questions in -[`../../b2-llm/evals/questions.json`](../../b2-llm/evals/questions.json) through the real -`Vault::ask`. Unlike `queries.json`, questions do **not** avoid the target's vocabulary: -this scores what the model does with retrieved passages, not what retrieval finds. - -Four deliberately-separable scores, because a bad answer has more than one possible author: - -| Line | Meaning | A miss is… | -|---|---|---| -| **retrieval reach** | did a labelled note make it into the passages at all? | `make eval`'s result, not chat's — the ceiling every other score is judged under | -| **citation accuracy** | did the answer cite the labelled note? (scored only over reached questions) | the headline chat number | -| **grounding rate** | did the answer cite *anything*? | an uncited answer is a refusal or general knowledge, and the prompt forbids the second | -| **refusal accuracy** | on the two deliberate negatives, did it say "I don't find that in your notes"? | a confabulation — one negative is near the corpus's coverage on purpose, because models confabulate from loosely related passages | - -Per-question verdicts: `cited the note` / `refused (correct)` are the good ends; -`retrieval missed (not a chat result)` blames the other half; `MISCITED`, -`REFUSED WITH EVIDENCE PRESENT`, and `CONFABULATED` are the model's failures, with -hallucinated `[n]` markers (markers naming no passage) counted beside them. TTFT/total -latency print per question. - -**The exit code is a liveness check, not a quality bar**: `2` only when retrieval reached -labelled notes and *no* answer cited one — a broken pipeline. Model quality is read off the -numbers; a gate that fails on every small local model would be a gate nobody runs. Known -coverage gap: every question is single-turn, so the condense step is unmeasured -([#220](https://github.com/AlteredCraft/B2/issues/220)). - ---- - -## `results.jsonl` — the run log - -Both harnesses append one JSON line per scored run to their `evals/results.jsonl` -(gitignored, machine-local — scores depend on the machine's models). Every number ever -cited in an issue traces to a row here. Conventions: - -- **Append-only**, so runs accumulate into one comparable dataset. -- **Keys are additive, never redefined.** A metric that changes meaning gets a *new* key; - the old one goes absent, so a reader of a mixed file sees a missing field rather than one - number silently meaning something narrower than it used to. (Retired keys so far: - `discovery_z.shipped`/`replay_faults` with [#197](https://github.com/AlteredCraft/B2/issues/197); - `similar_per_mate_raw`/`similar_mates_suppressed` with [#217](https://github.com/AlteredCraft/B2/issues/217).) -- **`"corpus"` tags every row** (`"orthogonal"` / `"dense"`); rows are never averaged across - corpora, and the dense row's transfer reading lives under its own `search_transfer` key - rather than overloading `search_evidence`. -- **Re-derivability**: the calibration subtrees (`discovery_z`, `discovery_fold`, - `search_evidence`, `search_tail`, `search_transfer`) record the raw per-candidate / - per-row data, so any window, fold depth, or bar can be re-derived from a row without - re-running the model. -- `pool_blind: true` marks a row no candidate-width comparison may be read from. - -A few readers: - -```console -# the latest orthogonal default-config row -jq -s '[.[] | select(.corpus == "orthogonal" and .config.label == "default")] | last' results.jsonl - -# hybrid note MRR across runs -jq -r 'select(.corpus == "orthogonal") | [(.ts | todate), .git, .config.label, .note.hybrid.mrr] | @tsv' results.jsonl - -# per-mate discovery MRR, both corpora -jq -r '[(.ts | todate), .corpus, .similar_per_mate.mrr] | @tsv' results.jsonl - -# which queries missed hybrid rank 1 in a given row -jq -r 'select(.corpus == "orthogonal") | .queries[] | select(.hybrid != 1) | [.q, (.hybrid // "miss")] | @tsv' results.jsonl -``` - -## The corpora and labels (ground truth) - -| Path | Role | -|---|---| -| [`corpus/`](corpus/) | the hand-written **orthogonal** vault (31 notes): topic clusters, six long multi-chunk notes, five unambiguous loners, the stemmer-adversarial block, the [#183](https://github.com/AlteredCraft/B2/issues/183) multi-topic family, and `week-log.md`, the journal-shaped dilution extreme ([#189](https://github.com/AlteredCraft/B2/issues/189)/[#192](https://github.com/AlteredCraft/B2/issues/192)). Its token audit *minimizes* shared vocabulary — which is exactly why it cannot express topical concentration | -| [`queries.json`](queries.json) | retrieval labels — 44 positives (20 with a chunk-level `passage`; three date-shaped, [#202](https://github.com/AlteredCraft/B2/issues/202)) + 5 **negatives** (empty `relevant` = the labelled answer is *no matches*). Seven positives carry `tail_relevant` ([#206](https://github.com/AlteredCraft/B2/issues/206)) — the per-hit keep-set, **exhaustive by label** and encoded by note, never by rank. Its `description` field is the labelling rulebook — read it before editing | -| [`similar.json`](similar.json) | discovery labels — positive anchors with expected mates; **empty `expected` = a negative anchor** (a loner whose correct answer is *nothing*). Its `description` is the loner-orthogonality rulebook | -| [`corpus-dense/`](corpus-dense/) + [`similar-dense.json`](similar-dense.json) | the **dense single-domain fixture** (15 beekeeping notes, all inter-related, no loner) — the vault-level geometry the orthogonal corpus is structurally incapable of expressing, and the bench that killed the existence gate and the `df` ceiling. Rankings-only labels; scored in its own vault and row | -| [`stability.json`](stability.json) + [`stability-baseline.json`](stability-baseline.json) | the unlabelled probe set and its blessed ranking snapshot (fake embedder over `fixtures/test-vault`) | -| [`../../b2-llm/evals/questions.json`](../../b2-llm/evals/questions.json) | the chat set — questions phrased as a person would type them, `expect` = the note(s) a correct answer must cite; empty `expect` = the only correct answer is the refusal | -| `results.jsonl` (both harnesses) | append-only run logs, gitignored | - -## The verdict record - -Every ruling this suite has produced, one line each — the issue holds the full argument and -the numbers, and the commit that shipped it is the record of the change. (This table -replaced the long-form verdict log that used to live here; `git log` on this file has it.) - -| Issue | Verdict | -|---|---| -| [#44](https://github.com/AlteredCraft/B2/issues/44) | `ChunkConfig::default()` held against a seven-variant sweep; the "winning" rows were impeached by a measured boundary-luck noise floor. Retrial is one `make eval-sweep` away | -| [#141](https://github.com/AlteredCraft/B2/issues/141)/[#142](https://github.com/AlteredCraft/B2/issues/142) | the labelled corpus is near-blind to candidate width; a 3× pool widening was reverted on the stability probe's evidence | -| [#150](https://github.com/AlteredCraft/B2/issues/150) | the z-score discovery quality floor shipped — per-anchor z over the centroid population, suppression entering the gate; **superseded by [#197](https://github.com/AlteredCraft/B2/issues/197)** | -| [#156](https://github.com/AlteredCraft/B2/issues/156) | RRF fused-score ties break on the dense signal's rank — a policy the eval decided, not walk order | -| [#157](https://github.com/AlteredCraft/B2/issues/157) | `porter unicode61` FTS stemming: 7–0 BM25 / 3–0 hybrid on the paired readout, precision probes unmoved; the unstemmed arm stays measurable via `make eval-stemmer` | -| [#183](https://github.com/AlteredCraft/B2/issues/183) | the multi-topic note family landed, and with it the **per-mate** metric — the per-anchor one saturates at 1.000 and hides every hard mate | -| [#187](https://github.com/AlteredCraft/B2/issues/187) | the member window is **empty on the shipped corpus's own numbers** — mates and strangers invert, so no constant separates them; windows are re-derived every run, never frozen in a docstring | -| [#189](https://github.com/AlteredCraft/B2/issues/189) | the journal-shaped note was built, measured, and **rejected** — the first corpus edit this harness refused (its diluted centroid tops loner anchors on content it doesn't contain) | -| [#192](https://github.com/AlteredCraft/B2/issues/192) | the floor moved to the stage-2 best-passage unit, and `week-log.md` landed — read correctly in both directions at once | -| [#182](https://github.com/AlteredCraft/B2/issues/182) | the buried gem is served, and the desktop's strength bands were re-read in the judged unit (the last surface still reading the retired one) | -| [#188](https://github.com/AlteredCraft/B2/issues/188) | discovery rank entered the exit gate; the harness measured **bit-reproducible** run-to-run, so floors are sized for corpus drift, not noise | -| [#196](https://github.com/AlteredCraft/B2/issues/196)/[#197](https://github.com/AlteredCraft/B2/issues/197) | the existence gate itself was the defect: a single-domain vault went 16/17 dark while the ranking was correct throughout. The gate retired; discovery **always serves the ranked list** (ADR-0014); `make calibrate` and the dense fixture landed *before* the fix, per the sequencing rule | -| [#200](https://github.com/AlteredCraft/B2/issues/200) | the discovery fold bake-off ran and **no fold ships**: mutual-k's admissible window is empty, and its safe depth is a different rule on every vault scale; the authored-edge bar is unpriceable on link-free corpora and stays open | -| [#201](https://github.com/AlteredCraft/B2/issues/201) | search's evidence bar was **earned** — and its first form (a hard `df` ceiling) read clean on the labelled bench and cut 3/15 answerable queries on the dense one, so the *rule* changed (IDF as a weight, not a bin), not the constant | -| [#202](https://github.com/AlteredCraft/B2/issues/202) | the verdict reached the surfaces ("no matches" is a real answer; `--json` became an object), the date-shaped block landed, and search's three structural-zero rows entered the gate | -| [#206](https://github.com/AlteredCraft/B2/issues/206) | the per-hit tail bake-off ran and **no tail fold ships**: 367 of 386 filler rows sit below the oracle fold, but the fused order misplaces the evidence, so every admissible prefix cut is near-vacuous — the tail complaint is ordering work (the reranker seam) | -| [#217](https://github.com/AlteredCraft/B2/issues/217) | the pass-vs-pass suppression tripwire compared a call against itself and could never fire; removed — the dense pane assertion and the per-mate floors are what catch a returning existence gate | - -**Standing principles from the record**: when a label and a note disagree, fix whichever one -is lying (the watercolor → throat-singing precedent); an arguable negative is replaced, not -argued; instruments land *before* the rules they price; and a reading that disqualifies a -rule is re-derived every run, never frozen into a comment. - -**The deliberately open threads**: the **phishing pair** (a real relation ranked under three -stranger pairs even in the best-passage unit — served, so ordering residue, the pair-scorer's -standing evidence); the **tail's 367 rows of headroom** (the second exhibit for a reranker, -measured on search's flow); and the **dense-vault band compression** (#197's A6 — on a vault -where every candidate is close, the within-list z compresses and the dots lose resolution; -first read on a real-embedded build of the dense fixture: 0 ●●● / 6 ●●○ / 144 ●○○). - -## Process rules - -Adopted 2026-08-10; each traces to a measured mistake or a named risk, and each is binding -on anyone editing the corpora, the labels, or the metrics. - -1. **A paired per-query win/loss list is the primary readout of any A/B; the aggregate is a - smoke alarm.** At n≈40, every aggregate point is 1–2 queries — "hit@1 +0.05" and "these - two flipped" are the same fact, but only the second can be argued with against the - labels. The sweep prints the diff (`Δ vs default`) automatically. -2. **A corpus edit is a change to the instrument, so it ships as its own commit** whose - message says what changed and why, and every edit runs the **two-direction token audit** - before it lands: no existing query's content tokens may newly land in the edited/added - note, and no new query's content tokens may split evenly toward a rival — word-boundary - and stem-prefix both (the `insomnia.md` steal and the `recover`+`mistake` near-miss are - the precedents). Run the audit, don't eyeball it ([#218](https://github.com/AlteredCraft/B2/issues/218) - tracks committing it as a script). And — since the gate reads discovery rank — **a red - gate is never an argument for editing a label**: per-mate MRR and the strangers count - both move when labels move, so the only honest response to red is to argue about the - *notes*. -3. **The same person authoring notes, queries, and fixes is a ratchet toward measuring what - the engine already does.** Mitigations in order of cheapness: rule 2's audit; sourcing - future queries from outside the corpus author's head (from note titles alone, or another - person); dogfooding on a real vault before trusting any threshold. -4. **A bit-identical or unmoved metric is a claim to verify, never proof of "no effect"** — - compare a continuous quantity (the piles) before believing a discrete one. (The - `prepend-heading-path` trace; the sweep diff prints its own reminder.) -5. **A constant derived from a corpus's score *distribution* is invalid until - transfer-checked on a real vault** (`make calibrate` is the check; adopted with - [#197](https://github.com/AlteredCraft/B2/issues/197), from a measured mistake made - twice). Rank-derived readings transfer, because the corpus's *orderings* are engineered - to be checkable; its score **distributions** are an artifact of engineered orthogonality, - so any threshold read off them — a cosine bar, a z window, a band landmark — describes - the corpus, not a vault. A distributional constant ships only with a `calibrate` reading - from at least one real vault beside the corpus numbers. +The guide to this harness lives in [docs/evals.md](../../../docs/evals.md): every +instrument, how to read its output, the exit gate, the verdict record, and the process rules. +Read it before touching the corpora, the labels, or the metrics in this directory. diff --git a/crates/b2-embed/examples/eval.rs b/crates/b2-embed/examples/eval.rs index dbcdb8f..b9a15c3 100644 --- a/crates/b2-embed/examples/eval.rs +++ b/crates/b2-embed/examples/eval.rs @@ -8,7 +8,7 @@ //! cargo run -p b2-embed --example eval -- --stemmer # + FTS tokenizer A/B (the #157 gate) //! ``` //! -//! **`crates/b2-embed/evals/README.md` is the notebook of record** — the corpus, what the exit code +//! **`docs/evals.md` is the notebook of record** — the corpus, what the exit code //! enforces, every verdict this harness has ruled, and the process rules. Read it before //! touching the corpus, the labels, or a constant here. What this comment carries is only //! what a reader of *this file* needs: @@ -824,7 +824,7 @@ fn run() -> Result> { ); // 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 (crates/b2-embed/evals/README.md, the + // per-query win/loss list is the data (docs/evals.md, the // process rules). print_rank_moves(&positives, &hybrid, &pass); append_result( @@ -3717,7 +3717,7 @@ fn print_tail_join(ev: &SearchEvidence, orth: &TailBench, titles: &[SearchProbe] (zero headroom — the constant placement the house sizing method forbids), each payoff \ reads against the oracle ceiling above, and a shipped constant owes process rule 5's \ real-vault reading besides (`make calibrate VAULT= ARGS=--search`, the tail block). The ruling of \ - record lives in crates/b2-embed/evals/README.md." + record lives in docs/evals.md." ); } else { println!( @@ -4151,7 +4151,7 @@ fn band_glyph(z: f64) -> &'static str { /// 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 (crates/b2-embed/evals/README.md, the process rules). Prints nothing but a +/// 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". diff --git a/design/data-model.md b/design/data-model.md deleted file mode 100644 index 3c694a5..0000000 --- a/design/data-model.md +++ /dev/null @@ -1,497 +0,0 @@ ---- -title: "B2 — Data Model" -type: note -tags: [b2, data-model, frontmatter, typed-links, edges, resources, okf] -created: 2026-06-29 -status: active ---- - -# B2 — Data Model - -> Defines **what a note is** and **what a connection is**, as the plain-Markdown source of truth — -> engine-independent. This is the yardstick the engine measures against: the SQLite schema in -> [index-engine.md](index-engine.md) §3 is a *derived projection* of this model and must satisfy it, -> never the reverse. Companion docs: [invariants.md](invariants.md) (the normative register, cited by -> id) and [index-engine.md](index-engine.md) (the *how*). -> -> **Rationale lives in [`ADRs/`](../ADRs/README.md)** — why connections live where they do -> (ADR-0010), why B2 never authors the body (ADR-0004), why identity is the path (ADR-0003), why -> there is no suggestion queue (ADR-0009). This doc defines the shapes; the ADRs say why. - -The model has exactly **two source-of-truth objects**, both plain Markdown: - -1. **A note** — one `.md` file: YAML frontmatter + a Markdown body. -2. **A connection (edge)** — a directed link from one note to another: a plain link a human writes in - the body, or a typed relation in frontmatter `b2_relations:` (§0). - -Both are **authored**. A real vault also holds **resources** — every non-`.md` file. A resource is a -**peer vault member**, *not* a third authored object: B2 can *read* it but cannot author structure -into it, because Markdown is the only format whose bytes B2 may write. So the source *tier* is **the -whole vault directory** while the two authored objects stay note + edge. Resources are defined in -**§10**; §0–§9 are unchanged by them. - -### Two storage tiers - -1. **Markdown — source of truth for *knowledge*.** Notes + every committed edge, on your disk, fully - usable with no B2. Stays **pristine**, and the **body is 100% the human's** (W2). The enumerated - on-command writes are W3; **reading your vault writes nothing at all** (W1). -2. **Index (`b2.sqlite`) — disposable cache.** The search indexes and the keyed graph. Holds - **nothing** that can't be reconstructed from the Markdown. - -> **Two tiers, sharply split** (S1); **index = projection of (the vault directory)** (S2, ADR-0002). Drop `b2.sqlite` → re-derive → an -> identical index (S3). There is **no** durable B2-derived state outside your notes (S4). A -> **resource** (§10) contributes only *derived* rows, so the guarantee is unchanged. - -### Folders — user-authored structure, filesystem-authoritative - -The vault directory carries two kinds of authored material: the Markdown files (**content**) and the -directory tree itself (**structure**). A folder — *empty or not* — is user-authored exactly like a -note, and the **filesystem is authoritative** for it; B2 proxies the OS rather than modeling folders: -`create_dir` makes missing parents but refuses an occupied target (no `mkdir -p` idempotence — the -human asked to *create*), `move_dir` is one `rename`, `delete_dir` is `remove_dir_all`, and each -resolves its target against the *disk*, never the index, so empty folders are first-class throughout. -Folders are **never projected into the index** — they carry nothing to chunk, embed, or link — so the -tree's structure listing (`Vault::list_dirs`) is a **live fs walk** (dot-folders skipped, §1): the -tree is one-to-one with the vault's managed subtree *by construction*, in both directions. S4 scopes -to **B2-derived** data; the human's own structure is vault material, not B2 state. - ---- - -## 0. The central decision — where a connection lives - -Ruled by **[ADR-0010](../ADRs/0010-typed-graph-two-homes-closed-vocabulary.md)** and -**[ADR-0004](../ADRs/0004-markdown-is-the-only-surface-b2-writes.md)**. A connection lives in -exactly one of two homes, **by origin** — and the two homes split by *what they can say*, not just -who writes them: - -| Origin of the edge | Where it lives | SSOT | -|---|---|---| -| A plain body link | **Body** — a bare `[[path\|title]]`, a Markdown `[text](path)`, an embed — ordinary Markdown, always an untyped `references` edge | the body; B2 **reads**, never writes it | -| A typed relation (committed via `b2 link`, or human/importer-written) | **Frontmatter `b2_relations:`** — a typed-link string `- " [[path\|title]] — …"` (§2); the **only** home of a verb + explanation | frontmatter; B2's managed metadata zone | - -**`b2 link` writes frontmatter, not body.** Committing appends one typed-link string to the source -note's `b2_relations:` (Markdown first, index reconciled after). The edge then materializes as an -`origin='frontmatter'` edge derived from that Markdown — committing is the projection of an authored -line, not a bespoke index write (§3). - -> One line: **the body holds the plain links the human writes (all `references`); frontmatter -> `b2_relations:` holds every *typed* relation — verb and explanation live only there; both are -> authored Markdown, and the graph is their union** (G2). - -Each edge has exactly **one** home and B2 never copies between them — so there is nothing to keep "in -sync," only a one-way projection to rebuild. A `b2_relations:` entry may deliberately target a note the -body already links: that is the **augment** flow (§2). The overlap case — the *same* `(target, type)` -in both homes (necessarily `references`, the only type a body link can carry) — is resolved at -projection time by **frontmatter-wins** dedup: the frontmatter row is kept (it alone can carry an -explanation), the redundant body reference ignored as a duplicate, never auto-removed from the file. - ---- - -## 1. The note - -A note is one `.md` file **whose vault-relative path has no dot-prefixed segment**: YAML frontmatter, -then a Markdown body. (Any segment — an ancestor folder counts, so `notes/.templates/daily.md` is no -more a note than `.scratch.md` is.) - -```markdown ---- -type: concept # optional, defaults to `note`; OKF entity discriminator -title: "Spaced repetition" # optional, INERT — the title is the filename (L2) -description: "Why expanding intervals beat massed practice." -tags: [learning, memory] -created: 2026-06-20 -updated: 2026-06-29 -aliases: [SRS] # optional Obsidian-native extra titles -b2_relations: # B2's managed zone: typed edges (§2). origin=frontmatter - - "contradicts [[notes/cramming-works|Cramming works]] — short-term recall only" -provenance: # optional; defaults to {by: human} - by: human ---- - -Spaced repetition schedules reviews at expanding intervals… - -It builds on [[concepts/memory|Human memory]] — the forgetting curve is the mechanism. -``` - -The body link is **human-authored** (`origin=inline`) and untyped — an ordinary `references` edge; the -surrounding prose is just prose. The `b2_relations:` entry is a *typed* edge (`origin=frontmatter`). - -### Hidden means hidden — a dot-prefixed name is not vault material - -A dot-prefixed name is outside the managed subtree **whatever it is**: a folder (`.git/`, -`.obsidian/`, B2's own `.b2/`), a resource (`.DS_Store`), or a Markdown file (`.scratch.md`). The -convention is the filesystem's, not B2's, so B2 reads it the same way everywhere (S2, -[GH #136](https://github.com/AlteredCraft/B2/issues/136)): - -- **The walk skips it before it routes it** (`pathspec::is_hidden`, applied *above* the note/resource - dispatch in `collect_vault_files`), so "hidden" cannot mean one thing for a PDF and another for a - Markdown draft. A dot-prefixed `.md` is never a note: no chunks, embeddings, graph presence, - listing, search, or file-tree appearance. -- **The file itself is untouched** — skipping is not deleting (W4). That is the point: keep a scratch - draft out of B2's way without leaving the folder you work in. -- **B2 will not author one either.** Every authoring destination refuses a dot-prefixed segment: - creating a member the walk would never see is a silent fs/index desync. - -*Migration note:* rows for a previously-indexed dot-prefixed `.md` are ghost-pruned on the next -reindex and inbound links re-dangle (G5); renaming it back restores it exactly. - -### Frontmatter schema - -**Required: nothing.** A note is a `.md` file; that is the whole entry requirement. Identity is the -**vault-relative path** (L1, ADR-0003) — not a key in the file — so there is no frontmatter B2 needs -present, none it will add, and a note with no frontmatter block at all is an ordinary, fully-indexed -note. - -**Optional (B2-recognized)** - -- **`type`** — what *kind* of note this is (`note`, `concept`, `source`, `person`, `daily`, …). - Controlled-but-extensible; unknown values tolerated; the OKF entity discriminator (§5). **Defaults - to `note`** — its only consumer is display, so nothing keys on its presence and the new-note - template does not seed it (GH #80: the template stamps only what can't be reconstructed later). Not - `b2`-namespaced on purpose — a courtesy the human owns, not a key B2 machines on. -- **`title`** — **recognized but inert** (L2). A note's title **is its filename** (basename minus - `.md`); the key is parsed and round-tripped losslessly like any other, and never drives display, - link aliases, or search. -- **`description`** — one-line summary; feeds the embedding prompt and OKF export. -- **`tags`** — list of strings. -- **`created` / `updated`** — ISO-8601 date or datetime. `created` is set by B2 at creation - (`b2 add`); `updated` is the human's (or another tool's) to maintain — B2 does not stamp it. -- **`aliases`** — Obsidian-native additional titles; treated as alternate link aliases. -- **`provenance`** — *optional, opt-in* note-level authorship: `{by: human | agent:, - source?, confidence?}`. Absent ⇒ `{by: human}`. B2 neither requires nor manages it. (Edges carry no - provenance — §4.) -- **`b2_relations`** — **B2's managed zone for typed edges** (§2): a YAML list of typed-link strings, - the **only** place a relation verb and explanation live. **Namespaced** — and B2's *only* key — so it - can never collide with a user's or another tool's `relations:` key; for the same reason a generic - un-namespaced `relations:` is *not* read (just another unknown key, preserved verbatim). B2 appends - here on `b2 link` (never the body); humans and importers may write it too. - -**Unknown keys** — preserved verbatim and byte-for-byte on round-trip (W5, §6). A `b2id:` line an -older B2 left behind is exactly that: an unknown key, never read, never removed (ADR-0003). - ---- - -## 2. Authored links & typed relations - -### Bare wikilink ⇒ an untyped `references` edge - -A normal `[[path|title]]` anywhere in prose is a connection of type **`references`**, `origin=inline` -— the untyped graph Obsidian already gives you, typed and materialized. It is **directed** (A→B — the -literal fact that A's text points at B), which preserves the backlink ↔ forward-link split -(`b2 neighbors` shows it as *referenced-by* from B's side), is the information-preserving default, and -keeps the explicit symmetric verb (`contradicts`) meaningful as a deliberate choice. - -### Frontmatter `b2_relations:` ⇒ a *typed* edge (`origin=frontmatter`) - -The typed-link syntax ` [[path|title]] — explanation`, as a **quoted string** in a -`b2_relations:` list — **the one and only home of a typed relation**. Optional trailing text after an -em-dash (or `:`) is the edge's **`explanation`**. - -```yaml -b2_relations: - - "supports [[concepts/forgetting-curve|Forgetting curve]] — the schedule exploits it" - - "contradicts [[notes/cramming-works|Cramming works]]" -``` - -- **Quoted** so `[[`, `|`, and `:` are always YAML-safe; the reader accepts quoted or unquoted. -- An entry that is just a bare `[[path|title]]` (no verb) is accepted and reads as `references`. -- Humans and importers may write this block too; B2 appends to it on `b2 link` and never authors the - body. - -### Typing a body link — frontmatter *augments* the body - -A `b2_relations:` entry may target a note the body already links. It **augments** that connection: the -body keeps its plain, clickable link exactly as written, and the frontmatter carries the stance. A -different verb (`supports [[x]]` over a body `[[x]]`) adds the typed edge alongside the untyped -reference — both are real, separately-authored facts. The same verb (`references [[x]] — why`) -collapses into one edge, **frontmatter-wins** (§0/§3), so the explanation survives. This is the -intended UI affordance: select a body link, choose a verb and optionally an explanation, and B2 appends -one `b2_relations:` entry — the body is never touched. - -### Relation vocabulary — a stance core + a tolerated tail - -Small, orthogonal, stable core; expressiveness in the tail (G3, ADR-0010). - -**The core (closed set — the `b2 link` palette, and what queries can rely on):** - -| Verb | Stance | Direction | Inverse (display only) | -|---|---|---|---| -| `references` | neutral | directed | referenced-by | -| `supports` | for | directed | supported-by | -| `contradicts` | against | symmetric | contradicts | - -*Boundary notes:* `references` is both the **automatic** type of a bare link and the deliberate "see -also"; `supports` is a **directed** "A backs B"; `contradicts` is a **deliberate symmetric** "these -state opposites" — tension has no aggressor, so no direction is recorded. - -**Extensibility:** the core is stable across versions. Any other verb a human writes (`elaborates`, -`part-of`, `supersedes`, …) is **tolerated and stored verbatim, never dropped**; tooling treats tail -verbs as opaque strings (no inverse label, no special traversal). A tail verb that proves common can be -**promoted** into the core later (gaining an inverse label); demotion is just removal from the palette, -stored data untouched. - -**Typing guidance:** use a stance verb whenever the notes take a position on each other; `references` -is the honest default when they don't. - -**Conventions:** lowercase kebab-case, named from the source's perspective (`derived-from`, not -`DerivedFrom`). **Edges are directed and stored once** (G4): inbound edges are computed by scanning -`dst_path` and labelled with the inverse; the symmetric verb is its own inverse. B2 **never** writes a -reciprocal link into the target file — that would be write-amplification and would edit a note the user -didn't touch. - -### Edge identity is *derived*, so the file stays clean - -An authored edge — body **or** frontmatter — is identified by the tuple **(src path, dst path, `type`, -occurrence-index)**, all recoverable from the Markdown alone. No edge-id is ever written into the file. -A committed edge carries **no provenance at all** — it is a pristine authored line, nothing more (§4). - ---- - -## 3. The connection / edge model (derived projection) - -Every edge projects to one record. This is the shape the [index-engine.md](index-engine.md) §3 `edges` -table holds; the Markdown is the source, this is the index. - -| Field | Values | Source | -|---|---|---| -| `id` | derived | edge identity, from `(src, dst, type, occurrence_index)` | -| `src_path` | note path | the authoring note (always a note — G6) | -| `dst_path` | note path, or NULL | resolved from the authored `[[path]]` at parse time | -| `dst_resource_path` | resource path, or NULL | set instead of `dst_path` when the target is a non-`.md` file (§10) | -| `dst_path_raw` | text | the target exactly as authored — what a dangling edge keeps | -| `type` | relation verb (§2) | the `b2_relations:` verb; `references` for every body link | -| `origin` | `inline` \| `frontmatter` | which of the two homes (§0) the edge came from | -| `explanation` | free text, optional | trailing text after `—`/`:` (frontmatter entries only) | -| `caption` | text, optional | a Markdown link's text / an embed's alt text | - -- **Every edge is authored and active** (G1). `origin` records *which home it came from*. There is no - lifecycle and no `status` column: an edge exists iff it is written in the Markdown, which is exactly - what keeps `index = projection of (Markdown)` exact. -- **`src`/`dst` are vault-relative paths, resolved at parse time.** The authored `[[path]]` is - normalized against the resolver (the wikilink `+ ".md"` ladder) and stored as the path it named; - `dst_path_raw` keeps the text exactly as written. So an edge stores what the human authored, and the - graph carries no identifier the vault does not. A **B2-performed** move is therefore one transaction - — rewrite the inbound `[[path|title]]` *text*, re-key the moved note's rows, re-project the inbound - sources — and L1's "rename keeps every backlink resolving" holds. A move made **outside** B2 is a - delete plus a create, and its inbound links project as surfaced dangling edges (G5). -- **The edge set is the union of the two homes, deduped** (G2). A frontmatter entry with a *different* - verb than a body link to the same target is no duplicate — it is the augment case (§2), and both - edges project. If the *same* `(src, dst, type)` is authored in **both** homes (necessarily - `references`), projection keeps the frontmatter row — **frontmatter-wins**, because only it can carry - an explanation — never auto-editing the file. -- **A `dst` may be a resource, not a note.** A body embed/link to a non-`.md` file (`![[photo.png]]`, - `[[papers/x.pdf]]`) resolves against the `resources` table and records `dst_resource_path`; `src` is - still a note (§10). -- **A `dst` that resolves to *nothing* is a surfaced dangling edge, not a dropped one** (G5). A note is - one `.md` file (§1), so a `[[Hermes]]` naming a **folder** — or a plain typo — matches no note and no - resource: the edge is still projected with both `dst_path` and `dst_resource_path` NULL. - `b2 neighbors`/`b2 explain` (and the desktop Connections pane) present these **distinctly**, as - *unresolved* with the authored target, so a mistyped link reads as broken rather than silently - vanishing ([GH #12](https://github.com/AlteredCraft/B2/issues/12)). Resolving the target turns the - same edge into an ordinary connection on the next reindex. Folder-note resolution (Obsidian-style - `Hermes/Hermes.md`) is a possible later refinement, deliberately out of scope. - -Why this projection is **materialized rather than computed on read** — and why that does not make it a -third source of truth (G6) — is [index-engine.md](index-engine.md) §3; the standing cost is its §8. - ---- - -## 4. Committing a connection - -There is **no suggestion lifecycle, no review queue, no rejection memory, and no event log** -(ADR-0009). A connection becomes real in exactly two ways, both **authored in Markdown**: - -1. **A body link you write** — a plain `[[path|title]]`, Markdown link, or embed: an untyped - `references` edge (§2). B2 **reads** it on the next reindex; it never writes the body. -2. **`b2 link [--type ] [--explanation …]`** — B2 appends one typed-link string to - the **source note's frontmatter `b2_relations:`** (Markdown first; **never the body**), then - re-projects the note so the edge materializes as `origin='frontmatter'`. `--type` defaults to - `references`; the palette is the core vocabulary (§2). B2 writes the target as a **bare `[[path]]`** - — no `|alias`: the filename is the note's title (L2), so the path already reads as the title. - *(A human writing a body link may still add any `|alias` they like; B2 reads it and never rewrites - it.)* - -The GUI adds one further authoring *gesture* over the same model: dragging a Similar card onto a line -of the note being edited types a `[[wikilink]]` at that line's end — the untyped, body kind of link, -landing in the editor's buffer exactly as `[[` completion does. B2 still authors nothing (W1) and the -human is still the precision gate. - -**No provenance tier.** A committed edge is **pristine**: no `by`, no `confidence`, no `source` — -nothing stapled to the note beyond the ` [[path|title]]` line itself. Provenance is *decision -fuel* for a review step B2 doesn't have. (Optional **note-level** `provenance:` frontmatter remains the -human's to write — §1 — and is separate from edges.) - ---- - -## 5. OKF compatibility (export is a no-op, not a migration) - -Build *like* OKF for cheap interop; don't depend on it. The model already lines up: - -- **`type`** is the OKF entity discriminator — recognized frontmatter, defaulting to `note` (§1). -- **Resource URI** — a per-note URI is derivable from its vault-relative path under a configured base. - It is exactly as durable as the path (L1) — the same handle Obsidian, a static-site generator, and a - file manager all give you (ADR-0003). -- **`index.md`** — a vault-root manifest listing notes/types, **derivable** from the frontmatter, so - an OKF consumer has a collection entry point. Generated, never a second source of truth. - -Net: "export to OKF" is selecting and re-shaping fields that already exist. The export surface itself -(minting URIs, emitting the manifest) is **not built**; -[GH #103](https://github.com/AlteredCraft/B2/issues/103) tracks it. - ---- - -## 6. Serialization discipline - -W5's round-trip losslessness is what makes the two-tier split safe, and it is a property of the -*parser/serializer*, so it is specified here: - -- Unknown frontmatter keys are preserved **verbatim and in order**; body text, whitespace, and comment - tokens are byte-preserved. -- The **only** bytes B2 ever changes are the specific mechanical edits it is asked to make (W3), of - which exactly one touches the body: rewriting an inbound `[[oldpath|title]]` → `[[newpath|title]]` on - a move, aliases preserved verbatim. An operation the human did not invoke changes no byte at all - (W1). -- Nothing is reformatted, reordered, or normalized in passing — not YAML quoting style, not list - indentation, not line endings. - -These are the tripwires [index-engine.md](index-engine.md) §8 budgets: this doc defines them, that one -enforces them in the store. - ---- - -## 7. Rejected / deferred alternatives - -The reasoning for each rejection lives in the ADR that made the call; this is the index. - -| Rejected | Why, in one line | Recorded in | -|---|---|---| -| B2 authoring the body (a `## Relations` section) | The body is the rendered/exported document and must stay 100% the human's | ADR-0004 | -| Body typed-line syntax parsed from prose | Would make B2 an interpreter of prose *shape* — `- see [[x]] for background` becoming verb `see` (L4) | ADR-0004, ADR-0010 | -| Inline-in-body as the home for committed edges | Accepted trade: a frontmatter edge isn't guaranteed clickable in vanilla Obsidian, which can't render edge *types* regardless (§0) | ADR-0010 | -| A suggestion review layer / per-pair LLM relator | ~notes × candidates model calls, and exactly the model-compensating machinery M1 defers | ADR-0009, ADR-0005 | -| A durable event-log tier (`.b2/log/`) | It would exist to hold a queue and rejection memory B2 doesn't have; anything durable outside the notes weakens S2/S4 | ADR-0002 | -| A stamped machine identity (`b2id:`) — **removed 2026-08-13** ([GH #170](https://github.com/AlteredCraft/B2/issues/170)) | Its one real buy (out-of-band move re-binding) cost an unbidden write, a machine key in every file, a collision subsystem, and a carve-out on S3 | ADR-0003 | -| Content-hash identity | Churns the graph on a typo fix. (Hash-keying the *vector store* is the opposite case and shipped with the same change — derived data, where content-addressing is exactly right, M4) | ADR-0003, ADR-0006 | -| Stored reciprocal links | Inverse edges are derived at query time (G4); writing them back amplifies writes and edits notes the user didn't touch | ADR-0010 | -| Per-edge ULIDs in the file | Authored edge identity is derived (§2); explicit ids clutter the note for no gain | ADR-0010 | -| Edge provenance in Markdown | With no review step there is no provenance to keep (§4) | ADR-0009 | - ---- - -## 8. A golden-vault sketch (for the test harness) - -The smallest fixture that exercises the whole model — an authored typed edge and a bare reference. - -`concepts/memory.md` -```markdown ---- -type: concept -title: "Human memory" -created: 2026-06-20 ---- -The brain encodes, stores, and retrieves information… -``` - -`notes/spaced-repetition.md` -```markdown ---- -type: concept -title: "Spaced repetition" -created: 2026-06-20 -b2_relations: - - "supports [[concepts/memory|Human memory]] — applies the forgetting curve" ---- -Spaced repetition exploits the [[concepts/memory|Human memory]] retrieval curve. - -Expanding review intervals exploit the forgetting curve. -``` - -The body holds one plain link (`origin=inline`, `references`) and the `b2_relations:` entry types the -same connection with a stance (`origin=frontmatter`, `supports`): the augment shape from §2, exercising -both homes at once. Derived graph (no live model needed to assert): - -- `references`: spaced-repetition → memory (origin=inline) — from the prose wikilink. -- `supports`: spaced-repetition → memory (origin=frontmatter, explanation="applies…"). - -`b2 neighbors concepts/memory` returns spaced-repetition twice (referenced-by, supported-by); both -files round-trip byte-identical; dropping and rebuilding the index reproduces the identical graph. - ---- - -## 9. Judgment calls — resolved - -The data model is **locked**; nothing is open. Every decision and its rejected alternatives are in -[`ADRs/`](../ADRs/README.md) — chiefly ADR-0002 (two tiers), ADR-0003 (path identity), ADR-0004 -(write discipline), ADR-0009 (no suggestion queue), and ADR-0010 (the two homes, the dedup rule, and -the verb core). The normative claims are the invariant register's S, W, L, and G entries. - ---- - -## 10. Resources — the second kind of vault member - -§0–§9 define the **authored** objects. A real vault also holds **resources**: every non-`.md` file. -This section defines what a resource *is* in the model; the schema is -[index-engine.md](index-engine.md) §3. - -A resource is a **peer vault member** — not a lesser one, and not a generalized note. The single -asymmetry is **authoring surface, not status**: - -> **A note is where structure is *authored*; a resource is a peer document B2 cannot write.** Notes -> have frontmatter, authored edges, and B2's write guarantees — because Markdown is the one format -> whose bytes B2 may touch. Resources have bytes, *derivable* text and vectors, and *inbound* links. -> **Identity is not part of the asymmetry**: both are keyed by their vault-relative path (L3), so the -> note rules are the resource rules with an authoring surface added, not a second identity model. - -What the asymmetry does **not** mean: a resource is never *required* to be attached to a note. An -unlinked resource fully exists — walked, classified, in the file tree, in the index, openable. - -**Identity — path-keyed, index-only.** There is nowhere to stamp a machine id (binary bytes are not -B2's to edit; a sidecar file would be durable state outside Markdown, violating S4) and nothing one -would protect. That reasoning is now the *whole vault's* — ADR-0003 read it back onto notes. - -- **`b2 mv` on a resource** is simply *the* move: rewrite the inbound `[[path]]` / `![alt](path)` text, - move the file, re-project — on identical terms to a note (§3). -- **Placing one is not authoring one.** "B2 cannot write a resource" is about its *content*: B2 never - edits the bytes, and there is no format-specific writer. Putting a file **into** the vault on - explicit command — the desktop's drag-from-Finder import (`Vault::import_file`), the same act as - moving or deleting it (W3) — is file management, and the copy is byte-honest: B2 places exactly the - bytes it was handed and then projects them, routing on the extension exactly as the walk does. A - dropped `.md` is therefore a *note* arriving, frontmatter and all. -- **An out-of-band move degrades exactly as a note's does** — identification, not repair: the old - path's rows prune, the new path projects as a new member, and every inbound link surfaces as a - dangling edge (G5). The index keeps a **blake3 content hash** per resource, and notes carry a - `body_hash`, so the *proposed*-repair idea stays buildable on data already stored; it is recorded as - future investigation in GH #170, deliberately not built (a proposal is still the human's to accept, - W4). - -**Edges — `src` is a note in v1; `dst` may be anything** (G6). A *consequence* of the invariant, not a -status rule: every edge must trace to an authored line in Markdown (§3), and a resource has no writable -home for one. Two relief valves keep this from hardening into an expressiveness wall: **(a) today**, -the tolerated tail already authors the inverse direction from the note side -(`- "supported-by [[papers/x.pdf]]"`); **(b) if needed**, resource-sourced edges get a designed future -home — a **vault-level B2-managed relations file**, so the edge is still authored Markdown and the -invariant holds ([GH #102](https://github.com/AlteredCraft/B2/issues/102), deferred until the need is -real). - -**What is built, and what is designed.** The **inventory and graph** half ships: the walk classifies -every non-`.md` file by extension into one of six classes — `text` · `html` · `pdf` · `image` · -`media` · `binary` (the total fallback) — records `(path, class, size, mtime, content_hash)`, prunes -what the walk no longer meets, and resolves body embeds/links at them into `dst_resource_path` edges. -Classification is by extension **only**: deterministic, and a misclassification degrades rather than -mis-executes. - -The **content** half is locked design, not shipped: every class funnels to *text* — native (`text`), -extracted (`html` tag-strip, `pdf` text layer), or, for an `image`, the aggregated alt-text/captions -from the notes that embed it (a pure projection of authored Markdown) — then flows through the -**existing** bge space with zero new discipline (chunks + a per-document centroid). -[GH #108](https://github.com/AlteredCraft/B2/issues/108) tracks it, -[#109](https://github.com/AlteredCraft/B2/issues/109) the PDF extraction dependency and -[#107](https://github.com/AlteredCraft/B2/issues/107) render mechanisms. Until they land, a resource -is an inventoried, linkable, openable peer that search and discovery do not reach. Multimodal image -embedding and an LLM/OCR **Describer** are documented future seams, default-off (M3, -[GH #110](https://github.com/AlteredCraft/B2/issues/110)). - -**Why a separate object, not a `kind` column on the note.** Two tables, two contracts, zero "unless -it's a resource" clauses. Generalizing `notes` to hold resources would staple a caveat onto every -invariant, write guarantee, and frontmatter behavior in §0–§9; a distinct `resources` table isolates -the different *write* contract instead of threading it through the note rules. diff --git a/design/index-engine.md b/design/index-engine.md deleted file mode 100644 index f69feff..0000000 --- a/design/index-engine.md +++ /dev/null @@ -1,422 +0,0 @@ ---- -title: "B2 — Index Engine" -type: note -tags: [b2, index-engine, sqlite, fts5, vectors, search, discovery, chat, architecture] -created: 2026-06-29 -status: active ---- - -# B2 — Index Engine - -> **The engine design — the *how*.** Specifies the disposable SQLite index (FTS5 + an in-process -> vector scan + the typed graph) and the flows over it. Companion docs: [invariants.md](invariants.md) -> (the normative *why*, cited by id) and [data-model.md](data-model.md) (the *what*). -> -> **Rationale lives in [`ADRs/`](../ADRs/README.md)** — why SQLite and not qmd (ADR-0019), why -> plain vector tables (ADR-0006), why the graph is materialized (ADR-0010), why discovery always -> serves (ADR-0014), why a served result claims evidence (ADR-0015), why candle (ADR-0020). This -> doc specifies what the engine *is*; each section names the record that decided it. - -## 1. qmd, the reference — and what we took - -[qmd](https://github.com/tobi/qmd) is a local CLI search engine for Markdown, all on-device: SQLite + -FTS5 + `sqlite-vec`; ~900-token chunks with ~15% overlap and Markdown-aware break-point scoring; -three search modes; a pipeline of LLM query expansion → parallel retrieval → **RRF fusion** -(`Σ 1/(k+rank+1)`, k=60) → cross-encoder rerank → position-aware blend; local GGUF models via -`node-llama-cpp`; TypeScript on Node, MIT. - -**We rebuilt rather than depended — ADR-0019.** Taken wholesale: the chunking heuristic, **the RRF -formula and `RRF_K = 60`**, the position-aware blend, the asymmetric query/document prompt discipline -(each model brings its own prefix — B2 ships bge's, §6), and the JSON/`--explain` agent-output -discipline. Discarded: the npm/Node packaging and the "DB is the product" framing. - -**Chunking as adapted** (`chunk.rs`, [GH #19](https://github.com/AlteredCraft/B2/issues/19)) — four -model-free changes to qmd's heuristic: a **450**-token target (headroom under bge's 512-token -truncation), a `chars/4` proxy for token sizing (the core stays tokenizer-free, E1), an unconditional -stored `heading_path` breadcrumb, and every lever on a `ChunkConfig` (overlap 0.15, backscan 200, the -H1=100 … list=5 break weights). A forced cut is pushed past a fenced code block or Markdown table -rather than bisecting it ([GH #41](https://github.com/AlteredCraft/B2/issues/41)). Tree-sitter AST -chunking for code stays deferred ([GH #104](https://github.com/AlteredCraft/B2/issues/104)). - -## 2. Why we rebuild instead of depend on qmd - -Recorded in full as **[ADR-0019](../ADRs/0019-build-our-own-sqlite-index-engine.md)**. In one -line: qmd is a search engine and B2 is a typed graph with hybrid retrieval over it — qmd models no -typed edges, no backlinks, and nothing that repairs links on a move (G1–G6, L1), and SQLite holds all -three queryable concerns in one transactional store. - -## 3. The storage architecture (one disposable SQLite index) - -One artifact, per S1/S2 and ADR-0002: a **disposable** SQLite index holding every queryable concern -transactionally, rebuildable from the vault at any time (S3). - -> The precise DDL and build order are realized in `crates/b2-core/src/db.rs` (schema) and `ingest.rs` -> (flows). The sketch below is the orientation; the code is the buildable contract. - -``` -b2.sqlite — DISPOSABLE CACHE (= projection of the vault directory; drop & rebuild any time) -├── MIRROR OF THE VAULT (lets us diff vs. disk) -│ ├── meta(key, value) -- schema_version, embed_model_id, embed_dim -│ ├── notes(path PK, type, title, description, -- the path IS the identity (L1) -│ │ created, updated, body_hash, mtime, indexed_at) -│ ├── note_aliases(note_path, alias) -- frontmatter `aliases:` -│ └── resources(path PK, class, size, mtime, -- non-.md peers; class by extension (§10 dm) -│ content_hash, indexed_at) -│ -├── DERIVED: SEARCH -│ ├── chunks(id, note_path, seq, char_start, char_end, token_count, heading_path, text, text_hash) -│ ├── chunks_fts -- FTS5 over chunk text, `porter unicode61` (BM25) -│ ├── embeddings(text_hash PK, vector) -- CONTENT-ADDRESSED plain BLOB vectors (768-dim) -│ └── note_centroids(note_path, centroid) -- per-note centroid (discovery's coarse stage) -│ -└── DERIVED: TYPED GRAPH - └── edges(id PK, src_path, dst_path, dst_resource_path, dst_path_raw, - type, origin, explanation, caption, embed, occurrence_index) -``` - -Every table is derived from the vault; there is no third home. The two **vector** tables are created -at *embed* time rather than in the base migration — their existence *is* the "this vault has an -embedding space" signal the BM25-only fallbacks key on (M4, ADR-0006). - -*(The projection runs as two separately-invokable passes — model-free `project` (notes/resources/ -chunks/FTS/edges) then `embed` (vectors), with `reindex` their composition — so keyword search and the -graph are usable before embedding completes -([GH #15](https://github.com/AlteredCraft/B2/issues/15)). S2 is untouched: a projected-but-unembedded -index is a smaller projection, never a wrong one.)* - -Why this shape fits B2 specifically: - -- **Everything keys on the vault-relative path** (L1, ADR-0003). `notes.path` is the primary key; - `chunks.note_path`, `note_aliases.note_path`, `note_centroids.note_path` and `edges.src_path` are - `REFERENCES notes(path) ON DELETE CASCADE ON UPDATE CASCADE`, which makes a B2-performed move a - **path re-key rather than a rebuild**: `UPDATE notes SET path = …` cascades through every child in - one transaction, alongside the inbound link-text rewrite and a re-projection of the inbound sources. - `edges.dst_path` is deliberately *not* an FK — it must be allowed to be NULL, the dangling case - (G5). "Rename keeps every backlink resolving" is therefore a property of the **move operation**, not - of the key; the price is that a move made *outside* B2 is a delete plus a create (§8). -- **Vectors are content-addressed** (M4, ADR-0006), keyed by blake3 of the chunk text — which *is* the - embed input, verbatim — so a note that moves re-embeds nothing, identical text anywhere shares one - vector, and the only invalidation rule is "a hash no chunk references is garbage", pruned by the - whole-vault pass on the same derived-data lifecycle as centroids. -- **Every `edges` row derives from Markdown** (G1, G2, ADR-0010), deduped frontmatter-wins. There is - **no `status` column and no suggestion queue**: `b2 link` appends a typed-link string to the source - note's frontmatter and re-projects that note — the projection of an authored line, not an in-place - index write. -- **Hybrid retrieval and graph queries compose in one query** — "semantic-nearest chunks whose note is - within 2 typed hops of note X" is a join across `embeddings`, `chunks`, and `edges`. This is the - substrate `b2 similar` runs on. -- **Deterministic seams for tests** — a fake embedder writes to `embeddings`, so the whole pipeline is - assertable with no live model (E2). - -**Resources widen the projection without disturbing any statement above** — path-keyed peers, so -`index = projection of (the vault directory)`. `resources` is a **separate** table from `notes`, not a -`kind` column on it (two tables, two contracts, zero "unless it's a resource" clauses), and -`edges.dst_resource_path` lets a body `![[photo.png]]` or `[[papers/x.pdf]]` resolve against it while -`src` stays a note (G6). What is built is inventory + graph; resource *content* search is designed, -not shipped — [data-model.md](data-model.md) §10. Either way there is **no migration**: a schema -change is a `schema_version` bump + rebuild (S5). - -### Opening the index concurrently — many readers, one builder - -Several things open one index at once: `b2 reindex &` racing a `b2 status`, the desktop app launching -while a CLI reindex runs, the desktop host's own threads. The locked stance is **C1**, enforced in -`db::open` in three layers that each answer a different failure: - -- **The `WAL` flip is retried** ([GH #111](https://github.com/AlteredCraft/B2/issues/111)). Setting - `journal_mode = WAL` is the one statement in `open` that takes a write lock and the one - `busy_timeout` cannot cover — SQLite skips the busy handler for a write lock upgraded from an - already-open read transaction — so a second opener took an immediate `SQLITE_BUSY`. The wait is ours. -- **The schema migration is one `BEGIN IMMEDIATE` transaction, entered only when there is work** - ([GH #114](https://github.com/AlteredCraft/B2/issues/114)). Unserialized, two openers that both read - a stale `schema_version` interleave — one's `DROP TABLE` landing after the other's `CREATE`, and the - current version stamped over a half-demolished schema. `busy_timeout` was irrelevant: nothing - contended, every statement succeeded, in the wrong order. The check that decides whether to enter is - a **read**, so the common open against a current schema takes no write lock and can never be refused. -- **Completeness is checked, not assumed.** A stamp is believed only alongside the tables it vouches - for; a current stamp over missing tables is stale and rebuilt from empty. Recreating just the missing - tables would be worse than useless — an incremental reindex skips notes whose `body_hash` matches, so - they would stay empty and S3 would quietly fail. - -The vector tables are the same drop-and-rebuild shape and get the same treatment. **Not an advisory -lock file:** that would be a third concurrency mechanism guarding state the database already guards, -and the weaker one where it counts — on a network share or synced folder, `flock` quietly stops -meaning anything. The `reindex` lock ([GH #55](https://github.com/AlteredCraft/B2/issues/55)) answers -a question SQLite cannot (*is another **process** already doing this expensive work?*); it is taken by -`b2-cli` alone, never by the desktop host or by readers, and so cannot cover schema atomicity. - -### Why materialize the graph at all — vs. resolving links at runtime - -A note's *outbound* links are parseable from that one file on demand, so edge metadata is not the -reason. **Inversion and composition are** — materializing turns three things from full-vault scans -(or impossibilities) into indexed lookups: - -- **Backlinks / inversion.** "Who points at X" cannot be read from X, only from every *other* note: - O(vault) per query at runtime, one lookup here. This is also what services L1 — the edges name the - exact N inbound files to rewrite on a move instead of scanning the vault to find them (§8). -- **Typed multi-hop traversal.** "notes within 2 hops of X via `supports`" is a scan *per hop* at - runtime; over `edges` it is one SQL traversal. -- **The graph⨝vector join.** "semantic-nearest chunks whose note is within k typed hops of X" is a - single join `embeddings ⨝ chunks ⨝ edges`, not expressible as a per-note parse — a **scoped - traversal** primitive. **`b2 similar`'s candidate generation is its complement**: notes semantically - near an anchor but *not* within 1 hop, where the materialized graph supplies the exclusion. - -**G6** keeps this cheap: runtime outbound-parsing is the correctness *definition*, the `edges` table -its *cache* — one more disposable table in the same store, populated by the same parse pass that -already walks each body for chunking. The standing cost is the move-repair amplification in §8. - -### Discovery surfacing serves the ranked list — `limit` is a cap, not a promise - -The standing rule is **invariants.md D1**; the decision, the seven issues of measurement behind it, -and the retired z-gate are **[ADR-0014](../ADRs/0014-discovery-always-serves-the-ranked-prefix.md)**. -Mechanically, in this engine: - -- `similar` serves the ranked top-N whenever candidates exist. `limit` under-fills only for want of - scorable notes, and **no statistical bar truncates the list**. -- The per-candidate **z survives as a statistic that gates nothing** — computed after stage 2 on the - best-passage distances, non-increasing down the row order (strictly monotonic in the *score*; tied - scores share a z and order by the path tie-break), painted as the within-list strength band. Because - the judged z is affine in the squared best-pair distance the score negates the root of, **score - order, z order, and band are one number**: a card can never show a weaker band above a stronger one. -- Below `STATS_MIN_POPULATION` (12), in a space with no spread, or under the fake embedder, **no z - exists at all**, and a surface must *say* the list is ungraded — silence there reads as "all judged, - all scored low", the opposite of what happened. -- Candidate *generation* is unchanged and stays recall-oriented: the two-stage scan (§4) over-produces, - nothing auto-links, and the human commits every edge (W4). - -The **pair-scorer escalation** stays the named long-term seam: an anchor-local rule cannot catch a -*pair-level* miscalibration (a single stranger the model scores like a cluster-mate — the standing -`encryption ↔ phishing` residue), and a discovery-side pair-scorer would be a second model seam, -sibling of §5's reranker but distinct from it (that seam needs query text and `similar` has none). It -would still only filter what is surfaced, never author a link. Under always-serve that residue is -**ordering quality, not existence**. - -## 4. Retrieval — semantic search, fusion, and discovery - -Semantic search is **in v1** (ADR-0019) — exact, in-process, no vector extension, no ANN. - -> The step-by-step walk through flows ② and ③ — every stage anchored to the function that -> implements it — is the docs site's [retrieval deep dive](../docs/retrieval.html). This section -> stays the spec; that page is the guided walk through the code. - -- **Storage & scoring.** Vectors live in plain tables — `embeddings(text_hash, vector)` plus per-note - `note_centroids` — read with one statement and scored in-process (`embed::l2_sq`). - Content-addressing costs the scan one indexed join back to `chunks` to recover which chunk each - vector ranks for. A `vec0`-style virtual table charges a per-row shadow-table probe on every scan, - which dominates at real-vault scale; the plain-table scan does not - ([GH #38](https://github.com/AlteredCraft/B2/issues/38), ADR-0006). -- **Flow ② hybrid search** (`search.rs`). BM25 over `chunks_fts` ⊕ vector KNN, fused with Reciprocal - Rank Fusion (`Σ 1/(k+rank+1)`, `RRF_K = 60`), resolved from chunks up to notes. Raw NL queries are - sanitized into a safe FTS5 `MATCH` expression — punctuation is FTS5 syntax and would otherwise crash - the parse. On a projected-but-unembedded vault the vector half is simply absent and the same fusion - runs over the single BM25 list, so scores stay on one scale. -- **The flow can answer zero, and the evidence rides beside the order** (invariants.md **D2**; - decision and history in - **[ADR-0015](../ADRs/0015-a-served-search-result-is-a-claim-of-evidence.md)**). `hybrid_search` - returns a `Retrieval`: the same fused order (untouched — provenance is carried, never folded in), - each hit naming the lists that ranked it and its own distance, plus the dense half's best cosine. - The lexical reading is deliberately *not* in it — it costs a `count(*)` per distinct term, so - `Vault::search_evidence` reads it only where a caller wants a verdict and joins the two signals - into the query-level `QueryEvidence`. The rule over them is *lexical OR semantic*: - - **The lexical anchor is IDF-weighted coverage, not presence.** `fts5_query` ORs every term, and - the labelled phrase negatives match 68 of 70 chunks through `a`/`to`/`my` alone — one reading a - *better* best-BM25 than several positives off nothing but function words. So neither a hit count - nor a raw BM25 score is a lexical test. What is: each term weighs `ln((chunks+1)/(df+1))`, and the - anchor is the share of the query's total weight the vault carries (`min_term_coverage`). Stopwords - are a *measurement*, never a shipped word-list. - - **The cosine bar is the backstop** (`min_cos`), judged only on queries the lexical half leaves - undecided, and thin by construction: ask the lexical half for more and the window collapses, - because the queries it then has to rescue are the ones with the weakest semantic evidence too. The - two constants are placed inside a **joint** band, never tuned one at a time. - - The constants are **distributional**, so they are keyed to `embed_model_id` (M2 — a swap - invalidates them; the device suffix shares the reading and `make eval-metal` is where that - assumption is re-checked). They live in `search::BGE_BASE_EVIDENCE_BAR` and their *justification* - is re-derived on every `make eval` run — never frozen into a comment (ADR-0013). - - The verdict reaches an adapter through `Vault::search_evidence`, which serves **exactly** the rows - `search` does, in the same order — three-state, and the three states are three behaviours - (ADR-0015). `search_evidence_excluding` (`b2 search --exclude`) is the same read minus a - **caller-named** set of notes — the follow-up-search form an agent loop passes its - already-inspected paths to. The subtraction is the caller's, never the verdict's: the verdict and - its signals still read the whole vault, the remaining rows keep their fused order, and the pool is - unchanged (width moves only on measured relevance, below), so a heavily-excluded query may - honestly under-fill. **The tail** — folding where a *real* query's per-hit evidence runs out — is - measured and stays unshipped (GH #206): with `tail_relevant` labels in place, the four-family - prefix-cut bake-off found every admissible rule vacuous — the fused order is not an evidence - order, so admissible folds reach 2–23 of the 367 rows an oracle fold would cut. The ruling and - its numbers live in [crates/b2-embed/evals/README.md](../crates/b2-embed/evals/README.md); the bake-off re-arms every run. -- **Flow ③ discovery is two-stage** (`discover.rs`). An O(notes) coarse scan over centroids — the - anchor and its 1-hop graph neighbours excluded *up front*, so the already-connected never occupy a - shortlist slot — keeps a shortlist (`SHORTLIST_PER_RESULT = 20` per asked result, floored at - `SHORTLIST_MIN = 200`), then an exact max-sim rescore over only the shortlist's chunk vectors. - No model call at surface time, and nothing is re-embedded: the anchor is represented by its - *stored* chunk vectors, never an `embed_query` of its text (bge's asymmetric query prefix is the - wrong side of the space). `b2 similar` surfaces, `b2 link` commits, and the human is the precision - gate (ADR-0009). -- **Candidate width is per view, and it is a quality knob, not plumbing.** Retrieval widens twice: - each façade read asks for a hit pool over its `limit`, and each signal (`search::pool_size`) pulls - 5× *that* (minimum 30) before RRF fuses the two lists. The two reads need headroom for different - reasons, so they get different pools - ([GH #142](https://github.com/AlteredCraft/B2/issues/142)). Note-level `Vault::search` keeps **3×**: - dedup collapses every chunk sharing a note onto that note's best one, so a pool of exactly `limit` - would under-fill `limit` distinct notes. Passage-level `Vault::search_chunks` has no dedup and keeps - a **small constant** (`limit + 2`) — enough to backfill the one hit it can drop to a C1 torn read, - and no more. Giving it the 3× too is not a free tidy-up: the 5× multiplies every hit of headroom - into candidates (150 per signal against 60, at a 10-result ask), and RRF over a wider candidate set - returns *different* answers — at k = 60 a chunk ranked ~60th in **both** lists outscores one ranked - first in a single list (`2/121 > 1/61`). Width moves only on measured relevance (§5). -- **A fused-score tie is broken by the dense signal, and that is a policy.** RRF over integer ranks - lands every fused score on a discrete lattice, so bit-identical ties between mirrored rank pairs — - (1, 3) vs (3, 1) — are structural, and the eval corpus produced one - ([GH #156](https://github.com/AlteredCraft/B2/issues/156): the semantic half named the labelled - answer, BM25 named the wrong one). The secondary sort key in `rrf_fuse` is therefore the candidate's - rank in the **vector list** (absent ranks below present), with id last purely for determinism — a - photo finish is decided on the signal measured to be right there, never on projection walk order. -- **The FTS tokenizer is `porter unicode61` — stemmed, and that is a *measured verdict*, not a - default.** Unstemmed BM25 matched surface forms only — `pedalling` found nothing in a note that says - "pedals", leaving the lexical half at rank 41–46 on queries the dense half ranked first, which RRF's - consensus bias turned into hybrid demotions of correct dense hits. The A/B that settled it - ([GH #157](https://github.com/AlteredCraft/B2/issues/157), 2026-08-11): porter improved 7 BM25-only - and 3 hybrid note ranks, worsened none, and dissolved every standing fusion demotion — while the - precision probes built to vote *against* stemming (the `universe`/`university` Porter collision, the - code-literal queries) did not move. Stemming remains a real trade — Porter is English-only, and a - vault holds code, identifiers, and proper nouns — so the retired arm stays measurable: - `Vault::rebuild_fts` swaps the tokenizer over identical chunk rows and vectors (nothing re-chunks or - re-embeds), and `make eval-stemmer` scores the unstemmed ablation beside every default run. -- **Does brute force scale to B2?** Comfortably. A personal vault of 10k notes → ~50–100k chunks; - brute-force cosine over ~100k × 768-dim float32 vectors is single-digit to low-tens of milliseconds. - We are nowhere near the regime where ANN matters; if a vault ever is, int8/binary quantization and - ANN hold a standby order behind the centroid stage - ([GH #106](https://github.com/AlteredCraft/B2/issues/106)). - -## 5. Deferred model machinery — the reranker & query expansion - -**The reranker is a fast follow.** Slot it where qmd puts it: **after RRF fusion, before final -ranking**, behind a swappable seam. v1 is retrieve → fuse → return top-N, already a strong hybrid -baseline; the fast follow inserts a cross-encoder rerank over the top ~30 candidates plus a -position-aware blend. It is a pure function `(query, candidates) → scores`, so it changes *ordering*, -not the store, the schema, or the candidate set — and it is **store-agnostic**, a model-side seam -above the index. Tracked in [GH #28](https://github.com/AlteredCraft/B2/issues/28). - -**Scope — this reranks `b2 search`, not `b2 similar`.** The signature is the tell: it needs *query -text*, and `b2 similar` has none — it is passage↔passage KNN, "near ∖ connected" (§3). The -discovery-side levers are distance-weighting ([GH #20](https://github.com/AlteredCraft/B2/issues/20)) -and the pair-scorer seam (§3); the discovery-side *precision* stance is D1's (ADR-0014). - -**Gate the decision on the eval, not intuition** (ADR-0013). RRF is a strong baseline; the reranker -buys **top-k precision**, whose value *grows with vault size* and is *highest when an agent consumes -top-1/top-3 with no human eye* ([GH #24](https://github.com/AlteredCraft/B2/issues/24)). Vault size -changes whether the precision is worth it, not the reranker's cost. - -**…and check the instrument can see the change you are gating.** Retrieval reaches at least -`chunk_candidate_pool(10) = 60` candidates per signal (150 for the note view); while a corpus has no -more chunks than that, neither signal is truncated and a candidate-width change prints bit-identical -numbers while genuinely reordering a real vault -([GH #141](https://github.com/AlteredCraft/B2/issues/141)). Score *relevance* on the labelled corpus, -but measure a width change with `make stability` over a vault big enough for the pool to bind. That -gate has already ruled once: [#140](https://github.com/AlteredCraft/B2/pull/140) widened the passage -view as plumbing, the eval printed bit-identical numbers, the probe found 10 of 10 top-4 lists -changed, and [#142](https://github.com/AlteredCraft/B2/issues/142) returned it — **the instrument that -can say "different" is not the one that can say "better"**. The blindness is to *candidates*, not to -fusion: `RRF_K` re-weights the same two lists and reorders results at any corpus size. - -**Query expansion** (qmd's third model) is **optional and lowest priority** — the heaviest model for -the smallest, most variable win ([GH #105](https://github.com/AlteredCraft/B2/issues/105)). - -The harness itself — corpora, labels, metrics, exit gates, process rules — is -[crates/b2-embed/evals/README.md](../crates/b2-embed/evals/README.md); read it before touching any of them. - -## 6. The AI seams — the embedder in a single binary, and grounded chat - -Two seams, both enumerated by M1 (ADR-0005) and both injected by the adapters. - -### `Embedder` - -Runtime, provisioning, and the model choice are -**[ADR-0020](../ADRs/0020-embeddings-inside-the-single-binary.md)**: `candle` + `hf-hub` compiled -into the binary, an explicit `b2 init` into a shared XDG cache, default `BAAI/bge-base-en-v1.5` -(768-dim, CLS-pooled, L2-normalized, bge's asymmetric query prefix), the dimension read from the -model's own `config.json`. - -The engine-side consequences are invariants: the embedding space has exactly one recorded identity — -`meta.(embed_model_id, embed_dim)` — and the compute **device folds into it** (ADR-0007, [GH #40](https://github.com/AlteredCraft/B2/issues/40)). A -swap drops both vector tables and re-embeds on `reindex`; `search` **fails fast** rather than mixing -spaces; `open` never mutates the vector space (M2). The fake embedder stays the CI default, so model -quality never enters the fast suite (E2). - -### `LlmProvider` — flow ④, grounded chat - -Chat is a **reader** of this index and adds nothing to it (M1): no table, no cached response, no -`meta` row, session-only history (S4). That is the deliberate contrast with the embedder — swapping -chat models never touches the index, so a provider swap is a URL/config change. - -`Vault::ask` (`chat.rs`) is five steps: **condense** (multi-turn only — a provider call rewrites the -follow-up into a standalone query, degrading to the raw question on failure, so that step can never -break chat) → **retrieve** (`search_chunks` at `ASK_PASSAGES = 10`, the §4 pipeline unchanged, -BM25-only fallback included) → **assemble** (the grounded system prompt + numbered passages — prompt -assembly is core logic, not an adapter's) → **stream** (tokens up through the caller's callback, whose -return value cancels at token granularity — sync, no runtime) → **cite** (`[n]` markers resolve to -`(path, excerpt)` in the returned `AnswerView`; a hallucinated marker resolves to nothing and the -answer text is **never rewritten**). - -Two properties follow from the seam's shape. Cancellation is returning early from a blocking read -loop, so **no B2 crate starts an async runtime** (ADR-0011) — a cut stream is marked cancelled and -what already arrived is rendered, never discarded, because a truncated answer is not an error. And -model output is untrusted content like any note (E5, ADR-0016), enforced at the render surface rather -than in the core. - -## 7. Tech-stack implications — resolved: Rust - -The **single-binary goal** picked the language, not the engine; SQLite and FTS5 are -language-agnostic. See ADR-0019. - -## 8. Risks & operational burden - -**Chunk vs. note granularity for the graph.** Search is chunk-level; the typed graph is note-level. -`chunks.note_path` is the join, and search hits resolve up to notes for graph operations (§3). - -### The bill for a path-keyed graph under `[[path|title]]` links - -Keying the graph by the vault-relative path (L1, ADR-0003) makes the stored key *the same thing the -human authored*. These are *the trade working as designed*, not defects; they must be budgeted, -tested, and watched. - -- **Write amplification on move.** The link *is* the path, so moving one note rewrites the inbound link - text in **every** file that points at it — an N-file write. It is bounded and mechanical (the - materialized edges name exactly which files and links to touch, Markdown-first then index), but - moving a heavily-linked note is proportional to its backlink count, not O(1). Keep the rewrite - transactional so a partial move never half-updates the vault. The index side is one cascading - `UPDATE` plus the re-projection of the inbound sources, bounded by the same count. -- **Out-of-band moves are identified, not repaired — and that is the scope decision.** A `git mv` or - Finder move is, to a path-keyed index, a delete plus a create: the old path's rows prune, the new - path projects fresh, and every inbound `[[oldpath]]` becomes a **surfaced dangling edge** (G5) — - authored text kept, `dst` NULL, healing by itself on the next pass if the target comes back. Nothing - is silently dropped and nothing is guessed at. **Content-addressed vectors (M4) make it cheap as - well as honest**: the re-created note's chunk text hashes identically, so the delete-plus-create - re-embeds nothing. Two repairs are future investigation in GH #170 — a *proposed* hash-match repair - (`notes.body_hash` and the resource `content_hash` are already stored, but a proposal must stay the - human's to accept, W4), and a `b2 watch` daemon observing renames live, rejected as a lifecycle and - coordination surface that shrinks the gap rather than closing it. -- **Path ownership follows the filesystem, so there is nothing to reconcile.** A path names at most - one file — the filesystem's guarantee, not B2's — so a note deleted and recreated at the same path is - that path's note, a Finder-duplicated note is two notes at two paths, and `db::upsert_note`'s - `ON CONFLICT(path)` is the whole of the reconciliation. A note file *deleted with no replacement* is - reconciled by the whole-vault pass ([GH #31](https://github.com/AlteredCraft/B2/issues/31)): - `project_vault` prunes every `notes` row whose path the walk did not see this run (aliases, chunks, - FTS, centroid, outgoing edges cascade; inbound links re-dangle when phase 2 re-derives edges against - the pruned resolver), **except** rows whose file was skipped as unreadable — the walk *saw* that - file, so evicting it would lie. Single-note ingest (`add`/`mv`/`write`) touches one note and never - prunes. Orphaned vectors — hashes no chunk references after that pruning — are collected by the same - pass, the only bookkeeping content-addressing adds. -- **A single unreadable file never fails the whole index.** A real vault holds the odd non-UTF-8 or - permission-denied `.md`; projection **skips** it (reported as a `skipped` entry carrying a short, - file-level reason, surfaced by the CLI and the desktop) and indexes everything else, rather than - aborting on one file it cannot read. -- **Derived-index consistency is a permanent invariant, not a one-time build.** W5, S3, and L1 are the - tripwires; every edit path (`b2 mv`, link delete, out-of-band reindex) has to preserve all three or - the graph silently diverges from the source of truth. -- **Committed edges are only ever authored, never inferred** (G1). Editing the vault can strand a - connection — deleting an authored `A→B` link — but B2 only ever *surfaces* the consequence, never - silently rewrites an inbound file or an edge (W4). - -*(The stamped `b2id` made "two files, one identity" representable and cost a collision subsystem, a -shadowed-copy panel, restamp notices, and a carve-out on S3. All of it went with the stamp — -ADR-0003. Nothing anomaly-shaped was ever *stored*, so nothing had to be migrated away.)* diff --git a/design/invariants.md b/design/invariants.md deleted file mode 100644 index 44c27cf..0000000 --- a/design/invariants.md +++ /dev/null @@ -1,321 +0,0 @@ ---- -title: "B2 — Invariants" -type: note -tags: [b2, invariants, architecture, canonical] -created: 2026-07-22 -status: active ---- - -# B2 — Invariants - -> The normative register of what must always be true of B2. Each entry is one testable claim; the -> linked doc holds the elaboration. This page is the top of the design set — the *why* — with the -> *what* in [data-model.md](data-model.md) and the *how* in [index-engine.md](index-engine.md). -> Product non-negotiables (local-first, zero lock-in, single-binary) are captured as invariants here. -> -> **On conflict, this page wins and the other doc gets fixed.** Changing this page is a deliberate -> decision, never a drive-by edit. Cite entries by id (S2, G2, …). -> -> The *why* behind an entry — the context it was ruled in, and what the ruling costs — lives in -> [`ADRs/`](../ADRs/README.md). Changing an entry here means writing or superseding an ADR there. - -The register is the two design tenets — *a volatile vault over a disposable index* and *build for -tomorrow's model* — made mechanical. - -## S — Storage: two tiers, one projection - -- **S1 — Two tiers, sharply split.** The vault (Markdown + resources + the directory tree) is the - source of truth; `.b2/b2.sqlite` is a disposable cache. Nothing in the index is authoritative. - ([data-model.md](data-model.md) "Two storage tiers") -- **S2 — The index is a pure projection: `index = projection of (the vault directory)`.** Drop - `b2.sqlite`, reindex, get an identical index. **Markdown is the vault's sole authored subset** — - the only format whose bytes B2 may write; resources contribute derived rows only, and folders are - never projected at all (read live off disk). The projected *domain* is the vault's **managed - subtree**: a dot-prefixed name is not vault material of any kind — folder, resource, or `.md` alike - — so it is skipped by every walk before routing, and refused as an authoring destination (B2 never - creates a member it would then never see). Such files stay on disk untouched, simply outside the - projection. ([data-model.md](data-model.md) §1, §10, - [index-engine.md](index-engine.md) §3, [GH #136](https://github.com/AlteredCraft/B2/issues/136)) -- **S3 — `full-reindex ≡ incremental-update`, unconditionally.** Re-deriving one changed note - converges on exactly the state a from-scratch rebuild would produce — including pruning rows for - deleted files on a whole-vault pass. There is no carve-out: with identity **path-keyed** (L1), the - filesystem itself guarantees one member per path, so the "two files presenting one identity" state - that once needed one (GH #81) cannot arise — a copy is simply another note at another path. - ([index-engine.md](index-engine.md) §8, [GH #170](https://github.com/AlteredCraft/B2/issues/170)) -- **S4 — No durable B2-derived state outside the Markdown.** No event log, no sidecar files, no - index-only authored facts. Scope: *B2-derived* data — the human's own directory tree is vault - material, for which the **filesystem is authoritative** (folders are never projected; the tree - listing is a live fs walk). ([data-model.md](data-model.md) "Folders") -- **S5 — Schema change = version bump + rebuild, never a data migration.** Disposability is what - makes this free; a migration script would be evidence S2 broke. - ([index-engine.md](index-engine.md) §3) - -## W — Write discipline: the vault changes only on your command - -- **W1 — B2 makes no unbidden writes. Period.** Every byte B2 writes to the vault is the mechanics of - an operation the human explicitly invoked (W3). Reading a vault — walking it, projecting it, - reindexing it — writes **nothing**, so `reindex` runs unchanged on a read-only vault and a - git-versioned vault shows no diff from having been indexed. This is the claim the `b2id` stamp - used to hold an asterisk over; removing the stamp (GH #170) is what let the asterisk go. - ([data-model.md](data-model.md) §1) -- **W2 — B2 never authors the body, and never asks it to carry B2 syntax.** The body is 100% the - human's document. The lone body write is the mechanical move-repair: rewriting an inbound - `[[oldpath|alias]]`'s *path text* when its target moves — fixing a link the human already wrote, - never adding one, aliases preserved verbatim. ([data-model.md](data-model.md) §0) -- **W3 — The on-command writes are enumerated and minimal:** append one `b2_relations:` entry on - `b2 link` (frontmatter, never the body); the move-repair of W2; the editor save (`Vault::write` — a - byte-honest splice of the *human's own* body bytes, guarded by a content-hash revision); the - frontmatter save (`Vault::write_frontmatter` — the same-guard splice of the *human's own* - frontmatter bytes, body untouched, and otherwise unjudged: B2 owns no line in that block); import - (`Vault::import_file`/`import_path` — the handed bytes copied verbatim, then projected); and - create/move/delete of notes, resources, and folders on explicit command. -- **W4 — B2 never deletes, moves, or archives vault files of its own accord.** Consequences of human - edits (orphans, dangling links, hash-matched move candidates) are *surfaced*, flagged, or proposed — - never silently applied. ([index-engine.md](index-engine.md) §8) -- **W5 — Round-trip losslessness.** `parse → serialize → parse` is byte-identical outside the specific - edit performed; unknown frontmatter keys survive verbatim, in order. B2's one key is namespaced - (`b2_relations`) so it can never collide; a generic `relations:` key is *not* read. A `b2id:` line - left by an older B2 is now exactly an unknown key — never read, never rewritten, never removed; - nothing needs migrating, and deleting `.b2/` is the whole upgrade (GH #170). - ([data-model.md](data-model.md) §1, §6) - -## L — Identity & links - -- **L1 — A note's identity is its vault-relative path, and the graph keys every edge by path** - (GH #170). Both link homes are *already* written by path — a body `[[path]]`, a frontmatter - `b2_relations:` entry — so an edge stores what the human authored, resolved at projection time, - with no machine id in the file and nothing to key on that the vault does not already carry. - Consequence: **rename keeps every backlink resolving *when B2 does the move*** — a move rewrites - the inbound path *text* and re-keys the moved note's rows in one transaction. A move made - **outside** B2 is a delete plus a create: the inbound links surface as dangling (G5) — identified, - never silently dropped — the durability a path handle has in Obsidian, one notch better in that B2 - says so. ([data-model.md](data-model.md) §1, §3) -- **L2 — A note's title is its filename.** The frontmatter `title:` key is recognized but inert — - round-tripped, never driving display, aliases, or search. `b2 link` therefore writes a bare - `[[path]]`, no alias. ([data-model.md](data-model.md) §1) -- **L3 — Notes and resources share one identity model: the vault-relative path, index-only, with no - sidecar files ever.** Since GH #170 the remaining asymmetry is **authoring surface alone**, not - status and no longer identity: a note has frontmatter and authored edges because Markdown is the - one format whose bytes B2 may write; a resource is a peer document B2 can read and never write. - ([data-model.md](data-model.md) §10) -- **L4 — The body is read strictly as ordinary Markdown.** Every body link — wikilink, Markdown link, - embed — is an untyped, **directed** `references` edge; no prose shape (list marker, leading verb) - is ever B2 structure. ([data-model.md](data-model.md) §2) - -## G — The typed graph - -- **G1 — Every edge is authored and active.** An edge exists iff it is written in the Markdown; there - is no `status` column, no suggestion queue, no lifecycle, and nothing inert. Committing is - appending an authored line and re-projecting, never an index mutation. - ([data-model.md](data-model.md) §3, §4) -- **G2 — The edge set is the union of exactly two homes, deduped frontmatter-wins.** Body links - (`origin=inline`, always untyped) ∪ frontmatter `b2_relations:` (`origin=frontmatter`, the **sole** - home of a verb + explanation). Same `(target, type)` in both homes keeps the frontmatter row (it - alone carries the explanation); a *different* verb over a body-linked target coexists (the augment - case). Nothing is ever copied between homes or auto-removed from a file. - ([data-model.md](data-model.md) §0–§3) -- **G3 — The relation vocabulary is a closed three-verb stance core plus a tolerated tail.** - `references` (neutral) / `supports` (for) / `contradicts` (against, symmetric) is the typing - palette and what queries rely on; any other verb is stored verbatim as an opaque tail. The closed - core is a *policy we can relax* (promotion path), never a structural assumption. - ([data-model.md](data-model.md) §2) -- **G4 — Edges are directed and stored once.** Inverse labels are display-only, computed at read - time; B2 never writes a reciprocal link into the target file. ([data-model.md](data-model.md) §2) -- **G5 — An unresolvable link target projects as a surfaced dangling edge, never a dropped one.** - Broken links read as broken (`dst` NULL, authored text kept) and heal on the next reindex once the - target exists. ([data-model.md](data-model.md) §3, GH #12) -- **G6 — The materialized graph is a cache; runtime parsing is the correctness definition.** The - `edges` table exists for what parsing can't serve — backlinks, typed traversal, the discovery - exclusion — and is rebuilt from scratch on every reindex. In v1 resources are edge *targets* only; - `src` is always a note, because an edge must trace to an authored Markdown line. - ([index-engine.md](index-engine.md) §3, [data-model.md](data-model.md) §10) - -## M — The AI seams & the embedding space - -- **M1 — Only enumerated AI seams: `Embedder` and `LlmProvider`.** `b2-core` is model-free and - tested against deterministic fakes; a real model drops in through its seam with **no schema or - flow change**. `Embedder` (text → vector) carries the index's one recorded identity (M2); - `LlmProvider` (chat — streamed, cooperatively cancellable) deliberately carries **none**: nothing - it produces is stored, so swapping chat models never touches the index. Model-compensating - machinery (per-pair adjudication, query expansion, heavy orchestration) is deferred or off by - default — the Bitter-Lesson tenet. A reranker, if it lands, is the next enumerated seam, not an - exception. ([index-engine.md](index-engine.md) §5–§6, GH #151/#153) -- **M2 — The embedding space has one recorded identity: `meta.(embed_model_id, embed_dim)` — and the - compute device folds into it** (a Metal build tags the id `@metal`). Any identity change is a model - swap: `search` **fails fast** rather than mixing spaces, `reindex` drops and re-embeds, and `open` - **never** mutates the vector space. ([index-engine.md](index-engine.md) §6, GH #40) -- **M3 — One embedding space in v1.** Every vault member funnels to *text* through the same model; - multimodal spaces and describers are documented future seams, default-off - ([GH #110](https://github.com/AlteredCraft/B2/issues/110)). - ([data-model.md](data-model.md) §10) -- **M4 — Vectors live in plain tables, scored in-process; their existence *is* the signal; and they - are keyed by the hash of what was embedded.** The vector tables are created at embed time, so - "tables exist" = "this vault has an embedding space" — the fallbacks (BM25-only search on a - projected-but-unembedded vault) key on it. `embeddings` is **content-addressed** - (`text_hash → vector`, GH #170): the embed input is exactly the chunk's stored text, so identical - text has one vector, a renamed or moved note re-embeds nothing, and the only invalidation rule is - "a hash no chunk references is garbage" — pruned by the whole-vault pass. Centroids are the same - derived data keyed by note path — refreshed by the embed pass, dropped on re-chunk. Model identity - is not part of the key because it need not be: a swap drops the whole table (M2). ([index-engine.md](index-engine.md) §3–§4, GH #38) -- **M5 — Note content is never sent off-machine unbidden.** A cloud model endpoint exists only by - explicit user configuration: the default chat configuration is a local endpoint, and a chat - request carries the question *and* retrieved note passages — so the consent moment is the - configuration moment, informed in place (plain-language privacy copy beside the Cloud-models - setting, never a later popup). (GH #151) - -## D — Surfacing & disclosure - -- **D1 — Discovery ranking answers a relative question; the default view answers a quality one; and - no anchor-local statistic ever makes a candidate unreachable.** "What in my vault belongs next to - this note?" is relative, so `b2 similar` and the discovery pane rank by best-passage distance and - the full ranked list stays reachable — `limit` is a cap that under-fills only for want of scorable - notes, and **an empty surface never asserts "nothing relates" from anchor-local statistics**: such - a test cannot distinguish *nothing is related* from *everything is related* (the same geometry - from opposite ends: a single-domain vault went dark on 16 of 17 notes, GH #196). Reachable is not - vouched for, though: **what the default view shows is a claim of quality, and filling to `limit` - regardless is a false one** — always-serve was GH #197's safe interim ruling, and real-vault - dogfooding (2026-08) measured its cost: a pane that always finds ten trains distrust of all ten. - A quality signal may therefore set the **default disclosure boundary** — a fold that is a *prefix* - of the ranked order (a signal that would admit rank 5 while folding rank 2 is inadmissible: row - order, band, and fold must never visibly disagree), with everything below it collapsed but one - gesture away, so a misjudged fold costs a keystroke where the retired gate cost the feature. Every - such signal is **evidence-gated** (it must win the measured bake-off on the orthogonal corpus, the - dense single-domain fixture — where a non-empty default view is absolute — and real vaults via - `make calibrate`, with "no fold at all" an admissible winner) and **continuous in population - size**: a threshold may move banding or the fold, never which rows exist or can be reached. - Strength stays a within-list grading painted from the z, which gates nothing. **The first - bake-off found no admissible fold** (GH #200, 2026-08-22): mutual-kNN reciprocity's window is - empty — on the orthogonal corpus the depth that stops hiding labelled mates is past the depth - that still empties a loner's view, while the dense fixture carries no loner and so supplies only - the lower bound and the absolute; and the two corpora's safe depths are the same *fraction* of - their candidate pools rather than the same constant — so the default view is still the whole - served prefix. The permission above stands unchanged for the next candidate; what the measurement - retires is one rule, not the axis. With no fold there were no discovery-side surfaces or gate - rows for GH #202 to land either: its exit-gate moves are all search's, and the harness keeps only - #200's **structural-zero tripwire**, which re-arms on its own if a fold ever ships. - ([index-engine.md](index-engine.md) §3, GH #196/#197/#200/#202; the - generation side's recall posture and 1-hop exclusion are unchanged) -- **D2 — A served search result is a claim of evidence, and `limit` is a quota nowhere in B2.** - Flow ②'s vector half always has k nearest — *nearest* is a fact about the vault, never evidence - about the query — and RRF fuses ranks, discarding the absolute signals that could tell the - difference, so the pipeline as first shipped could not answer zero: a nonsense query served - `limit` confident-looking results, the same false claim D1 names. The rule: a result in the - default view must trace to positive evidence — a lexical match, or semantic proximity clearing a - bar calibrated per model in the harness — and a query the vault holds no evidence for answers - **"no matches"**, honestly empty, the nearest-by-meaning list never presented as matches. The - clause that once said "at most folded behind D1's disclosure boundary" is struck: **#200 built no - such boundary**, and #202 ruled the human surface *strict* rather than folded (below). D1's - guards apply unchanged: any bar is a distributional constant (process rule 5's `make calibrate` - transfer check), earned against labelled negative queries in the eval corpus before it ships — - and **a labelled relevant query the bar would cut is the search-side tripwire, asserted at zero - with no headroom**. - **The engine now answers it** (GH #201, 2026-08-22): `hybrid_search` carries the discarded - signals beside the untouched fused order — per hit its rank in each list and its own distance, - per query the best cosine; the lexical reading is read beside it where a verdict is wanted - (`Vault::search_evidence`) — and the rule over them is *lexical OR - semantic*, two independent signals so the test can tell "nothing matches" from "everything - matches" where a one-signal one could not (D1's own reason, GH #196). The lexical half is - **IDF-weighted term coverage**: how much of the query's own weight the vault carries, a word in - most chunks weighing ~nothing and a word in none weighing the most — so a stopword is a - measurement, not a word-list, and a query sharing only a *function* word with the vault carries - almost none of its own weight and so is not anchored. Its first form was a hard - df ceiling and that form **failed process rule 5 on the dense fixture** (a 1.5-chunk ceiling - called `drone` and `comb` stopwords in a beekeeping vault, cutting 3 of 15 queries naming notes - the vault holds) — the register keeps that, because it is the same lesson twice: a constant read - off one corpus's distribution describes that corpus, and the fix was to change the *rule* rather - than re-tune the number. The verdict reaches an adapter through `Vault::search_evidence`, which - serves exactly the rows `search` does in the same order. The per-hit **tail** fold is unshipped - by **measurement** now, not by missing labels (GH #206, 2026-08-25): `tail_relevant` made the - labels exhaustive per query, the four-family prefix-cut bake-off ran on both corpora and a real - vault, and no admissible family reaches more than 23 of the **367** filler rows an oracle fold - would cut — the fused order is not an evidence order (filler outranks keep rows it loses to on - every absolute signal), and D1's prefix requirement rightly forbids a fold from re-sorting it. - So the tail complaint is an **ordering** problem, its payment the reranker seam M1 reserves, and - the bake-off re-arms every run - ([crates/b2-embed/evals/README.md](../crates/b2-embed/evals/README.md), the #206 verdict). - **The surfaces carry it now** (GH #202, 2026-08-22), and the verdict is **three-state, each state - a different behavior**: evidence found → serve as always; **no evidence → the honest empty state - and none of the rows** (*strict* — no reveal, no `--all`, no expander: any of those would put the - nearest list forward as candidates after all, which is the claim this invariant says a served row - makes; the nearest list becomes unreachable from the human surface, and that cost is accepted - because it is bounded to one query at a time, never a whole vault, which is what separates it - from the failure GH #196 measured); and **no calibrated bar for the active model → serve as - always, never "no matches"**, since that third state is what the fake embedder and every model - until the harness measures one produce (M2), and folding it into "no evidence" would blank a dev - vault. `b2 search --json` is consequently an **object** — the rows plus the verdict, a documented - break of the array contract, because a query-level reading has nowhere to live in a list of rows - — and it keeps serving the rows at `vouched: false` where the human surfaces show none: an agent - handed rows *plus* an explicit verdict can be honest about them where a reader given rows - alone cannot. - ([index-engine.md](index-engine.md) §4, GH #201/#202, - [crates/b2-embed/evals/README.md](../crates/b2-embed/evals/README.md) process rules) - -## E — Engineering discipline (what keeps the above true) - -- **E1 — The core is deterministic.** No wall-clock and no randomness inside `b2-core`; timestamps - are injected (`created` params) and nothing is minted at all — since GH #170 identity is the path, - so the id generator that was the core's other randomness source is gone rather than merely - injected. Clocks and log subscribers live in the adapters. - ([CLAUDE.md](../CLAUDE.md) Conventions) -- **E2 — `cargo test` is fast, deterministic, and model-free; model quality never enters CI.** - Real-model work lives behind `b2 init` / the out-of-CI eval harness. `#[ignore]` is forbidden — a - hard-to-write test is a signal to re-anchor on the invariant or fix the system. - ([crates/b2-embed/evals/README.md](../crates/b2-embed/evals/README.md), the harness under `crates/b2-embed/evals/`) -- **E3 — The `Vault` façade is the one typed API, and every adapter is dumb.** CLI and desktop - commands are deserialize → one façade call → serialize; logic that wants to live in an adapter - belongs behind the façade. Dependencies point one way (adapters → core, never back); façade ops are - added on need, never pre-built. ([crates/b2-desktop/CLAUDE.md](../crates/b2-desktop/CLAUDE.md)) -- **E4 — User-facing errors are generic and actionable, never leaking internals.** Full detail goes - to logs / `B2_DEBUG`, not to the terminal or webview. ([CLAUDE.md](../CLAUDE.md) Conventions) -- **E5 — Note content is untrusted input; rendering is a trust boundary.** Authorship is not trust: a - `.md` can come from anyone (a shared vault, a downloaded or web-clipped note), so B2 treats rendered - note content — and model output, which is the same class of input (M1) — as hostile. Two rules hold - together: B2 HTML-escapes every value *it* interpolates into UI chrome, and the **single** - Markdown→HTML render seam (`renderMarkdown`) sanitizes its output before it reaches the DOM, so no - note can inject executable markup at any call site. The webview CSP (`default-src 'self'`, no inline - scripts) is a second, independent layer — defense-in-depth, never the sole guard. The same posture - governs a note's **links**: the webview *is* the application, so a note's link never navigates it — - a web link (`http`, `https`, `mailto`) is an **OS handoff** performed host-side behind a scheme - allow-list, and every other scheme is refused rather than handed to an OS that would launch whatever - app claims it. - ([crates/b2-desktop/CLAUDE.md](../crates/b2-desktop/CLAUDE.md), GH #77) - -## C — Concurrency: many readers, one builder - -- **C1 — Any number of processes may hold one vault's index open at once.** The index is a - disposable projection, so concurrent *readers* are unrestricted and **a reader is never - refused** — opening an index already at the current `schema_version` takes no write lock at - all, so a running reindex cannot turn a `search` into an error. Creating and rebuilding that - projection is the one step that must be **atomic and serialized**: an `open` observes a complete - schema at the current `schema_version`, or waits out a bounded budget for the opener building one — - **never a partial schema**. The no-partial half is absolute; the waiting half is deliberately not — - past the budget a stuck writer is reported rather than hung on. "Complete" is checked, not assumed: - a current stamp over missing tables is stale and rebuilt from empty, since surviving rows would look - up-to-date to an incremental reindex and break S3. The same holds for the index's *other* - drop-and-rebuild, the vector tables (M4). Concurrent *writers* stay single-in-flight by the - `reindex` advisory lock — which readers never take, and so cannot cover this. - ([index-engine.md](index-engine.md) §3, GH #111, #114) - -## K — Interaction: keyboard-first - -- **K1 — B2 is fully operable from the keyboard; the mouse is an accelerator, never a requirement.** - Every action the mouse can take has a keyboard path — a focusable control in a sensible tab order, or - a documented shortcut — across the whole desktop surface: the file tree and open/create/rename/move/ - delete, search and find-in-note, edit mode (⌘E) and every in-editor chord, discovery and linking, - chat (⌘J), the graph, and each menu/modal (`Escape` dismisses, `Enter` confirms, focus is trapped - while an overlay is open and restored on close). Focus is always visible and follows platform/ARIA - conventions. Three corollaries: - - **A chord live in the app is B2's to document, whoever authored it.** The macOS menu bar's - accelerators are *declared* rather than inherited from Tauri's default (`b2-desktop/src/menu.rs`), - so the reference sheet can list them and the conflict gate can see them — a chord nothing - enumerates cannot be found, and the app cannot warn about landing on it. - - **The chords are the user's, not B2's.** Every chord B2 dispatches is re-recordable from - Settings → Keyboard and stored as a UI preference (`localStorage`, like the theme — never vault - state, never the index). The exception is narrow and stated per row (`Binding.fixed`): the - platform's own reflexes — ⏎/Esc in a text field, a dialog's default button, ⏎/Space on a - `