diff --git a/.gitignore b/.gitignore index b01d4fa..0af1ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ **/*.rs.bk Cargo.lock.orig +# cargo-llvm-cov artifacts (`just coverage*`). Both live under /target above, but the +# raw profiles are named here because a stale set silently skews the next report — +# `cargo llvm-cov clean` is the fix, never committing them. +/target/llvm-cov/ +/target/llvm-cov-target/ + # B2's disposable index — a pure projection of the Markdown, never authoritative. # Drop it and `b2 reindex` rebuilds it identical, so it must never be committed. .b2/ diff --git a/CLAUDE.md b/CLAUDE.md index 9bb273e..8e9b2d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,11 +46,20 @@ cargo test # whole workspace (compiles candle in b2 cargo test -p b2-core --test discover # one integration-test file (targets in tests/*.rs) cargo test -p b2-core one_note_reindex # filter by test-name substring +# Coverage (cargo-llvm-cov; `cargo install cargo-llvm-cov` + `rustup component add llvm-tools-preview`) +just coverage # engine line/region coverage — mirrors `just test`, no ML deps +just coverage-html # the same as a browsable per-line report under target/llvm-cov/ +just coverage-all # + the CLI adapter (its tests spawn the instrumented `b2` + # binary, so those runs count); heavier cold — b2-cli pulls candle +just coverage-lcov # lcov.info for editor gutters / a CI upload + # Real embedder (out of CI; needs the model provisioned first) cargo run -p b2-cli -- init # download + verify bge-base-en-v1.5 into the XDG cache cargo run -p b2-embed --example eval # retrieval + discovery quality eval (never in `cargo test`): # BM25-vs-hybrid lift, passage ranks, `similar`; appends each - # run to crates/b2-embed/evals/results.jsonl (gitignored) + # run to crates/b2-embed/evals/results.jsonl (gitignored). Also + # gates batch ≡ single embedding (a correctness check that needs + # the real model, so it lives here rather than behind #[ignore]) cargo run -p b2-embed --example eval -- --sweep # + in-process ChunkConfig A/B (the GH #44 gate) # Metal GPU embedder — research lever (GH #40, macOS-only). The `metal` cargo feature moves the @@ -69,8 +78,11 @@ just ui-install # one-time: install the frontend's npm d B2_VAULT_PATH=~/notes just app # run the app in dev (Vite HMR + a live Tauri window) B2_LOG_FILE=$PWD/logs/desktop.jsonl B2_VAULT_PATH=~/notes just app # + structured JSONL log ($PWD: cwd is crates/b2-desktop) just check-app # clippy for b2-desktop (builds ui/dist first) -(cd ui && npm test) # the frontend's pure-logic suite (pane sizing); no deps — - # node strips the TS types and runs off the source +(cd ui && npm test) # the frontend's pure-logic suite — node's own test runner over + # src/*.test.ts (globbed, so a new file is never silently + # skipped), stripping the TS types and running off the source. + # Needs `just ui-install` first: most cases are dependency-free, + # but paste.test.ts exercises the real turndown conversion cargo fmt cargo clippy --workspace --exclude b2-desktop # fast lint gate (desktop needs ui/dist; see check-app) @@ -256,7 +268,19 @@ invalidation exists or is needed. implementation detail (a retired dependency's constant, a since-changed fixture assumption) rather than a real invariant — re-anchor it on the invariant, or fix the system. When the resolution isn't obvious, **open a conversation with the user** to work through it; do not reach for `#[ignore]`, a slow/brittle - fixture, or a weakened assertion to move on. + fixture, or a weakened assertion to move on. **A check that genuinely needs the real model belongs in the + eval harness, not in `cargo test` wearing an `#[ignore]`** — `--example eval` runs on demand and actually + runs, which is the whole point (the batch ≡ single embedding check is the worked example). +- **Shared test scaffolding lives in `crates/b2-core/tests/common/mod.rs`.** Fixture setup + (`reindexed_vault` / `opened_vault` / `ingest_golden`) and the read-back shims (`index_conn`, `count`) + are there; a helper wanted by more than one file goes there rather than being copied. What only one file + needs — a purpose-built vault, a bespoke row snapshot — stays in that file. The one deliberate exception + is the tracing `Capture` writer duplicated by `tests/logging.rs` and `tests/discover_query_count.rs`: + hoisting it would make every test binary link `tracing-subscriber` to serve two, and those two already + need their own binaries (tracing's global callsite-interest cache races across parallel test threads). +- **A test's name is part of its contract.** If the name claims more than the body asserts, the suite reads + as covering ground it doesn't — the same silent gap as `#[ignore]`. `just coverage` is how you find the + other half of that problem: a line the suite never executes. - **User-facing errors are generic and actionable, never leaking internals** (sqlite/io/serde). The CLI funnels everything through `user_message` (`b2-cli/src/main.rs`); `B2_DEBUG` opts into detail. This matches the repo-wide logging policy in the parent `CLAUDE.md`. diff --git a/crates/b2-core/src/link.rs b/crates/b2-core/src/link.rs index 4f6264b..971180b 100644 --- a/crates/b2-core/src/link.rs +++ b/crates/b2-core/src/link.rs @@ -373,6 +373,39 @@ mod tests { assert!(!l.embed); } + /// The exact hazard that killed the body typed-line syntax (decision + /// 2026-07-21): a *lowercase* verb lookalike opening a list item must stay + /// prose. `- see [[x]]` becoming a typed edge of verb "see" is the failure + /// mode; only the wikilink may project, always untyped. + #[test] + fn lowercase_verb_lookalikes_in_prose_stay_prose() { + let links = parse_links("- see [[concepts/memory|Human memory]] for the mechanism\n"); + assert_eq!(links.len(), 1); + assert_eq!(links[0].edge_type, "references"); + assert!(!links[0].typed); + } + + /// A prose link and a verb-led list item are the same thing to the parser: two + /// `references` edges in document order, neither typed and neither carrying an + /// explanation — no body shape is ever "special". + #[test] + fn body_links_never_gain_a_type_from_surrounding_prose() { + let body = "Spaced repetition exploits the [[concepts/memory|Human memory]] retrieval curve.\n\n## Relations\n- supports [[concepts/memory|Human memory]] — applies the forgetting curve\n"; + let links = parse_links(body); + assert_eq!(links.len(), 2); + assert!(links.iter().all(|l| l.edge_type == "references")); + assert!(links.iter().all(|l| !l.typed && l.explanation.is_none())); + } + + /// `alias` is the wikilink's own `|`-part, distinct from the display `caption` + /// the other cases assert: absent means `None`, never an empty string. + #[test] + fn a_wikilink_without_an_alias_keeps_a_none_alias() { + let links = parse_links("Refer to [[concepts/memory]].\n"); + assert_eq!(links[0].target_path, "concepts/memory"); + assert_eq!(links[0].alias, None); + } + #[test] fn parse_relation_reads_verb_link_and_explanation() { let l = parse_relation("supports [[papers/x.pdf|the paper]] — key evidence").unwrap(); @@ -391,6 +424,22 @@ mod tests { assert!(parse_relation("just some words").is_none()); } + /// The two accepted explanation separators and the tolerated verb tail + /// (relation.rs): `—` is asserted above, `:` here, and a non-core verb is + /// stored verbatim rather than coerced into the closed core. + #[test] + fn relation_accepts_a_colon_separator_and_keeps_a_tail_verb_verbatim() { + let colon = + parse_relation("supersedes [[notes/old-plan|Old plan]] : replaced after Q2").unwrap(); + assert_eq!(colon.edge_type, "supersedes"); + assert_eq!(colon.explanation.as_deref(), Some("replaced after Q2")); + + let tail = parse_relation("inspired-by [[notes/x|X]]").unwrap(); + assert!(tail.typed); + assert_eq!(tail.edge_type, "inspired-by"); + assert_eq!(tail.explanation, None); + } + #[test] fn malformed_forms_do_not_derail_the_scan() { // an unclosed wikilink on one line never hides the next line's links diff --git a/crates/b2-core/src/relation.rs b/crates/b2-core/src/relation.rs index 104fbd1..910c118 100644 --- a/crates/b2-core/src/relation.rs +++ b/crates/b2-core/src/relation.rs @@ -56,3 +56,57 @@ pub fn is_symmetric(verb: &str) -> bool { pub fn inverse_label(verb: &str) -> &str { core(verb).map_or(verb, |c| c.inverse) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The core is *closed* and its membership is what `b2 link` validates against + /// (`Vault::link` refuses a non-core verb). A verb silently joining or leaving + /// it would change the typing palette, so pin the set itself. + #[test] + fn the_core_is_exactly_the_three_stance_verbs() { + let verbs: Vec<&str> = CORE.iter().map(|c| c.verb).collect(); + assert_eq!(verbs, vec!["references", "supports", "contradicts"]); + assert!(verbs.iter().all(|v| is_core(v))); + // The tolerated tail is stored verbatim but is never *core*. + for tail in ["inspired-by", "supersedes", "Supports", ""] { + assert!(!is_core(tail), "{tail:?} must not be a core verb"); + assert!(core(tail).is_none()); + } + } + + /// Stance is the one thing embedding similarity cannot infer, and `contradicts` + /// is the only verb that reads the same from both ends. Its symmetry is the + /// single non-trivial fact in this module. + #[test] + fn only_contradicts_is_symmetric_and_tail_verbs_are_directed() { + assert!(is_symmetric("contradicts")); + assert!(!is_symmetric("references")); + assert!(!is_symmetric("supports")); + // An unknown verb is opaque, so it is treated as directed — never guessed + // symmetric off its spelling. + assert!(!is_symmetric("inspired-by")); + assert!(!is_symmetric("")); + } + + /// Inverse labels are display-only (the edge is stored once, directed): each + /// core verb has its own, a symmetric verb is its own inverse, and a tail verb + /// falls back to itself rather than gaining an invented "-by" form. + #[test] + fn inverse_labels_cover_core_symmetric_and_tail() { + assert_eq!(inverse_label("references"), "referenced-by"); + assert_eq!(inverse_label("supports"), "supported-by"); + assert_eq!(inverse_label("contradicts"), "contradicts"); + assert_eq!(inverse_label("inspired-by"), "inspired-by"); + // Every core verb's inverse round-trips through the symmetry flag. + for c in CORE { + assert_eq!( + c.symmetric, + c.verb == c.inverse, + "{}: `symmetric` must agree with the inverse label", + c.verb + ); + } + } +} diff --git a/crates/b2-core/tests/add.rs b/crates/b2-core/tests/add.rs index fbcc635..c846ed5 100644 --- a/crates/b2-core/tests/add.rs +++ b/crates/b2-core/tests/add.rs @@ -7,24 +7,13 @@ mod common; use b2_core::vault::Vault; use b2_core::Error; -use common::{golden_vault_copy, MEMORY_ID}; +use common::{reindexed_vault, MEMORY_ID}; use std::fs; -use std::path::{Path, PathBuf}; - -/// A reindexed golden vault under a temp dir; returns (vault, vault_root). Gives -/// `add` real notes to link to for the edge-projection test. -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} #[test] fn add_writes_a_stamped_note_and_projects_it() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let report = vault .add_note( @@ -69,7 +58,7 @@ fn add_writes_a_stamped_note_and_projects_it() { #[test] fn add_projects_the_edges_its_body_authors() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); // A note whose body links to an existing golden note. let report = vault @@ -101,7 +90,7 @@ fn add_projects_the_edges_its_body_authors() { #[test] fn add_creates_missing_parent_directories() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); vault .add_note("deeply/nested/dir/note", None, None) @@ -130,7 +119,7 @@ fn add_works_on_a_never_reindexed_vault() { #[test] fn add_refuses_to_clobber_an_existing_file() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); // Onto an existing golden note. let err = vault @@ -155,7 +144,7 @@ fn add_refuses_to_clobber_an_existing_file() { #[test] fn create_note_writes_a_stamped_minimal_note_model_free() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let before = vault.embed_status().unwrap(); let report = vault.create_note("inbox/idea").unwrap(); @@ -193,7 +182,7 @@ fn create_note_writes_a_stamped_minimal_note_model_free() { #[test] fn create_note_refuses_clobber_and_invalid_paths() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let err = vault.create_note("concepts/memory").unwrap_err(); assert!(matches!(err, Error::AddTargetExists(p) if p == "concepts/memory.md")); @@ -211,7 +200,7 @@ fn create_note_refuses_clobber_and_invalid_paths() { #[test] fn add_rejects_an_invalid_path() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); for bad in ["../escape.md", "/abs/path.md", " "] { assert!( diff --git a/crates/b2-core/tests/cancel.rs b/crates/b2-core/tests/cancel.rs index cfb048c..e3cf9c7 100644 --- a/crates/b2-core/tests/cancel.rs +++ b/crates/b2-core/tests/cancel.rs @@ -13,15 +13,9 @@ use b2_core::id::UlidGen; use b2_core::ingest::ingest_vault_with_progress; use b2_core::open; use b2_core::vault::Vault; -use common::golden_vault_copy; -use rusqlite::Connection; +use common::{count, golden_vault_copy}; use std::ops::ControlFlow; -fn count(conn: &Connection, table: &str) -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() -} - #[test] fn cancel_after_first_batch_leaves_a_consistent_resumable_index() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-core/tests/chunks.rs b/crates/b2-core/tests/chunks.rs index 25572c7..42f3684 100644 --- a/crates/b2-core/tests/chunks.rs +++ b/crates/b2-core/tests/chunks.rs @@ -463,23 +463,47 @@ fn prepend_heading_path_seeds_the_embedded_text() { assert!(!body[hit.char_start..hit.char_end].starts_with(hp)); } +/// `weights` is a real lever, not decoration — the scorer reads the numbers from +/// the config rather than hard-coding the qmd gradient. +/// +/// Two halves: with the default gradient (H2 = 90 ≫ blank_line = 20) a cut near a +/// section break snaps to the heading, so each section keeps its own breadcrumb; +/// demote headings *below* every other boundary and the same body cuts somewhere +/// else. Only `weights.heading` differs between the two runs, so the difference is +/// attributable to it alone. #[test] -fn weights_are_a_lever_a_giant_heading_weight_pulls_the_cut() { - // Sanity that the scorer is wired to the config: default weights split a two- - // section body at the heading boundary, so each section keeps its own path. +fn heading_weight_is_a_lever_on_where_the_cut_lands() { let cfg = ChunkConfig { target_tokens: 10, overlap_frac: 0.0, - weights: BreakWeights::default(), ..ChunkConfig::default() }; let body = "## Alpha\n\n\ Alpha section prose with a unique aardvark token inside of it here.\n\n\ ## Beta\n\n\ Beta section prose with a unique buffalo token inside of it here.\n"; + let chunks = chunk_body(body, &cfg); let a = chunks.iter().find(|c| c.text.contains("aardvark")).unwrap(); let b = chunks.iter().find(|c| c.text.contains("buffalo")).unwrap(); assert_eq!(a.heading_path.as_deref(), Some("Alpha")); assert_eq!(b.heading_path.as_deref(), Some("Beta")); + let default_cuts: Vec = chunks.iter().map(|c| c.char_start).collect(); + + let flattened = ChunkConfig { + weights: BreakWeights { + heading: [0; 6], + ..BreakWeights::default() + }, + ..cfg.clone() + }; + let flat_cuts: Vec = chunk_body(body, &flattened) + .iter() + .map(|c| c.char_start) + .collect(); + assert_ne!( + default_cuts, flat_cuts, + "zeroing the heading weights must move the cuts — else `weights` is inert \ + and the qmd gradient is hard-coded ({default_cuts:?} vs {flat_cuts:?})" + ); } diff --git a/crates/b2-core/tests/common/mod.rs b/crates/b2-core/tests/common/mod.rs index 1830bed..5fa8b80 100644 --- a/crates/b2-core/tests/common/mod.rs +++ b/crates/b2-core/tests/common/mod.rs @@ -1,15 +1,36 @@ //! Shared helpers for the integration tests (golden-vault fixtures). +//! +//! Every test binary that says `mod common;` gets this whole file, so it is +//! deliberately small and dependency-light: fixture setup and the two or three +//! read-back shims almost every file needs. Anything that only one file wants +//! (a purpose-built vault, a bespoke row snapshot) stays in that file. +//! +//! One thing that is **not** here on purpose: the tracing `MakeWriter` capture +//! `tests/logging.rs` and `tests/discover_query_count.rs` each define. Hoisting it +//! would make all ~28 test binaries link `tracing-subscriber` to serve two, and +//! those two are already documented as needing their own binary (tracing's global +//! callsite-interest cache races across parallel test threads). The 20 duplicated +//! lines are the price of that isolation. #![allow(dead_code)] -use b2_core::id::IdGen; +use b2_core::embed::FakeEmbedder; +use b2_core::id::{IdGen, UlidGen}; +use b2_core::ingest::ingest_vault; +use b2_core::open; +use b2_core::vault::Vault; +use rusqlite::Connection; use std::cell::Cell; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; /// b2ids of the two golden-vault notes (data-model.md §8). pub const MEMORY_ID: &str = "01JMEM0000000000000000000A"; pub const SRS_ID: &str = "01JSRS0000000000000000000B"; +/// Vault-relative paths of the two golden-vault notes. +pub const MEMORY_PATH: &str = "concepts/memory.md"; +pub const SRS_PATH: &str = "notes/spaced-repetition.md"; + /// Deterministic id generator so stamped `b2id`s are assertable. pub struct FixedId(pub &'static str); impl IdGen for FixedId { @@ -60,3 +81,49 @@ pub fn golden_vault_copy(dst: &Path) { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); copy_dir(&src, dst); } + +// --- façade fixtures ------------------------------------------------------------- + +/// A golden-vault copy under `dir/vault`, opened but **not** reindexed — the +/// index-free starting point (structure reads, "before the first reindex" cases). +/// Returns `(vault, vault_root)`. +pub fn opened_vault(dir: &Path) -> (Vault, PathBuf) { + let root = dir.join("vault"); + golden_vault_copy(&root); + let vault = Vault::open(&root).unwrap(); + (vault, root) +} + +/// A golden-vault copy under `dir/vault`, opened and fully reindexed (projected + +/// fake-embedded) — the ordinary starting point for façade tests. Returns +/// `(vault, vault_root)`. +pub fn reindexed_vault(dir: &Path) -> (Vault, PathBuf) { + let (vault, root) = opened_vault(dir); + vault.reindex().unwrap(); + (vault, root) +} + +// --- index read-back ------------------------------------------------------------- + +/// A second connection onto a vault root's index, for assertions the façade does +/// not surface (raw rows, table counts). +pub fn index_conn(root: &Path) -> Connection { + open(&root.join(".b2").join("b2.sqlite")).unwrap() +} + +/// Row count of `table`. +pub fn count(conn: &Connection, table: &str) -> i64 { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() +} + +/// Ingest the golden vault into a standalone `dir/b2.sqlite`, for the module-level +/// tests that drive `ingest_vault` directly instead of going through the façade. +/// The embedder is explicit because the dimension is load-bearing in some suites. +pub fn ingest_golden(dir: &Path, embedder: &FakeEmbedder) -> Connection { + let vault = dir.join("vault"); + golden_vault_copy(&vault); + let conn = open(&dir.join("b2.sqlite")).unwrap(); + ingest_vault(&conn, &vault, &UlidGen, embedder).unwrap(); + conn +} diff --git a/crates/b2-core/tests/dirs.rs b/crates/b2-core/tests/dirs.rs index 05e585f..6a22129 100644 --- a/crates/b2-core/tests/dirs.rs +++ b/crates/b2-core/tests/dirs.rs @@ -6,25 +6,14 @@ mod common; -use b2_core::vault::Vault; use b2_core::Error; -use common::golden_vault_copy; +use common::opened_vault; use std::fs; -use std::path::{Path, PathBuf}; - -/// A golden vault copied under a temp dir (no reindex — structure reads are -/// index-free); returns (vault, vault_root). -fn opened(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - (vault, root) -} #[test] fn list_dirs_returns_every_folder_sorted_including_empty_ones() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = opened(tmp.path()); + let (vault, root) = opened_vault(tmp.path()); // An empty folder (with an empty nested child) made outside B2 — Finder, mkdir. fs::create_dir_all(root.join("projects/2026")).unwrap(); @@ -45,7 +34,7 @@ fn list_dirs_returns_every_folder_sorted_including_empty_ones() { #[test] fn list_dirs_is_index_free_and_skips_dot_folders() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = opened(tmp.path()); + let (vault, root) = opened_vault(tmp.path()); // Never reindexed — `.b2/` exists (Vault::open creates it) and `.obsidian/` // simulates a sibling tool; both are dot-folders, never vault structure. @@ -58,7 +47,7 @@ fn list_dirs_is_index_free_and_skips_dot_folders() { #[test] fn create_dir_makes_a_real_folder_on_disk_that_lists() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = opened(tmp.path()); + let (vault, root) = opened_vault(tmp.path()); let report = vault.create_dir("projects").unwrap(); assert_eq!(report.dir, "projects"); @@ -69,7 +58,7 @@ fn create_dir_makes_a_real_folder_on_disk_that_lists() { #[test] fn create_dir_creates_missing_parents_and_tolerates_a_trailing_slash() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = opened(tmp.path()); + let (vault, root) = opened_vault(tmp.path()); // The UI's inline input allows nesting ("projects/2026"), like `mkdir -p`. let report = vault.create_dir("projects/2026/q3/").unwrap(); @@ -80,7 +69,7 @@ fn create_dir_creates_missing_parents_and_tolerates_a_trailing_slash() { #[test] fn create_dir_refuses_an_existing_folder_or_file() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = opened(tmp.path()); + let (vault, _root) = opened_vault(tmp.path()); // An existing folder: refused, not silently a no-op — the user asked to // *create* something, and it's already there. @@ -98,7 +87,7 @@ fn create_dir_refuses_an_existing_folder_or_file() { #[test] fn create_dir_rejects_invalid_and_hidden_paths() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = opened(tmp.path()); + let (vault, root) = opened_vault(tmp.path()); for bad in ["", " ", "/abs", "../up", "a/../../b", ".b2", "a/.git/b"] { assert!( diff --git a/crates/b2-core/tests/discover.rs b/crates/b2-core/tests/discover.rs index 16ac243..9c3d61c 100644 --- a/crates/b2-core/tests/discover.rs +++ b/crates/b2-core/tests/discover.rs @@ -16,7 +16,7 @@ use b2_core::embed::FakeEmbedder; use b2_core::id::UlidGen; use b2_core::ingest::ingest_vault; use b2_core::open; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; +use common::{ingest_golden, MEMORY_ID, SRS_ID}; use rusqlite::Connection; use std::collections::BTreeSet; use std::fs; @@ -49,14 +49,6 @@ fn linked_chain_vault(dir: &Path) -> Connection { conn } -fn ingest_golden(dir: &Path) -> Connection { - let vault = dir.join("vault"); - golden_vault_copy(&vault); - let conn = open(&dir.join("b2.sqlite")).unwrap(); - ingest_vault(&conn, &vault, &UlidGen, &FakeEmbedder::new(64)).unwrap(); - conn -} - fn note_set(cands: &[CandidateNote]) -> BTreeSet { cands.iter().map(|c| c.note_b2id.clone()).collect() } @@ -140,7 +132,7 @@ fn a_directly_connected_pair_yields_no_candidates() { // The golden vault is two notes, directly connected (spaced-repetition supports // /references human-memory), so each is within 1 hop of the other → no candidates. let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); assert!(discover::candidates(&conn, SRS_ID, 10).unwrap().is_empty()); assert!(discover::candidates(&conn, MEMORY_ID, 10) diff --git a/crates/b2-core/tests/embed.rs b/crates/b2-core/tests/embed.rs index 88f2e84..af55ae5 100644 --- a/crates/b2-core/tests/embed.rs +++ b/crates/b2-core/tests/embed.rs @@ -11,28 +11,15 @@ use b2_core::embed::{Embedder, FakeEmbedder}; use b2_core::id::UlidGen; use b2_core::ingest::ingest_vault; use b2_core::open; -use common::{golden_vault_copy, SRS_ID}; +use common::{count, golden_vault_copy, ingest_golden, SRS_ID}; use rusqlite::Connection; use std::ops::ControlFlow; -fn ingest_golden(dir: &std::path::Path, embedder: &FakeEmbedder) -> Connection { - let vault = dir.join("vault"); - golden_vault_copy(&vault); - let conn = open(&dir.join("b2.sqlite")).unwrap(); - ingest_vault(&conn, &vault, &UlidGen, embedder).unwrap(); - conn -} - fn meta(conn: &Connection, key: &str) -> Option { conn.query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) .ok() } -fn count(conn: &Connection, table: &str) -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() -} - #[test] fn fake_embedder_is_deterministic() { let e = FakeEmbedder::new(16); @@ -301,3 +288,47 @@ fn changing_dim_recreates_the_vector_space_and_clears_vectors() { "swap drops centroids with the vectors they summarize" ); } + +/// The other half of the model-swap contract. `ensure_embedding_space` (above) +/// covers what a *reindex* does — drop the stale vectors and re-embed. This covers +/// what happens **before** anyone reindexes: `open` deliberately never touches the +/// vector space, so a vault can sit with vectors from one model while a different +/// one is configured. Ranking those stored vectors against a query vector from the +/// new model would be silently wrong, so `search` refuses instead. +#[test] +fn search_fails_fast_on_a_model_swap_and_a_reindex_heals_it() { + use b2_core::vault::Vault; + use b2_core::Error; + + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("vault"); + golden_vault_copy(&root); + + // Index the vault under a 64-dim embedder. + let vault = Vault::open_with_embedder(&root, Box::new(FakeEmbedder::new(64))).unwrap(); + vault.reindex().unwrap(); + assert!(!vault.search("forgetting", 5).unwrap().is_empty()); + drop(vault); + + // Reopen with a different dimension — a model swap, as far as the recorded + // identity is concerned. + let swapped = Vault::open_with_embedder(&root, Box::new(FakeEmbedder::new(128))).unwrap(); + let err = swapped.search("forgetting", 5).unwrap_err(); + assert!( + matches!(err, Error::ModelMismatch { .. }), + "a swap must fail fast, not rank on incomparable vectors: {err:?}" + ); + + // `open` left the stored vectors alone (so a misconfigured model can never wipe + // a vault's embeddings) — the refusal is a query-time guard, not a migration. + let conn = open(&root.join(".b2").join("b2.sqlite")).unwrap(); + assert!(count(&conn, "embeddings") > 0, "vectors survive the reopen"); + assert_eq!(meta(&conn, "embed_dim").as_deref(), Some("64")); + drop(conn); + + // The documented fix: reindex re-creates the space at the new dimension. + swapped.reindex().unwrap(); + assert!(!swapped.search("forgetting", 5).unwrap().is_empty()); + let conn = open(&root.join(".b2").join("b2.sqlite")).unwrap(); + assert_eq!(meta(&conn, "embed_dim").as_deref(), Some("128")); +} diff --git a/crates/b2-core/tests/explain.rs b/crates/b2-core/tests/explain.rs index ba4b4e9..66ba3a5 100644 --- a/crates/b2-core/tests/explain.rs +++ b/crates/b2-core/tests/explain.rs @@ -5,24 +5,13 @@ mod common; -use b2_core::vault::Vault; -use b2_core::Error; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; +use common::{reindexed_vault, MEMORY_ID, SRS_ID}; use std::fs; -use std::path::{Path, PathBuf}; - -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} #[test] fn explain_shows_the_header_and_outbound_edges_with_their_why() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let view = vault.explain("notes/spaced-repetition").unwrap(); // Header: the note resolved to its identity + display fields. @@ -79,7 +68,7 @@ fn explain_surfaces_outbound_resource_links() { // must be visible from the *note's* side (not only as the resource's backlinks), // else a graph over `explain` silently hides a note's file links. let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); fs::write( root.join("notes/uses-diagram.md"), "---\nb2id: 01JUSD0000000000000000000C\ntype: note\ntitle: Uses diagram\n---\n\ @@ -114,7 +103,7 @@ fn explain_surfaces_unresolved_folder_and_typo_links() { // typo — resolves to nothing. `explain` must surface it as an unresolved link, // distinct from a resolved connection, so a broken link reads as broken not gone. let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); fs::write( root.join("guide.md"), "---\nb2id: 01JGUIDE00000000000000001\ntype: note\ntitle: Guide\n---\n\ @@ -139,7 +128,7 @@ fn explain_surfaces_unresolved_folder_and_typo_links() { #[test] fn explain_shows_inbound_backlinks_with_inverse_labels() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); // Memory is only pointed *at* (by SRS) — inbound edges, inverse-labelled. let view = vault.explain(MEMORY_ID).unwrap(); @@ -165,7 +154,7 @@ fn explain_shows_inbound_backlinks_with_inverse_labels() { #[test] fn explain_resolves_by_path_and_by_b2id() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let by_path = vault.explain("concepts/memory").unwrap(); let by_id = vault.explain(MEMORY_ID).unwrap(); @@ -178,7 +167,7 @@ fn explain_surfaces_frontmatter_provenance() { // An edge accepted into (or authored in) frontmatter reads as origin=frontmatter, // distinct from a human body link — the provenance data-model §0 says explain shows. let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); fs::write( root.join("author.md"), "---\nb2id: 01JAUTH000000000000000001\ntype: note\ntitle: Author\n\ @@ -201,7 +190,7 @@ fn explain_surfaces_frontmatter_provenance() { #[test] fn explain_reports_an_isolated_note_with_no_connections() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); fs::write( root.join("lonely.md"), "---\nb2id: 01JLONELY0000000000000001\ntype: note\ntitle: Lonely\n---\nNo links at all.\n", @@ -222,13 +211,3 @@ fn explain_reports_an_isolated_note_with_no_connections() { view.unresolved ); } - -#[test] -fn explain_unknown_note_is_note_not_found() { - let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); - assert!(matches!( - vault.explain("does/not/exist").unwrap_err(), - Error::NoteNotFound(r) if r == "does/not/exist" - )); -} diff --git a/crates/b2-core/tests/graph.rs b/crates/b2-core/tests/graph.rs index a6f131f..2b98c46 100644 --- a/crates/b2-core/tests/graph.rs +++ b/crates/b2-core/tests/graph.rs @@ -8,7 +8,7 @@ use b2_core::graph::{neighbors, unresolved_outbound, Direction}; use b2_core::id::UlidGen; use b2_core::ingest::{ingest_file, ingest_vault}; use b2_core::open; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; +use common::{golden_vault_copy, ingest_golden, MEMORY_ID, SRS_ID}; use rusqlite::Connection; use std::fs; @@ -49,18 +49,10 @@ fn edge_snapshot(conn: &Connection) -> Vec { .collect() } -fn ingest_golden(dir: &std::path::Path) -> Connection { - let vault = dir.join("vault"); - golden_vault_copy(&vault); - let conn = open(&dir.join("b2.sqlite")).unwrap(); - ingest_vault(&conn, &vault, &UlidGen, &FakeEmbedder::default()).unwrap(); - conn -} - #[test] fn golden_graph_has_inline_references_and_frontmatter_supports() { let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::default()); let edges = edge_snapshot(&conn); assert_eq!( @@ -91,35 +83,33 @@ fn golden_graph_has_inline_references_and_frontmatter_supports() { ); } +/// `graph::neighbors` at the raw-edge layer: the same stored edge reads as the +/// verb from the source end and as its inverse label from the target end, with no +/// reciprocal row (B2 stores each edge once, directed). The façade's resolved view +/// of the same pair — paths, titles, ref-form equivalence — is `tests/vault.rs`. #[test] -fn neighbors_of_memory_are_referenced_by_and_supported_by() { +fn neighbors_label_by_direction_at_both_ends() { let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::default()); - let ns = neighbors(&conn, MEMORY_ID).unwrap(); - let mut labels: Vec<&str> = ns.iter().map(|n| n.label.as_str()).collect(); + // The target end: inbound edges, inverse-labelled, all from spaced-repetition. + let inbound = neighbors(&conn, MEMORY_ID).unwrap(); + let mut labels: Vec<&str> = inbound.iter().map(|n| n.label.as_str()).collect(); labels.sort_unstable(); assert_eq!(labels, vec!["referenced-by", "supported-by"]); - - // both are inbound edges from spaced-repetition (B2 stores no reciprocal link) - assert!(ns + assert!(inbound .iter() .all(|n| n.other == SRS_ID && n.direction == Direction::Inbound)); -} - -#[test] -fn neighbors_of_spaced_repetition_are_outbound() { - let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); - let ns = neighbors(&conn, SRS_ID).unwrap(); - let mut labels: Vec<&str> = ns.iter().map(|n| n.label.as_str()).collect(); + // The source end: the very same two edges, outbound, labelled by their verbs. + let outbound = neighbors(&conn, SRS_ID).unwrap(); + let mut labels: Vec<&str> = outbound.iter().map(|n| n.label.as_str()).collect(); labels.sort_unstable(); - // outbound labels are the verbs themselves assert_eq!(labels, vec!["references", "supports"]); - assert!(ns + assert!(outbound .iter() .all(|n| n.other == MEMORY_ID && n.direction == Direction::Outbound)); + assert_eq!(inbound.len(), outbound.len(), "one edge set, two views"); } #[test] diff --git a/crates/b2-core/tests/ingest_resolver.rs b/crates/b2-core/tests/ingest_resolver.rs index 6c18925..c711a25 100644 --- a/crates/b2-core/tests/ingest_resolver.rs +++ b/crates/b2-core/tests/ingest_resolver.rs @@ -1,117 +1,38 @@ -//! Step 1 — ingest into `notes`/`note_aliases` and the `b2id ⇄ path` resolver. +//! Step 1 — ingest into `notes`/`note_aliases` and the `b2id ⇄ path` resolver +//! (index-engine.md): ingest the golden vault and resolve `memory ⇄ path` both +//! ways, then prove `aliases:` projects into the alias table. //! -//! Green-scenario assertions for build-plan step 1 -//! (index-engine.md): ingest the golden vault, resolve -//! `memory ⇄ path` both ways, and prove a note missing a `b2id` is stamped on disk -//! (B2's one always-allowed write; the id travels in the frontmatter — data-model.md §1). +//! *Stamping is not here.* It is `tests/stamp.rs`'s subject end to end (the exact +//! inserted bytes, the invalid-YAML case, and the reindex-settles-after-one-pass +//! loop), with `tests/props.rs` proving the surgical-insertion property over +//! generated input. This file is only about what ingest *projects*. -use b2_core::id::IdGen; +mod common; + +use b2_core::embed::FakeEmbedder; +use b2_core::id::UlidGen; use b2_core::ingest::ingest_vault; use b2_core::{db, open}; +use common::{ingest_golden, MEMORY_ID}; use std::fs; -use std::path::Path; - -/// Deterministic id generator so stamping is assertable byte-for-byte. -struct FixedId(&'static str); -impl IdGen for FixedId { - fn new_id(&self) -> String { - self.0.to_string() - } -} - -fn copy_dir(src: &Path, dst: &Path) { - fs::create_dir_all(dst).unwrap(); - for entry in fs::read_dir(src).unwrap() { - let entry = entry.unwrap(); - let from = entry.path(); - let to = dst.join(entry.file_name()); - if from.is_dir() { - copy_dir(&from, &to); - } else { - fs::copy(&from, &to).unwrap(); - } - } -} - -/// Copy the committed golden vault into a temp dir so ingest (which may write a -/// stamp) never mutates the repo fixtures. -fn golden_vault_copy(dst: &Path) { - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/golden-vault"); - copy_dir(&src, dst); -} #[test] fn ingests_golden_vault_and_resolves_b2id_path_both_ways() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = tmp.path().join("vault"); - golden_vault_copy(&vault); - - let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); - let idgen = FixedId("01JSHOULDNEVERBEUSED000000"); - - ingest_vault( - &conn, - &vault, - &idgen, - &b2_core::embed::FakeEmbedder::default(), - ) - .unwrap(); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::default()); // resolver, both directions, for concepts/memory.md let b2id = db::resolve_path_to_b2id(&conn, "concepts/memory.md") .unwrap() .expect("memory note should resolve"); - assert_eq!(b2id, "01JMEM0000000000000000000A"); + assert_eq!(b2id, MEMORY_ID); let path = db::resolve_b2id_to_path(&conn, &b2id) .unwrap() .expect("b2id should resolve back to a path"); assert_eq!(path, "concepts/memory.md"); // both golden notes landed (they already carry a b2id — nothing to stamp) - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 2); -} - -#[test] -fn stamps_b2id_for_a_note_missing_one_and_persists_it_to_disk() { - let tmp = tempfile::TempDir::new().unwrap(); - let vault = tmp.path().join("vault"); - fs::create_dir_all(&vault).unwrap(); - let note_path = vault.join("orphan.md"); - fs::write( - ¬e_path, - "---\ntype: note\ntitle: \"Orphan\"\n---\nNo id here.\n", - ) - .unwrap(); - - let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); - let idgen = FixedId("01JSTAMPED0000000000000000"); - - ingest_vault( - &conn, - &vault, - &idgen, - &b2_core::embed::FakeEmbedder::default(), - ) - .unwrap(); - - // the always-allowed write actually hit the file — the id lives in the frontmatter, - // so identity travels with the note (there is no separate log; data-model.md §1, §4). - let on_disk = fs::read_to_string(¬e_path).unwrap(); - assert_eq!( - on_disk, - "---\nb2id: 01JSTAMPED0000000000000000\ntype: note\ntitle: \"Orphan\"\n---\nNo id here.\n" - ); - - // the freshly stamped note resolves - assert_eq!( - db::resolve_path_to_b2id(&conn, "orphan.md") - .unwrap() - .as_deref(), - Some("01JSTAMPED0000000000000000") - ); + assert_eq!(common::count(&conn, "notes"), 2); } #[test] @@ -126,13 +47,7 @@ fn aliases_are_projected_and_searchable() { .unwrap(); let conn = open(&tmp.path().join("b2.sqlite")).unwrap(); - ingest_vault( - &conn, - &vault, - &b2_core::id::UlidGen, - &b2_core::embed::FakeEmbedder::default(), - ) - .unwrap(); + ingest_vault(&conn, &vault, &UlidGen, &FakeEmbedder::default()).unwrap(); let alias_hit: String = conn .query_row( diff --git a/crates/b2-core/tests/links.rs b/crates/b2-core/tests/links.rs deleted file mode 100644 index c5a611d..0000000 --- a/crates/b2-core/tests/links.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Step 2 — link parsing (data-model.md §2). Pure (no DB), so these pin -//! the classification rules directly: every body link is an untyped `references` -//! edge (the body carries no B2 syntax; decision 2026-07-21), and the verb + -//! explanation are parsed only from a frontmatter `b2_relations:` entry -//! (`parse_relation`). - -use b2_core::link::{parse_links, parse_relation}; - -#[test] -fn bare_wikilink_in_prose_is_a_references_edge() { - let links = parse_links("See [[concepts/memory|Human memory]] for context.\n"); - assert_eq!(links.len(), 1); - assert_eq!(links[0].edge_type, "references"); - assert_eq!(links[0].target_path, "concepts/memory"); - assert_eq!(links[0].alias.as_deref(), Some("Human memory")); - assert_eq!(links[0].explanation, None); - assert!(!links[0].typed); -} - -#[test] -fn verb_prefixed_body_list_item_is_a_plain_reference() { - // The old body typed-line syntax is gone: the verb and trailing text are - // prose, and only the wikilink projects — as an untyped reference. - let links = - parse_links("- supports [[concepts/memory|Human memory]] — applies the forgetting curve\n"); - assert_eq!(links.len(), 1); - assert_eq!(links[0].edge_type, "references"); - assert_eq!(links[0].target_path, "concepts/memory"); - assert_eq!(links[0].alias.as_deref(), Some("Human memory")); - assert_eq!(links[0].explanation, None); - assert!(!links[0].typed); -} - -#[test] -fn body_links_never_gain_a_type_from_surrounding_prose() { - // A prose link and a verb-led list item are the same thing to the parser: - // two references edges, in document order — no shape is "special". - let body = "Spaced repetition exploits the [[concepts/memory|Human memory]] retrieval curve.\n\n## Relations\n- supports [[concepts/memory|Human memory]] — applies the forgetting curve\n"; - let links = parse_links(body); - assert_eq!(links.len(), 2); - assert!(links.iter().all(|l| l.edge_type == "references")); - assert!(links.iter().all(|l| !l.typed && l.explanation.is_none())); -} - -#[test] -fn list_item_with_a_bare_link_and_no_verb_is_a_reference() { - let links = parse_links("- [[concepts/memory|Human memory]]\n"); - assert_eq!(links.len(), 1); - assert_eq!(links[0].edge_type, "references"); - assert!(!links[0].typed); -} - -#[test] -fn lowercase_verb_lookalikes_in_prose_stay_prose() { - // The exact hazard that killed the body syntax: `- see [[x]]` must not - // become a typed edge of verb "see". - let links = parse_links("- see [[concepts/memory|Human memory]] for the mechanism\n"); - assert_eq!(links.len(), 1); - assert_eq!(links[0].edge_type, "references"); - assert!(!links[0].typed); -} - -#[test] -fn a_link_without_an_alias_keeps_a_none_alias() { - let links = parse_links("Refer to [[concepts/memory]].\n"); - assert_eq!(links[0].target_path, "concepts/memory"); - assert_eq!(links[0].alias, None); -} - -#[test] -fn relation_entry_parses_verb_link_and_explanation() { - let l = - parse_relation("supports [[concepts/memory|Human memory]] — applies the forgetting curve") - .unwrap(); - assert!(l.typed); - assert_eq!(l.edge_type, "supports"); - assert_eq!(l.target_path, "concepts/memory"); - assert_eq!(l.alias.as_deref(), Some("Human memory")); - assert_eq!( - l.explanation.as_deref(), - Some("applies the forgetting curve") - ); -} - -#[test] -fn relation_explanation_after_a_colon_is_supported() { - let l = parse_relation("supersedes [[notes/old-plan|Old plan]] : replaced after Q2").unwrap(); - assert_eq!(l.edge_type, "supersedes"); - assert_eq!(l.explanation.as_deref(), Some("replaced after Q2")); -} - -#[test] -fn relation_tail_verb_is_kept_verbatim() { - let l = parse_relation("inspired-by [[notes/x|X]]").unwrap(); - assert!(l.typed); - assert_eq!(l.edge_type, "inspired-by"); - assert_eq!(l.explanation, None); -} - -#[test] -fn relation_bare_link_falls_back_to_references() { - let l = parse_relation("[[concepts/memory|Human memory]]").unwrap(); - assert!(!l.typed); - assert_eq!(l.edge_type, "references"); - assert_eq!(l.target_path, "concepts/memory"); -} diff --git a/crates/b2-core/tests/list.rs b/crates/b2-core/tests/list.rs index bf58cb4..e8ddd86 100644 --- a/crates/b2-core/tests/list.rs +++ b/crates/b2-core/tests/list.rs @@ -5,23 +5,12 @@ mod common; -use b2_core::vault::Vault; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; -use std::path::Path; - -/// A reindexed golden vault under a temp dir; returns the open vault. -fn reindexed(dir: &Path) -> Vault { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - vault -} +use common::{reindexed_vault, MEMORY_ID, SRS_ID}; #[test] fn list_notes_returns_every_note_ordered_by_path() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let notes = vault.list_notes().unwrap(); @@ -42,7 +31,7 @@ fn list_notes_returns_every_note_ordered_by_path() { #[test] fn every_listed_note_is_readable() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); // The tree only shows what the index knows, so a click on any entry always opens. for summary in vault.list_notes().unwrap() { @@ -50,14 +39,3 @@ fn every_listed_note_is_readable() { assert_eq!(note.b2id, summary.b2id); } } - -#[test] -fn a_never_reindexed_vault_lists_nothing() { - let tmp = tempfile::TempDir::new().unwrap(); - let root = tmp.path().join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - - // Index-first honesty: no rows before the first reindex, no error (mirrors search). - assert!(vault.list_notes().unwrap().is_empty()); -} diff --git a/crates/b2-core/tests/mv.rs b/crates/b2-core/tests/mv.rs index 66f76b7..c013bde 100644 --- a/crates/b2-core/tests/mv.rs +++ b/crates/b2-core/tests/mv.rs @@ -7,18 +7,9 @@ mod common; use b2_core::vault::Vault; use b2_core::Error; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; +use common::{reindexed_vault, MEMORY_ID, SRS_ID}; use std::fs; -use std::path::{Path, PathBuf}; - -/// A reindexed golden vault under a temp dir; returns (vault, vault_root). -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} +use std::path::Path; /// The inbound set of a note, as sortable `(label, b2id)` pairs — the shape the /// graph exposes and the thing a move must leave unchanged. @@ -37,7 +28,7 @@ fn inbound(vault: &Vault, note_ref: &str) -> Vec<(String, String)> { #[test] fn move_rewrites_inbound_links_and_the_graph_is_unchanged() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); // The backlink set of memory, before the move (SRS supports + references it). let before = inbound(&vault, MEMORY_ID); @@ -87,7 +78,7 @@ fn move_rewrites_inbound_links_and_the_graph_is_unchanged() { #[test] fn move_changes_only_the_link_path_every_other_byte_identical() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let memory_before = fs::read_to_string(root.join("concepts/memory.md")).unwrap(); let srs_before = fs::read_to_string(root.join("notes/spaced-repetition.md")).unwrap(); @@ -112,7 +103,7 @@ fn move_changes_only_the_link_path_every_other_byte_identical() { #[test] fn move_leaves_unrelated_files_byte_identical() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); // A note that links to nothing relevant. let bystander = root.join("unrelated.md"); fs::write( @@ -133,7 +124,7 @@ fn move_leaves_unrelated_files_byte_identical() { #[test] fn move_without_md_suffix_appends_it() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let report = vault.move_note(MEMORY_ID, "concepts/human-memory").unwrap(); @@ -144,7 +135,7 @@ fn move_without_md_suffix_appends_it() { #[test] fn move_into_a_new_subdirectory_creates_it() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); vault .move_note("concepts/memory.md", "archive/deep/memory.md") @@ -158,7 +149,7 @@ fn move_into_a_new_subdirectory_creates_it() { #[test] fn move_onto_an_existing_file_is_refused() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let err = vault .move_note("concepts/memory.md", "notes/spaced-repetition.md") @@ -171,7 +162,7 @@ fn move_onto_an_existing_file_is_refused() { #[test] fn an_invalid_destination_is_rejected() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); for dest in ["../escape.md", "/abs/path.md", " "] { assert!( @@ -194,7 +185,7 @@ fn an_invalid_destination_is_rejected() { #[test] fn moving_an_unknown_note_is_note_not_found() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let err = vault .move_note("does/not/exist", "wherever.md") diff --git a/crates/b2-core/tests/project_embed.rs b/crates/b2-core/tests/project_embed.rs index 7e1803b..1d194e4 100644 --- a/crates/b2-core/tests/project_embed.rs +++ b/crates/b2-core/tests/project_embed.rs @@ -13,17 +13,11 @@ use b2_core::id::UlidGen; use b2_core::ingest::{embed_vault, ingest_file, ingest_vault, project_file, project_vault}; use b2_core::open; use b2_core::vault::Vault; -use common::golden_vault_copy; -use rusqlite::Connection; +use common::{count, golden_vault_copy}; use std::fs; use std::ops::ControlFlow; use std::path::Path; -fn count(conn: &Connection, table: &str) -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() -} - #[test] fn project_only_builds_keyword_graph_index_with_no_vectors() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/b2-core/tests/read.rs b/crates/b2-core/tests/read.rs index 6182f4a..1df8a0f 100644 --- a/crates/b2-core/tests/read.rs +++ b/crates/b2-core/tests/read.rs @@ -7,23 +7,12 @@ mod common; use b2_core::vault::Vault; -use b2_core::Error; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; -use std::path::Path; - -/// A reindexed golden vault under a temp dir; returns the open vault. -fn reindexed(dir: &Path) -> Vault { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - vault -} +use common::{golden_vault_copy, reindexed_vault, MEMORY_ID, SRS_ID}; #[test] fn read_returns_body_and_metadata_with_frontmatter_stripped() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let note = vault.read("concepts/memory.md").unwrap(); @@ -52,7 +41,7 @@ fn read_returns_body_and_metadata_with_frontmatter_stripped() { #[test] fn read_returns_the_raw_frontmatter_block_verbatim() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let note = vault.read("concepts/memory.md").unwrap(); let fm = note.frontmatter.expect("golden note has frontmatter"); @@ -72,7 +61,7 @@ fn read_returns_the_raw_frontmatter_block_verbatim() { #[test] fn read_body_is_verbatim_markdown_including_wikilinks() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); // The body is byte-honest Markdown: wikilinks survive verbatim so the adapter // renders them (clickable wikilinks are the MVP's navigation). The typed @@ -92,7 +81,7 @@ fn read_body_is_verbatim_markdown_including_wikilinks() { #[test] fn read_resolves_path_stem_and_b2id_to_the_same_note() { let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let by_path = vault.read("notes/spaced-repetition.md").unwrap(); let by_stem = vault.read("notes/spaced-repetition").unwrap(); @@ -123,12 +112,3 @@ fn read_surfaces_tags_from_frontmatter() { assert_eq!(note.title.as_deref(), Some("tagged")); assert_eq!(note.body.trim(), "Hello body."); } - -#[test] -fn read_unknown_ref_is_note_not_found() { - let tmp = tempfile::TempDir::new().unwrap(); - let vault = reindexed(tmp.path()); - - let err = vault.read("does/not/exist").unwrap_err(); - assert!(matches!(err, Error::NoteNotFound(r) if r == "does/not/exist")); -} diff --git a/crates/b2-core/tests/rm_delete.rs b/crates/b2-core/tests/rm_delete.rs index 14e3acf..b1fd2d0 100644 --- a/crates/b2-core/tests/rm_delete.rs +++ b/crates/b2-core/tests/rm_delete.rs @@ -7,35 +7,14 @@ mod common; -use b2_core::vault::Vault; -use b2_core::{open, Error}; -use common::{golden_vault_copy, MEMORY_ID}; +use b2_core::Error; +use common::{count, index_conn, reindexed_vault, MEMORY_ID}; use rusqlite::Connection; use std::fs; -use std::path::{Path, PathBuf}; const MEMORY_PATH: &str = "concepts/memory.md"; const SRS_PATH: &str = "notes/spaced-repetition.md"; -/// A reindexed golden vault under a temp dir; returns (vault, vault_root). -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} - -/// Open a second connection onto a vault's index for direct assertions. -fn index_conn(root: &Path) -> Connection { - open(&root.join(".b2").join("b2.sqlite")).unwrap() -} - -fn count(conn: &Connection, table: &str) -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() -} - /// Every edge row's identity + resolution, ordered — the shape that must match a /// from-scratch rebuild for `delete ≡ external-delete + reindex` to hold. fn edge_rows(conn: &Connection) -> Vec<(String, String, Option, String, String, i64)> { @@ -63,7 +42,7 @@ fn edge_rows(conn: &Connection) -> Vec<(String, String, Option, String, #[test] fn delete_note_removes_file_and_rows_and_dangles_inbound_links() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let srs_before = fs::read_to_string(root.join(SRS_PATH)).unwrap(); let report = vault.delete_note(MEMORY_PATH).unwrap(); @@ -121,7 +100,7 @@ fn delete_note_equals_external_delete_plus_full_reindex() { let (vault_a, root_a) = { let dir = tmp.path().join("a"); fs::create_dir_all(&dir).unwrap(); - reindexed(&dir) + reindexed_vault(&dir) }; vault_a.delete_note(MEMORY_PATH).unwrap(); @@ -129,7 +108,7 @@ fn delete_note_equals_external_delete_plus_full_reindex() { let (vault_b, root_b) = { let dir = tmp.path().join("b"); fs::create_dir_all(&dir).unwrap(); - reindexed(&dir) + reindexed_vault(&dir) }; fs::remove_file(root_b.join(MEMORY_PATH)).unwrap(); let report = vault_b.reindex().unwrap(); @@ -150,7 +129,7 @@ fn delete_note_equals_external_delete_plus_full_reindex() { #[test] fn delete_note_unknown_ref_refuses() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); assert!(matches!( vault.delete_note("no/such-note.md").unwrap_err(), @@ -164,7 +143,7 @@ fn delete_note_unknown_ref_refuses() { #[test] fn delete_resource_removes_file_and_inventory_and_dangles_links() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); // Give SRS a body link at the resource, through the ordinary save path. let note = vault.read(SRS_PATH).unwrap(); @@ -206,7 +185,7 @@ fn delete_resource_removes_file_and_inventory_and_dangles_links() { #[test] fn delete_resource_unknown_path_refuses() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); assert!(matches!( vault.delete_resource("resources/nope.png").unwrap_err(), Error::ResourceNotFound(_) @@ -216,7 +195,7 @@ fn delete_resource_unknown_path_refuses() { #[test] fn delete_dir_removes_subtree_and_dangles_outside_links() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let report = vault.delete_dir("concepts").unwrap(); assert_eq!(report.dir, "concepts"); @@ -243,7 +222,7 @@ fn delete_dir_removes_subtree_and_dangles_outside_links() { #[test] fn delete_dir_containing_the_linker_leaves_the_target_intact() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); // Deleting `notes/` removes SRS (the linker). Memory survives untouched, and // no surviving file needs re-projection (the linker died with the folder). @@ -261,7 +240,7 @@ fn delete_dir_containing_the_linker_leaves_the_target_intact() { #[test] fn delete_dir_removes_resources_and_their_inventory() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let report = vault.delete_dir("resources").unwrap(); assert_eq!(report.deleted_notes, 0); @@ -273,7 +252,7 @@ fn delete_dir_removes_resources_and_their_inventory() { #[test] fn delete_dir_deletes_an_empty_folder() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); fs::create_dir_all(root.join("scratch")).unwrap(); // An empty folder is a real vault member (fs-authoritative structure): the @@ -289,7 +268,7 @@ fn delete_dir_deletes_an_empty_folder() { #[test] fn delete_dir_missing_or_invalid_refuses() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); assert!(matches!( vault.delete_dir("no-such-folder").unwrap_err(), diff --git a/crates/b2-core/tests/roundtrip.rs b/crates/b2-core/tests/roundtrip.rs index 00001e4..8521965 100644 --- a/crates/b2-core/tests/roundtrip.rs +++ b/crates/b2-core/tests/roundtrip.rs @@ -4,6 +4,12 @@ //! frontmatter keys + order, comments, and whitespace (data-model.md §6). B2 //! achieves this by keeping the raw text and only ever making the surgical edits //! it is asked to make — never re-dumping YAML. +//! +//! The *headingless* case (text with no frontmatter block at all) is not pinned +//! here: `tests/props.rs::any_text_round_trips_byte_identical` already proves it +//! over 512 generated strings, which are overwhelmingly frontmatter-free. What +//! that property cannot reach — a real, messy, human-authored block — is exactly +//! what the cases below hold. use b2_core::note::parse; use std::fs; @@ -47,14 +53,6 @@ fn round_trip_preserves_unknown_keys_comments_and_whitespace() { assert_eq!(parse(raw).as_str(), raw); } -#[test] -fn round_trip_note_without_frontmatter() { - let raw = "Just a body, no frontmatter at all.\n"; - let n = parse(raw); - assert_eq!(n.as_str(), raw); - assert!(n.fields().b2id.is_none()); -} - #[test] fn extracts_queryable_fields_without_disturbing_raw() { let raw = golden("notes/spaced-repetition.md"); diff --git a/crates/b2-core/tests/search.rs b/crates/b2-core/tests/search.rs index 4022d81..c7a1984 100644 --- a/crates/b2-core/tests/search.rs +++ b/crates/b2-core/tests/search.rs @@ -14,18 +14,8 @@ use b2_core::id::UlidGen; use b2_core::ingest::ingest_vault; use b2_core::search::{self, RRF_K}; use b2_core::{open, search::Hit}; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; -use rusqlite::Connection; +use common::{golden_vault_copy, ingest_golden, MEMORY_ID, SRS_ID}; use std::fs; -use std::path::Path; - -fn ingest_golden(dir: &Path) -> Connection { - let vault = dir.join("vault"); - golden_vault_copy(&vault); - let conn = open(&dir.join("b2.sqlite")).unwrap(); - ingest_vault(&conn, &vault, &UlidGen, &FakeEmbedder::new(64)).unwrap(); - conn -} fn note_set(hits: &[Hit]) -> std::collections::BTreeSet { hits.iter().map(|h| h.note_b2id.clone()).collect() @@ -56,7 +46,7 @@ fn rrf_ranks_a_doc_present_in_both_lists_above_single_list_winners() { #[test] fn keyword_search_finds_chunks_by_term() { let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); let ids = search::keyword_search(&conn, "forgetting", 10).unwrap(); assert!(!ids.is_empty()); @@ -77,7 +67,7 @@ fn keyword_search_tolerates_natural_language_punctuation() { // FTS5 *syntax* and would raise a parse error if passed raw (the bug the eval // surfaced). They must be sanitized to a safe MATCH, still matching real terms. let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); for q in [ "why can't I remember? the \"forgetting\" curve!", @@ -107,7 +97,7 @@ fn fts5_query_sanitizes_to_ored_literals() { #[test] fn hybrid_search_combines_signals_and_resolves_to_notes() { let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); let hits = search::hybrid_search(&conn, &FakeEmbedder::new(64), "forgetting curve", 5).unwrap(); assert!(!hits.is_empty()); @@ -162,6 +152,80 @@ fn graph_filtered_search_restricts_to_reachable_notes() { assert!(notes .iter() .all(|n| n == "01JA0000000000000000000001" || n == "01JB0000000000000000000002")); + + // …and `limit` genuinely truncates that reachable set. This is the complement of + // tests/vector_pool_scale.rs, which pins the other side — that a limit *above* + // what is reachable returns everything rather than a silently capped prefix. + assert!(hits.len() > 1, "the fixture must have room to truncate"); + let capped = search::graph_filtered_search( + &conn, + &FakeEmbedder::new(64), + "shared topic", + "01JA0000000000000000000001", + 1, + 1, + ) + .unwrap(); + assert_eq!(capped.len(), 1, "the scan stops at the limit"); +} + +/// A result's `snippet` must **window around the matched term**, not just show the +/// chunk's head. Under qmd chunking (#19) a chunk is section-sized — far longer than +/// the snippet budget — so a term buried past the head would otherwise never appear +/// in what the human reads, and every hit would look identical. +#[test] +fn a_long_chunks_snippet_windows_around_the_matched_term() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("vault"); + fs::create_dir_all(&root).unwrap(); + // ~470 characters of lead-in, then the term — well past the snippet head. + let lead = "Filler prose that exists only to push the matched term out of the head. ".repeat(7); + fs::write( + root.join("long.md"), + format!( + "---\nb2id: 01JLONG000000000000000001\ntype: note\n---\n\ + {lead}\nThe capybara paragraph is the one the query is looking for.\n" + ), + ) + .unwrap(); + let vault = b2_core::Vault::open(&root).unwrap(); + vault.reindex().unwrap(); + + let hits = vault.search("capybara", 5).unwrap(); + let hit = hits + .iter() + .find(|h| h.path == "long.md") + .expect("the keyword match must surface"); + assert!( + hit.snippet.contains("capybara"), + "the matched term must be inside the window: {:?}", + hit.snippet + ); + assert!( + hit.snippet.starts_with('…'), + "a windowed snippet opens with an ellipsis: {:?}", + hit.snippet + ); + // Bounded: the 160-char budget plus at most a leading and trailing ellipsis. + assert!(hit.snippet.chars().count() <= 162, "{:?}", hit.snippet); + + // A term already inside the head needs no window — the snippet is the head, so + // it opens with the text itself rather than an ellipsis. + let head_hit = vault + .search("Filler", 5) + .unwrap() + .into_iter() + .find(|h| h.path == "long.md") + .expect("the head term must surface too"); + assert!( + head_hit.snippet.starts_with("Filler prose"), + "a match in the head keeps the head: {:?}", + head_hit.snippet + ); + assert!( + head_hit.snippet.ends_with('…'), + "…still truncated to budget" + ); } #[test] @@ -194,7 +258,7 @@ fn search_chunks_exposes_passage_level_hits() { #[test] fn graph_filter_with_zero_hops_is_just_the_anchor() { let tmp = tempfile::TempDir::new().unwrap(); - let conn = ingest_golden(tmp.path()); + let conn = ingest_golden(tmp.path(), &FakeEmbedder::new(64)); // 0 hops from memory → only memory's own chunks are eligible. let hits = diff --git a/crates/b2-core/tests/vault.rs b/crates/b2-core/tests/vault.rs index 59ed350..099d252 100644 --- a/crates/b2-core/tests/vault.rs +++ b/crates/b2-core/tests/vault.rs @@ -8,17 +8,7 @@ mod common; use b2_core::vault::Vault; use b2_core::Error; -use common::{golden_vault_copy, MEMORY_ID, SRS_ID}; -use std::path::{Path, PathBuf}; - -/// A reindexed golden vault under a temp dir; returns (vault, vault_root). -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} +use common::{golden_vault_copy, reindexed_vault, MEMORY_ID, SRS_ID}; #[test] fn open_creates_the_b2_dir_and_index() { @@ -50,34 +40,10 @@ fn reindex_reports_counts_and_is_idempotent() { assert_eq!(again.stamped, 0); } -#[test] -fn reindex_stamps_a_note_missing_a_b2id() { - let tmp = tempfile::TempDir::new().unwrap(); - let root = tmp.path().join("vault"); - golden_vault_copy(&root); - // an extra note with no b2id → reindex must stamp exactly it. - std::fs::write( - root.join("orphan.md"), - "---\ntype: note\ntitle: Orphan\n---\nbody\n", - ) - .unwrap(); - - let vault = Vault::open(&root).unwrap(); - let report = vault.reindex().unwrap(); - assert_eq!(report.indexed, 3); - assert_eq!(report.stamped, 1); - // the stamp is durable in the note's frontmatter (the id travels in the file). - let stamped = std::fs::read_to_string(root.join("orphan.md")).unwrap(); - assert!( - stamped.contains("b2id:"), - "the missing b2id must be written to disk" - ); -} - #[test] fn neighbors_of_memory_are_inbound_resolved_to_paths_and_titles() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let ns = vault.neighbors(MEMORY_ID).unwrap(); let mut labels: Vec<&str> = ns.iter().map(|n| n.label.as_str()).collect(); @@ -100,7 +66,7 @@ fn neighbors_of_memory_are_inbound_resolved_to_paths_and_titles() { #[test] fn neighbors_of_srs_are_outbound_and_ref_forms_agree() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); // by path, by path-without-.md, and by b2id must all resolve to the same set. let by_path = vault.neighbors("notes/spaced-repetition.md").unwrap(); @@ -121,19 +87,33 @@ fn neighbors_of_srs_are_outbound_and_ref_forms_agree() { assert_eq!(by_stem.len(), by_id.len()); } +/// Every façade op that resolves a note ref rejects an unknown one the same way, +/// echoing the ref back verbatim — the single refusal the adapters map to their +/// "not found" message. Asserted once here rather than per-op across files. #[test] -fn unknown_ref_is_note_not_found() { +fn unknown_ref_is_note_not_found_on_every_resolving_op() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); - - let err = vault.neighbors("does/not/exist").unwrap_err(); - assert!(matches!(err, Error::NoteNotFound(r) if r == "does/not/exist")); + let (vault, _root) = reindexed_vault(tmp.path()); + + const MISSING: &str = "does/not/exist"; + let refusals = [ + ("read", vault.read(MISSING).err()), + ("neighbors", vault.neighbors(MISSING).err()), + ("explain", vault.explain(MISSING).err()), + ("similar", vault.similar(MISSING, 5).err()), + ]; + for (op, err) in refusals { + assert!( + matches!(err, Some(Error::NoteNotFound(ref r)) if r == MISSING), + "{op} must refuse an unknown ref as NoteNotFound, got {err:?}" + ); + } } #[test] fn search_finds_the_note_with_a_snippet_and_is_note_level() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let hits = vault.search("forgetting", 10).unwrap(); assert!(!hits.is_empty()); @@ -160,14 +140,16 @@ fn search_finds_the_note_with_a_snippet_and_is_note_level() { assert_eq!(ids, deduped, "search results must be deduped by note"); } +/// Index-first honesty: before the first reindex the projection is empty, so the +/// read surfaces answer *empty*, never an error — the adapters render "nothing +/// indexed yet", not a failure. #[test] -fn search_before_reindex_is_empty() { +fn reads_before_reindex_are_empty_not_errors() { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path().join("vault"); golden_vault_copy(&root); let vault = Vault::open(&root).unwrap(); - // no reindex → no chunks → no hits (and no error). - let hits = vault.search("forgetting", 10).unwrap(); - assert!(hits.is_empty()); + assert!(vault.search("forgetting", 10).unwrap().is_empty()); + assert!(vault.list_notes().unwrap().is_empty()); } diff --git a/crates/b2-core/tests/write.rs b/crates/b2-core/tests/write.rs index aecc42d..b8fa00e 100644 --- a/crates/b2-core/tests/write.rs +++ b/crates/b2-core/tests/write.rs @@ -9,38 +9,17 @@ mod common; use b2_core::db; use b2_core::vault::Vault; -use b2_core::{open, Error}; -use common::golden_vault_copy; -use rusqlite::Connection; +use b2_core::Error; +use common::{count, golden_vault_copy, index_conn, reindexed_vault}; use std::fs; use std::ops::ControlFlow; -use std::path::{Path, PathBuf}; const SRS_PATH: &str = "notes/spaced-repetition.md"; -/// A reindexed (projected + fake-embedded) golden vault under a temp dir. -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} - -fn count(conn: &Connection, table: &str) -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() -} - -/// Open a second connection onto a vault's index for direct assertions. -fn index_conn(root: &Path) -> Connection { - open(&root.join(".b2").join("b2.sqlite")).unwrap() -} - #[test] fn write_replaces_body_and_preserves_frontmatter_bytes() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let before = fs::read_to_string(root.join(SRS_PATH)).unwrap(); let fm_end = before.find("\n---\n").unwrap() + "\n---\n".len(); @@ -64,7 +43,7 @@ fn write_replaces_body_and_preserves_frontmatter_bytes() { #[test] fn write_conflicts_when_the_file_changed_on_disk() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); @@ -96,7 +75,7 @@ fn write_conflicts_when_the_file_changed_on_disk() { #[test] fn sequential_writes_chain_revisions_without_conflict() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); // Each save is based on the revision the previous save returned — the // serialized chain (§3 "last save wins — by construction"). @@ -114,7 +93,7 @@ fn sequential_writes_chain_revisions_without_conflict() { #[test] fn write_reprojects_keyword_graph_and_clears_stale_vectors() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let conn = index_conn(&root); assert_eq!(count(&conn, "embeddings"), count(&conn, "chunks")); @@ -198,7 +177,7 @@ fn write_an_empty_body_and_recover() { // upsetting the index, and the revision chain must continue out of the empty // state. `chunk_body` documents the empty case; this pins it end to end. let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); let report = vault.write(SRS_PATH, "", ¬e.revision).unwrap(); diff --git a/crates/b2-core/tests/write_frontmatter.rs b/crates/b2-core/tests/write_frontmatter.rs index b6bc03a..26f6142 100644 --- a/crates/b2-core/tests/write_frontmatter.rs +++ b/crates/b2-core/tests/write_frontmatter.rs @@ -9,38 +9,18 @@ mod common; use b2_core::vault::Vault; -use b2_core::{open, Error}; -use common::golden_vault_copy; +use b2_core::Error; +use common::{count, golden_vault_copy, index_conn, reindexed_vault}; use rusqlite::Connection; use std::fs; -use std::path::{Path, PathBuf}; const SRS_PATH: &str = "notes/spaced-repetition.md"; const SRS_ID: &str = "01JSRS0000000000000000000B"; -/// A reindexed (projected + fake-embedded) golden vault under a temp dir. -fn reindexed(dir: &Path) -> (Vault, PathBuf) { - let root = dir.join("vault"); - golden_vault_copy(&root); - let vault = Vault::open(&root).unwrap(); - vault.reindex().unwrap(); - (vault, root) -} - -/// Open a second connection onto a vault's index for direct assertions. -fn index_conn(root: &Path) -> Connection { - open(&root.join(".b2").join("b2.sqlite")).unwrap() -} - -fn count(conn: &Connection, table: &str) -> i64 { - conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) - .unwrap() -} - #[test] fn saves_the_block_verbatim_and_leaves_the_body_untouched() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); let body_before = note.body.clone(); @@ -68,7 +48,7 @@ fn saves_the_block_verbatim_and_leaves_the_body_untouched() { #[test] fn refuses_a_changed_removed_or_duplicated_b2id() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); let on_disk_before = fs::read_to_string(root.join(SRS_PATH)).unwrap(); @@ -103,7 +83,7 @@ fn refuses_a_changed_removed_or_duplicated_b2id() { #[test] fn refuses_a_fence_line_that_would_leak_into_the_body() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); let on_disk_before = fs::read_to_string(root.join(SRS_PATH)).unwrap(); @@ -126,7 +106,7 @@ fn refuses_a_fence_line_that_would_leak_into_the_body() { #[test] fn conflicts_when_the_file_changed_on_disk() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); // An external editor changes the file after our read… @@ -158,7 +138,7 @@ fn malformed_yaml_saves_and_surfaces_as_unreadable_not_an_error() { // (the b2id line still raw-scans, #75), keeps the bytes verbatim, and flags // the block unreadable on every subsequent read. let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); assert!(note.frontmatter_readable, "golden note starts clean"); @@ -186,7 +166,7 @@ fn malformed_yaml_saves_and_surfaces_as_unreadable_not_an_error() { #[test] fn reprojects_edges_from_the_new_block_without_touching_vectors() { let tmp = tempfile::TempDir::new().unwrap(); - let (vault, root) = reindexed(tmp.path()); + let (vault, root) = reindexed_vault(tmp.path()); let conn = index_conn(&root); let embeddings_before = count(&conn, "embeddings"); assert_eq!(embeddings_before, count(&conn, "chunks")); @@ -260,7 +240,7 @@ fn sequential_saves_chain_revisions_and_mix_with_body_saves() { // One whole-file revision guards both write sites: a frontmatter save chains // off a body save's revision and vice versa, never self-conflicting. let tmp = tempfile::TempDir::new().unwrap(); - let (vault, _root) = reindexed(tmp.path()); + let (vault, _root) = reindexed_vault(tmp.path()); let note = vault.read(SRS_PATH).unwrap(); let fm1 = vault diff --git a/crates/b2-embed/examples/eval.rs b/crates/b2-embed/examples/eval.rs index b7bc8a8..6942831 100644 --- a/crates/b2-embed/examples/eval.rs +++ b/crates/b2-embed/examples/eval.rs @@ -165,6 +165,10 @@ fn run() -> Result> { let dim = embedder.dim(); eprintln!("[eval] model = {model_id} (dim {dim})\n"); + // A correctness gate, not a score: every number below is computed from batched + // embeddings, so they only mean anything if batching is faithful. + check_batch_matches_single(&embedder)?; + // Build a throwaway vault from the corpus. let tmp = tempfile::TempDir::new()?; let vault_root = tmp.path().join("vault"); @@ -352,6 +356,61 @@ fn score_similar(vault: &Vault, set: &SimilarSet) -> Result Result<(), Box> { + // Deliberately varied lengths, so batching pads the short rows to the longest. + let texts = [ + "Spaced repetition schedules reviews at increasing intervals.", + "Sleep consolidates memory.", + "Short.", + "Focus and sustained attention shape what is later recalled from long-term memory across days.", + ]; + let refs: Vec<&str> = texts.to_vec(); + let batched = model.embed_batch(&refs)?; + if batched.len() != texts.len() { + return Err(format!( + "embed_batch returned {} rows for {} texts", + batched.len(), + texts.len() + ) + .into()); + } + let mut worst = f32::INFINITY; + for (text, batched_row) in texts.iter().zip(&batched) { + let single = model.embed(text)?; + if batched_row.len() != single.len() { + return Err(format!("batched/single dim mismatch for {text:?}").into()); + } + // Both rows are L2-normalized, so the dot product is cosine similarity; + // padding must not move it off ~1.0. Non-finite is checked first and + // explicitly: every comparison against a NaN is false, so `cos <= 0.9999` + // alone would wave a NaN row *through* the gate — the one failure mode a + // correctness check must not have. + let cos: f32 = batched_row.iter().zip(&single).map(|(a, b)| a * b).sum(); + if !cos.is_finite() { + return Err(format!("batched embedding is non-finite for {text:?}: {cos}").into()); + } + worst = worst.min(cos); + if cos <= 0.9999 { + return Err(format!( + "batched embedding differs from single for {text:?}: cosine {cos}" + ) + .into()); + } + } + eprintln!("[eval] batch ≡ single: worst-row cosine {worst:.6}\n"); + Ok(()) +} + /// Run the embed pass, timing it and counting the chunks it filled. fn timed_embed(vault: &Vault) -> Result<(usize, f64), Box> { let mut chunks = 0usize; diff --git a/crates/b2-embed/tests/batch.rs b/crates/b2-embed/tests/batch.rs deleted file mode 100644 index cc26112..0000000 --- a/crates/b2-embed/tests/batch.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Real-model check (out of CI): a batched embed equals the per-text single embed, -//! row for row. This is the correctness guarantee behind `LocalEmbedder::embed_batch` -//! — right-padding + the attention mask must leave each row's CLS vector unchanged. -//! -//! `#[ignore]`d because it needs the provisioned model (`b2 init`); run it with: -//! cargo test -p b2-embed --test batch -- --ignored - -use b2_core::embed::Embedder; -use b2_embed::{EmbedConfig, LocalEmbedder}; - -#[test] -#[ignore = "needs the provisioned model; run with --ignored"] -fn batched_equals_single_per_row() { - let config = EmbedConfig::load().expect("load embed config"); - let model = LocalEmbedder::load(&config).expect("run `b2 init` to provision the model first"); - - // Deliberately varied lengths, so batching pads short rows to the longest. - let texts = [ - "Spaced repetition schedules reviews at increasing intervals.", - "Sleep consolidates memory.", - "Short.", - "Focus and sustained attention shape what is later recalled from long-term memory across days.", - ]; - let refs: Vec<&str> = texts.to_vec(); - let batched = model.embed_batch(&refs).unwrap(); - assert_eq!(batched.len(), texts.len()); - - for (t, bv) in texts.iter().zip(&batched) { - let sv = model.embed(t).unwrap(); - assert_eq!(bv.len(), sv.len(), "dim mismatch for {t:?}"); - // Both are L2-normalized, so the dot product is cosine similarity; padding - // must not move it off ~1.0. - let cos: f32 = bv.iter().zip(&sv).map(|(a, b)| a * b).sum(); - assert!(cos > 0.9999, "batched vs single cosine {cos} for {t:?}"); - } -} diff --git a/justfile b/justfile index 0cf36f6..483e683 100644 --- a/justfile +++ b/justfile @@ -46,6 +46,50 @@ check: cargo clippy --workspace --exclude b2-desktop cargo test -p b2-core +# --- Coverage (cargo-llvm-cov; `cargo install cargo-llvm-cov` + `rustup component add +# llvm-tools-preview`) ------------------------------------------------------------ +# +# Source-based coverage over the same model-free suite CI runs — the numbers answer +# "which engine lines does the deterministic suite actually execute", so an untested +# branch shows up as a gap rather than as a hole nobody named. Real-model paths +# (b2-embed's candle code) are deliberately out: they are exercised by `just eval`, +# not by `cargo test`, so instrumenting them would report a permanent, meaningless 0%. +# +# No `--summary-only` here: cargo-llvm-cov documents it as valid only alongside +# --json/--lcov/--cobertura. 0.8.7 doesn't enforce that (it accepts and ignores the +# flag in text mode), but the default text report already *is* the per-file summary, +# so the flag buys nothing and would break if a later release starts rejecting it. + +# Engine coverage — the daily number. Mirrors `just test` (b2-core only), so it is as +# fast as the suite itself and pulls in no ML deps. +coverage: + cargo llvm-cov -p b2-core + +# Same, as a browsable HTML report (per-file, line-by-line) under target/llvm-cov/html. +coverage-html: + cargo llvm-cov -p b2-core --html + @echo "report: target/llvm-cov/html/index.html" + +# Engine + the CLI adapter. The CLI suite spawns the real `b2` binary, which is +# instrumented too, so its process-level runs count. Heavier on a cold cache: b2-cli +# depends on b2-embed, so this compiles candle once (excluded crates are still built +# when a covered crate depends on them). +coverage-all: + cargo llvm-cov --workspace --exclude b2-desktop --exclude b2-embed + +# Coverage for the desktop host's own unit tests. Separate and heavier for the same +# reason `check-app` is: it embeds ui/dist (so the frontend builds first) and needs +# the platform webview toolchain, exactly like `just app`. Expect a low number by +# design — b2-desktop is a dumb adapter, and the behaviour behind its commands is +# covered by the façade suite (crates/b2-desktop/CLAUDE.md, "inherited tests"). +coverage-app: ui-build + cargo llvm-cov -p b2-desktop + +# lcov.info for editor gutters (VS Code Coverage Gutters, etc.) or a CI upload. +coverage-lcov: + cargo llvm-cov -p b2-core --lcov --output-path target/llvm-cov/lcov.info + @echo "lcov: target/llvm-cov/lcov.info" + # Download + verify bge-base-en-v1.5 into the shared XDG cache (needed for the real embedder) init: cargo run -p b2-cli -- init diff --git a/ui/package.json b/ui/package.json index 1f3b8c3..7b0c7a6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "node --experimental-strip-types src/panes.test.ts && node --experimental-strip-types src/graph.test.ts && node --experimental-strip-types src/newentry.test.ts && node --experimental-strip-types src/embedreminder.test.ts && node --experimental-strip-types src/move.test.ts && node --experimental-strip-types src/wikicomplete.test.ts && node --experimental-strip-types src/format.test.ts && node --experimental-strip-types src/findbar.test.ts && node --experimental-strip-types src/reconcile.test.ts && node --experimental-strip-types src/paste.test.ts" + "test": "node --experimental-strip-types --test --test-reporter=spec \"src/*.test.ts\"" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/ui/ui/package-lock.json b/ui/ui/package-lock.json new file mode 100644 index 0000000..e0f7e3d --- /dev/null +++ b/ui/ui/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ui", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}