Files
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity
- Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys
- Visual view: tables, note embeds, %% comments, wrapping property chips
- Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin
- Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:36:00 -03:00

141 lines
5.2 KiB
Rust

//! Evaluation on a synthetic vault: recall@6 and MRR of the hybrid ranker, and proof
//! that plain BM25 does worse on questions that use other words than the note.
#![allow(clippy::cast_precision_loss)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use notescraft_knowledge::{Index, MapSignals, Options, Vocab, retrieve};
const RECALL_AT: usize = 6;
const MRR_DEPTH: usize = 10;
const MIN_RECALL: f64 = 0.80;
const MIN_NOTES: usize = 30;
const MIN_CASES: usize = 25;
struct Case {
question: String,
path: String,
heading: String,
gap: bool,
}
struct Vault {
index: Index,
vocab: Vocab,
signals: MapSignals,
files: HashMap<String, String>,
}
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/eval")
}
fn walk(dir: &Path, base: &Path, out: &mut Vec<(String, String)>) {
let mut entries: Vec<_> = fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
walk(&p, base, out);
} else if p.extension().is_some_and(|e| e == "md") {
let rel = p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/");
out.push((rel, fs::read_to_string(&p).unwrap()));
}
}
}
/// Tags come from a `tags: [a, b]` frontmatter line, links from `[[stem]]`.
fn load() -> Vault {
let base = root().join("vault");
let mut notes = Vec::new();
walk(&base, &base, &mut notes);
let by_stem: HashMap<String, String> = notes.iter().map(|(p, _)| (Path::new(p).file_stem().unwrap().to_string_lossy().to_string(), p.clone())).collect();
let (mut index, mut vocab, mut signals, mut files) = (Index::default(), Vocab::default(), MapSignals::default(), HashMap::new());
for (path, text) in notes {
index.update(&path, &text).unwrap();
vocab.update(&path, &text).unwrap();
let tags = text
.lines()
.find_map(|l| l.strip_prefix("tags:"))
.map(|t| t.trim().trim_matches(['[', ']']).split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
signals.tags.insert(path.clone(), tags);
let links = text.split("[[").skip(1).filter_map(|r| r.split("]]").next()).filter_map(|s| by_stem.get(s)).cloned().collect();
signals.links.insert(path.clone(), links);
files.insert(path, text);
}
Vault { index, vocab, signals, files }
}
fn cases() -> Vec<Case> {
let text = fs::read_to_string(root().join("cases.tsv")).unwrap();
text.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| {
let c: Vec<&str> = l.split('\t').collect();
Case { question: c[0].into(), path: c[1].into(), heading: c[2].into(), gap: c.get(3) == Some(&"gap") }
})
.collect()
}
/// 1-based rank of the expected passage in the top `MRR_DEPTH`, if present.
fn rank_of(v: &Vault, c: &Case, options: &Options) -> Option<usize> {
let options = Options { limit: MRR_DEPTH, ..*options };
let r = retrieve(&v.index, &v.vocab, &v.signals, |p| v.files.get(p).cloned(), &c.question, &options).unwrap();
r.passages.iter().position(|p| p.path == c.path && p.heading == c.heading).map(|i| i + 1)
}
struct Score {
recall: f64,
mrr: f64,
}
fn score(v: &Vault, cases: &[&Case], options: &Options, label: &str) -> Score {
let (mut hit, mut rr) = (0.0, 0.0);
for c in cases {
let rank = rank_of(v, c, options);
println!(" [{label}] {:>8} {}", rank.map_or("miss".to_string(), |r| format!("rank {r}")), c.question);
hit += f64::from(u8::from(rank.is_some_and(|r| r <= RECALL_AT)));
rr += rank.map_or(0.0, |r| 1.0 / r as f64);
}
let n = cases.len() as f64;
Score { recall: hit / n, mrr: rr / n }
}
const BASELINE: Options = Options { limit: RECALL_AT, follow_links: false, use_tags: false, learn_vocab: false, prefer_recent: false };
#[test]
fn eval_set_is_big_enough() {
let v = load();
assert!(v.index.note_count() >= MIN_NOTES, "{} notes", v.index.note_count());
assert!(cases().len() >= MIN_CASES);
assert!(v.signals.links.values().any(|l| !l.is_empty()) && v.signals.tags.values().any(|t| !t.is_empty()));
}
#[test]
fn hybrid_reaches_recall_at_six() {
let v = load();
let all = cases();
let refs: Vec<&Case> = all.iter().collect();
let hybrid = score(&v, &refs, &Options::default(), "hybrid");
let base = score(&v, &refs, &BASELINE, "bm25");
println!("hybrid: recall@{RECALL_AT} = {:.3}, MRR = {:.3}", hybrid.recall, hybrid.mrr);
println!("bm25 : recall@{RECALL_AT} = {:.3}, MRR = {:.3}", base.recall, base.mrr);
assert!(hybrid.recall >= MIN_RECALL, "recall@{RECALL_AT} {:.3} < {MIN_RECALL}", hybrid.recall);
assert!(hybrid.recall >= base.recall);
}
#[test]
fn plain_bm25_is_worse_on_vocabulary_gaps() {
let v = load();
let all = cases();
let gaps: Vec<&Case> = all.iter().filter(|c| c.gap).collect();
assert!(gaps.len() >= 6);
let hybrid = score(&v, &gaps, &Options::default(), "hybrid gap");
let base = score(&v, &gaps, &BASELINE, "bm25 gap");
println!("gap cases hybrid: recall {:.3} MRR {:.3}; bm25: recall {:.3} MRR {:.3}", hybrid.recall, hybrid.mrr, base.recall, base.mrr);
assert!(hybrid.recall > base.recall && hybrid.mrr > base.mrr);
}