Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
32 changes: 28 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
49 changes: 49 additions & 0 deletions crates/b2-core/src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions crates/b2-core/src/relation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
}
27 changes: 8 additions & 19 deletions crates/b2-core/tests/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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"));
Expand All @@ -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!(
Expand Down
8 changes: 1 addition & 7 deletions crates/b2-core/tests/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
32 changes: 28 additions & 4 deletions crates/b2-core/tests/chunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = chunks.iter().map(|c| c.char_start).collect();

let flattened = ChunkConfig {
weights: BreakWeights {
heading: [0; 6],
..BreakWeights::default()
},
..cfg.clone()
};
let flat_cuts: Vec<usize> = 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:?})"
);
}
Loading