15d7f5e873
- 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>
156 lines
7.7 KiB
Rust
156 lines
7.7 KiB
Rust
//! Behaviour of `retrieve`, `related` and `budget` on a tiny vault.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use notescraft_knowledge::{Index, MapSignals, NoSignals, Options, SignalKind, Vocab, budget, related, retrieve};
|
|
|
|
struct Fixture {
|
|
index: Index,
|
|
vocab: Vocab,
|
|
files: HashMap<String, String>,
|
|
}
|
|
|
|
fn fixture(notes: &[(&str, &str)]) -> Fixture {
|
|
let (mut index, mut vocab, mut files) = (Index::default(), Vocab::default(), HashMap::new());
|
|
for (p, t) in notes {
|
|
index.update(p, t).unwrap();
|
|
vocab.update(p, t).unwrap();
|
|
files.insert((*p).to_string(), (*t).to_string());
|
|
}
|
|
Fixture { index, vocab, files }
|
|
}
|
|
|
|
fn ask(f: &Fixture, s: &MapSignals, q: &str, o: &Options) -> notescraft_knowledge::Retrieval {
|
|
retrieve(&f.index, &f.vocab, s, |p| f.files.get(p).cloned(), q, o).unwrap()
|
|
}
|
|
|
|
fn paths(r: ¬escraft_knowledge::Retrieval) -> Vec<&str> {
|
|
r.passages.iter().map(|p| p.path.as_str()).collect()
|
|
}
|
|
|
|
/// Several notes where "notify" travels with "reminder", plus filler.
|
|
fn reminder_vault() -> Fixture {
|
|
let mut notes: Vec<(String, String)> = vec![
|
|
("a.md".into(), "# Alerts\n\nA reminder will notify you.\n\n# Sound\n\nPick the notify sound for each reminder.".into()),
|
|
("b.md".into(), "# Settings\n\nThe reminder can notify by email.".into()),
|
|
("c.md".into(), "# Tasks\n\nEvery task has a reminder at nine.".into()),
|
|
];
|
|
for i in 0..30 {
|
|
notes.push((format!("filler{i}.md"), format!("# Filler {i}\n\nGarden topic number{i} about soil and water.")));
|
|
}
|
|
let refs: Vec<(&str, &str)> = notes.iter().map(|(p, t)| (p.as_str(), t.as_str())).collect();
|
|
fixture(&refs)
|
|
}
|
|
|
|
#[test]
|
|
fn vocabulary_finds_note_with_other_words_and_explains_it() {
|
|
let f = reminder_vault();
|
|
let s = MapSignals::default();
|
|
let plain = ask(&f, &s, "notify", &Options { learn_vocab: false, ..Options::default() });
|
|
assert!(!paths(&plain).contains(&"c.md"));
|
|
let hybrid = ask(&f, &s, "notify", &Options::default());
|
|
let c = hybrid.passages.iter().find(|p| p.path == "c.md").expect("c.md found through the reminder link");
|
|
let signal = c.signals.iter().find(|s| s.kind == SignalKind::Vocab).expect("vocab signal");
|
|
assert_eq!(signal.detail, "added words: reminder");
|
|
assert!(c.text.contains("reminder at nine"));
|
|
assert!(hybrid.passages.iter().any(|p| p.signals.iter().any(|s| s.kind == SignalKind::Text && s.detail.starts_with("matched words: notify"))));
|
|
}
|
|
|
|
#[test]
|
|
fn links_and_tags_boost_and_say_so() {
|
|
let f = fixture(&[
|
|
("top.md", "# Lisbon trip\n\nlisbon room lisbon room lisbon room"),
|
|
("aa.md", "# Aa\n\nlisbon room lisbon room lisbon room"),
|
|
("ab.md", "# Ab\n\nlisbon room lisbon room lisbon room"),
|
|
("hotel.md", "# Hotel\n\nlisbon room near the river"),
|
|
("other.md", "# Other\n\nlisbon room near the river"),
|
|
("tagged.md", "# Tagged\n\nlisbon room near the river"),
|
|
]);
|
|
let mut s = MapSignals::default();
|
|
s.links.insert("top.md".into(), vec!["hotel.md".into()]);
|
|
s.tags.insert("tagged.md".into(), vec!["#lisbon".into()]);
|
|
let r = ask(&f, &s, "lisbon room", &Options::default());
|
|
let pos = |name: &str| paths(&r).iter().position(|p| *p == name);
|
|
assert!(pos("hotel.md") < pos("other.md"));
|
|
assert!(pos("tagged.md") < pos("other.md"));
|
|
let hotel = r.passages.iter().find(|p| p.path == "hotel.md").unwrap();
|
|
assert!(hotel.signals.iter().any(|s| s.kind == SignalKind::Link && s.detail == "linked from top.md"));
|
|
let tagged = r.passages.iter().find(|p| p.path == "tagged.md").unwrap();
|
|
assert!(tagged.signals.iter().any(|s| s.kind == SignalKind::Tag && s.detail.contains("#lisbon")));
|
|
let off = ask(&f, &s, "lisbon room", &Options { follow_links: false, use_tags: false, ..Options::default() });
|
|
assert!(off.passages.iter().all(|p| p.signals.iter().all(|s| ![SignalKind::Link, SignalKind::Tag].contains(&s.kind))));
|
|
}
|
|
|
|
#[test]
|
|
fn recency_only_when_asked() {
|
|
let f = fixture(&[("old.md", "# Old\n\nmeeting notes agenda"), ("new.md", "# New\n\nmeeting notes agenda")]);
|
|
let mut s = MapSignals::default();
|
|
s.modified.insert("old.md".into(), 100);
|
|
s.modified.insert("new.md".into(), 900);
|
|
assert_eq!(paths(&ask(&f, &s, "meeting agenda", &Options::default())), ["new.md", "old.md"]);
|
|
let r = ask(&f, &s, "meeting agenda", &Options { prefer_recent: true, ..Options::default() });
|
|
assert_eq!(paths(&r), ["new.md", "old.md"]);
|
|
assert!(r.passages.first().is_some_and(|p| p.signals.iter().any(|s| s.detail == "recently modified")));
|
|
}
|
|
|
|
#[test]
|
|
fn at_most_two_passages_per_note_unless_short() {
|
|
let big = "# One\n\nzebra alpha\n\n# Two\n\nzebra beta\n\n# Three\n\nzebra gamma";
|
|
let f = fixture(&[("big.md", big), ("small.md", "# S\n\nzebra")]);
|
|
let s = MapSignals::default();
|
|
let r = ask(&f, &s, "zebra", &Options { limit: 4, ..Options::default() });
|
|
assert_eq!(r.passages.len(), 4);
|
|
assert!(!r.truncated);
|
|
let r = ask(&f, &s, "zebra", &Options { limit: 2, ..Options::default() });
|
|
assert_eq!(r.passages.len(), 2);
|
|
assert!(r.truncated);
|
|
let many = fixture(&[("big.md", big), ("a.md", "# A\n\nzebra"), ("b.md", "# B\n\nzebra")]);
|
|
let r = ask(&many, &s, "zebra", &Options { limit: 3, ..Options::default() });
|
|
assert!(r.passages.iter().filter(|p| p.path == "big.md").count() <= 2);
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_and_hostile_inputs() {
|
|
let f = reminder_vault();
|
|
let s = MapSignals::default();
|
|
let a = ask(&f, &s, "notify reminder", &Options::default());
|
|
assert_eq!(a, ask(&f, &s, "notify reminder", &Options::default()));
|
|
assert_eq!(ask(&f, &s, "", &Options::default()).passages.len(), 0);
|
|
assert_eq!(ask(&f, &s, "the and of", &Options::default()).passages.len(), 0);
|
|
assert!(ask(&f, &s, &"word ".repeat(100_000), &Options { limit: usize::MAX, ..Options::default() }).passages.len() <= 50);
|
|
let gone = retrieve(&f.index, &f.vocab, &NoSignals, |_| None, "reminder", &Options::default()).unwrap();
|
|
assert_eq!(gone.passages.len(), 0);
|
|
let bad = retrieve(&f.index, &f.vocab, &NoSignals, |_| Some("short".into()), "reminder", &Options::default()).unwrap();
|
|
assert_eq!(bad.passages.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn related_uses_own_words_links_and_excludes_self() {
|
|
let f = fixture(&[
|
|
("me.md", "# Bread\n\nsourdough starter flour hydration crumb sourdough starter"),
|
|
("same.md", "# Starter\n\nfeed the sourdough starter with flour daily"),
|
|
("linked.md", "# Oven\n\npreheat the stone for an hour"),
|
|
("noise.md", "# Noise\n\ngarden roses and soil"),
|
|
]);
|
|
let mut s = MapSignals::default();
|
|
s.links.insert("me.md".into(), vec!["linked.md".into()]);
|
|
let r = related(&f.index, &f.vocab, &s, |p| f.files.get(p).cloned(), "me.md", &Options::default()).unwrap();
|
|
let names: Vec<&str> = r.iter().map(|n| n.path.as_str()).collect();
|
|
assert_eq!(names.first(), Some(&"same.md"));
|
|
assert!(!names.contains(&"me.md") && !names.contains(&"noise.md"));
|
|
assert!(r.first().is_some_and(|n| n.snippet.contains("sourdough") && n.signals.iter().any(|s| s.detail.starts_with("matched words"))));
|
|
assert_eq!(related(&f.index, &f.vocab, &s, |_| None, "me.md", &Options::default()).unwrap(), []);
|
|
assert_eq!(related(&f.index, &f.vocab, &s, |p| f.files.get(p).cloned(), "missing.md", &Options::default()).unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn budget_keeps_best_passages_within_tokens() {
|
|
let f = reminder_vault();
|
|
let r = ask(&f, &MapSignals::default(), "reminder notify", &Options::default());
|
|
let all: usize = r.passages.iter().map(|p| p.text.chars().count().div_ceil(4)).sum();
|
|
let b = budget(r.passages.clone(), all);
|
|
assert_eq!((b.passages.len(), b.truncated), (r.passages.len(), false));
|
|
let b = budget(r.passages, 8);
|
|
assert!(b.tokens <= 8 && b.truncated);
|
|
}
|