use super::*;
#[test]
fn test_recall_feedback_stores() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let emb = vec_seed(1.0, 8);
let rid = db
.record(
"feedback target",
"episodic",
0.5,
0.0,
604800.0,
&empty_meta(),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
db.recall_feedback(
Some("test query"),
Some(&emb),
&rid,
"relevant",
Some(0.85),
Some(1),
)
.unwrap();
let count: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM recall_feedback WHERE rid = ?1 AND feedback = 'relevant'",
params![rid],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1, "Expected 1 feedback row, got {}", count);
}
#[test]
fn test_learned_weights_default() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let weights = db.load_learned_weights().unwrap();
assert!(
(weights.w_sim - 0.50).abs() < 1e-6,
"w_sim default should be 0.50, got {}",
weights.w_sim
);
assert!(
(weights.w_decay - 0.20).abs() < 1e-6,
"w_decay default should be 0.20, got {}",
weights.w_decay
);
assert!(
(weights.w_recency - 0.30).abs() < 1e-6,
"w_recency default should be 0.30, got {}",
weights.w_recency
);
assert!(
(weights.gate_tau - 0.25).abs() < 1e-6,
"gate_tau default should be 0.25, got {}",
weights.gate_tau
);
assert!(
(weights.alpha_imp - 0.80).abs() < 1e-6,
"alpha_imp default should be 0.80, got {}",
weights.alpha_imp
);
assert_eq!(weights.generation, 0, "generation should start at 0");
}
#[test]
fn test_feedback_count_increments() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let emb = vec_seed(1.0, 8);
let rid = db
.record(
"counting feedback",
"episodic",
0.5,
0.0,
604800.0,
&empty_meta(),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
for i in 0..5 {
let feedback_type = if i % 2 == 0 { "relevant" } else { "irrelevant" };
db.recall_feedback(
Some("query"),
Some(&emb),
&rid,
feedback_type,
Some(0.5),
Some(i + 1),
)
.unwrap();
}
let count = db.feedback_count().unwrap();
assert_eq!(count, 5, "Expected feedback_count=5, got {}", count);
}
#[test]
fn test_learning_skipped_under_threshold() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let emb = vec_seed(1.0, 8);
let rid = db
.record(
"learning test",
"episodic",
0.5,
0.0,
604800.0,
&empty_meta(),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
for i in 0..10 {
db.recall_feedback(Some("q"), Some(&emb), &rid, "relevant", Some(0.5), Some(i))
.unwrap();
}
let report = db.run_learning().unwrap();
assert_eq!(
report.outcome, "insufficient_evidence",
"loop abstains below the episode gate: {report:?}"
);
assert_eq!(report.generation, 0, "no generation minted");
assert_eq!(report.engine_resurface_positive_count, 0);
}
#[test]
fn test_learning_runs_with_enough_feedback() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let emb = vec_seed(1.0, 8);
let rid = db
.record(
"learning convergence",
"episodic",
0.7,
0.0,
604800.0,
&empty_meta(),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
for i in 0..25 {
let feedback_type = if i % 3 == 0 { "irrelevant" } else { "relevant" };
let score = 0.3 + (i as f64 * 0.02);
db.recall_feedback(
Some("learning query"),
Some(&emb),
&rid,
feedback_type,
Some(score),
Some(i + 1),
)
.unwrap();
}
let result = db.run_learning();
assert!(
result.is_ok(),
"run_learning should not error with 25 feedback items: {:?}",
result.err()
);
}
#[test]
fn test_think_includes_learning() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let emb = vec_seed(1.0, 8);
let rid = db
.record(
"think learning integration",
"episodic",
0.7,
0.0,
604800.0,
&empty_meta(),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
for i in 0..26 {
let feedback_type = if i % 4 == 0 { "irrelevant" } else { "relevant" };
db.recall_feedback(
Some("think query"),
Some(&emb),
&rid,
feedback_type,
Some(0.5),
Some(i + 1),
)
.unwrap();
}
let config = ThinkConfig::default();
let result = db.think(&config);
assert!(
result.is_ok(),
"think() should not error when learning has enough feedback: {:?}",
result.err()
);
}
#[test]
fn test_conflict_entity_substitution_org() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let emb1 = vec_seed(1.0, 8);
let emb2 = vec_seed(1.1, 8);
db.relate("User", "Google", "works_at", 1.0).unwrap();
db.relate("User", "Meta", "works_at", 1.0).unwrap();
db.record(
"User works at Google as a senior engineer",
"episodic",
0.7,
0.0,
604800.0,
&empty_meta(),
&emb1,
"default",
0.8,
"work",
"user",
None,
)
.unwrap();
db.record(
"User works at Meta as a senior engineer",
"episodic",
0.7,
0.0,
604800.0,
&empty_meta(),
&emb2,
"default",
0.8,
"work",
"user",
None,
)
.unwrap();
let conflicts = crate::conflict::scan_conflicts(&db).unwrap();
assert!(!conflicts.is_empty(), "should detect works_at conflict");
assert_eq!(conflicts[0].conflict_type, "identity_fact");
}
#[test]
fn test_conflict_entity_substitution_tech() {
let db = YantrikDB::new(":memory:", 384).unwrap();
db.relate("API", "PostgreSQL", "uses", 1.0).unwrap();
db.relate("API", "MySQL", "uses", 1.0).unwrap();
let emb1 = vec_seed(2.0, 384);
let emb2 = vec_seed(2.05, 384);
db.record(
"The API service uses PostgreSQL for the database layer",
"semantic",
0.8,
0.0,
604800.0,
&empty_meta(),
&emb1,
"default",
0.8,
"architecture",
"user",
None,
)
.unwrap();
db.record(
"The API service uses MySQL for the database layer",
"semantic",
0.8,
0.0,
604800.0,
&empty_meta(),
&emb2,
"default",
0.8,
"architecture",
"user",
None,
)
.unwrap();
let conflicts = crate::conflict::scan_conflicts(&db).unwrap();
let _entity_based = conflicts
.iter()
.filter(|c| c.detection_reason.contains("contradict"))
.collect::<Vec<_>>();
}
#[test]
fn reclassify_reuses_existing_category_when_name_already_taken() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let pre_id = "cat_pre_existing";
{
let conn = db.conn();
conn.execute(
"INSERT INTO substitution_categories \
(id, name, conflict_mode, status, created_at, updated_at, hlc, origin_actor) \
VALUES (?1, 'learned_zorblat_quibnix', 'exclusive', 'active', 1.0, 1.0, X'00', 'test')",
rusqlite::params![pre_id],
)
.unwrap();
for (i, tok) in ["zorblat", "quibnix"].iter().enumerate() {
conn.execute(
"INSERT INTO substitution_members \
(id, category_id, token_normalized, token_display, confidence, source, \
status, created_at, updated_at, hlc, origin_actor) \
VALUES (?1, ?2, ?3, ?3, 1.0, 'llm_suggested', 'pending', 1.0, 1.0, X'00', 'test')",
rusqlite::params![format!("m{i}"), pre_id, tok],
)
.unwrap();
}
}
let mk = |text: &str, emb: &[f32]| {
db.record(
text,
"semantic",
0.7,
0.0,
604800.0,
&empty_meta(),
emb,
"default",
0.8,
"work",
"user",
None,
)
.unwrap()
};
let a = mk("the config sets zorblat for caching", &vec_seed(1.0, 8));
let b = mk("the config sets quibnix for caching", &vec_seed(1.1, 8));
let mk_conflict = |id: &str| {
db.conn()
.execute(
"INSERT INTO conflicts \
(conflict_id, conflict_type, priority, status, memory_a, memory_b, \
detected_at, detected_by, detection_reason, hlc, origin_actor) \
VALUES (?1, 'redundancy', 'medium', 'open', ?2, ?3, 2000.0, 'test', \
'same attribute, different value', X'00', 'test')",
rusqlite::params![id, a, b],
)
.unwrap();
};
mk_conflict("cf1");
mk_conflict("cf2");
db.reclassify_conflict("cf1", "semantic").unwrap();
let res = db
.reclassify_conflict("cf2", "semantic")
.expect("reclassify must reuse the existing category, not die on its FK");
assert!(
res.learned_members
.iter()
.any(|m| m.category_name == "learned_zorblat_quibnix"),
"strategy 3 must have run and targeted the colliding name, got {:?}",
res.learned_members
);
let n: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM substitution_categories WHERE name = 'learned_zorblat_quibnix'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 1, "no duplicate category row");
let orphans: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM substitution_members m \
WHERE NOT EXISTS (SELECT 1 FROM substitution_categories c WHERE c.id = m.category_id)",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
orphans, 0,
"no members stranded under a non-existent category"
);
let promoted: Vec<(String, String, String)> = db
.conn()
.prepare(
"SELECT token_normalized, source, status FROM substitution_members \
WHERE category_id = ?1 ORDER BY token_normalized",
)
.unwrap()
.query_map(rusqlite::params![pre_id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
for (tok, source, status) in &promoted {
assert_eq!(
(source.as_str(), status.as_str()),
("user_confirmed", "active"),
"{tok} must be promoted to user_confirmed/active, got {source}/{status}"
);
}
let visible: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM substitution_members \
WHERE token_normalized IN ('zorblat', 'quibnix') AND status = 'active'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
visible, 2,
"both learned tokens are findable as active members"
);
let members: Vec<String> = db
.conn()
.prepare(
"SELECT token_normalized FROM substitution_members \
WHERE category_id = ?1 ORDER BY token_normalized",
)
.unwrap()
.query_map(rusqlite::params![pre_id], |r| r.get(0))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert!(
members.contains(&"zorblat".to_string()) && members.contains(&"quibnix".to_string()),
"members belong to the surviving category, got {members:?}"
);
}
#[test]
fn learn_category_members_creates_new_category_without_deadlocking() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let db = YantrikDB::new(":memory:", 8).unwrap();
let r = db.learn_category_members(
"brand_new_category",
&[("alpha".to_string(), 1.0), ("beta".to_string(), 1.0)],
"user_confirmed",
);
let n: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM substitution_categories WHERE name = 'brand_new_category'",
[],
|r| r.get(0),
)
.unwrap_or(-1);
let _ = tx.send((r.map_err(|e| e.to_string()), n));
});
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok((res, n)) => {
let added = res.expect("learn_category_members must create the category");
assert_eq!(added, 2, "both members ingested");
assert_eq!(n, 1, "the new category row exists");
}
Err(_) => panic!(
"learn_category_members deadlocked creating a new category: \
self.conn() re-locked inside a match arm whose scrutinee still holds the guard"
),
}
}
#[test]
fn learn_category_members_promotes_but_never_demotes() {
let db = YantrikDB::new(":memory:", 8).unwrap();
db.learn_category_members("promo_cat", &[("tok".to_string(), 0.4)], "llm_suggested")
.unwrap();
let row = |db: &YantrikDB| -> (String, String, f64) {
db.conn()
.query_row(
"SELECT source, status, confidence FROM substitution_members \
WHERE token_normalized = 'tok'",
[],
|r| Ok((r.get(0).unwrap(), r.get(1).unwrap(), r.get(2).unwrap())),
)
.unwrap()
};
assert_eq!(
(row(&db).0.as_str(), row(&db).1.as_str()),
("llm_suggested", "pending"),
"llm_suggested starts pending"
);
db.learn_category_members("promo_cat", &[("tok".to_string(), 0.9)], "user_confirmed")
.unwrap();
let (source, status, conf) = row(&db);
assert_eq!(
(source.as_str(), status.as_str()),
("user_confirmed", "active"),
"user_confirmed promotes the pending row"
);
assert!(
(conf - 0.9).abs() < 1e-9,
"confidence promoted too, got {conf}"
);
db.learn_category_members("promo_cat", &[("tok".to_string(), 0.1)], "llm_suggested")
.unwrap();
let (source, status, conf) = row(&db);
assert_eq!(
(source.as_str(), status.as_str()),
("user_confirmed", "active"),
"a later llm_suggested must not demote a user_confirmed member"
);
assert!(
(conf - 0.9).abs() < 1e-9,
"confidence must not be clobbered by weaker evidence, got {conf}"
);
}
#[test]
fn reclassify_reinforcement_does_not_rebrand_seed_members() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let seed_sources = |db: &YantrikDB| -> Vec<(String, String)> {
db.conn()
.prepare(
"SELECT token_normalized, source FROM substitution_members \
WHERE token_normalized IN ('postgresql', 'mysql') ORDER BY token_normalized",
)
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap()
};
assert_eq!(
seed_sources(&db),
vec![
("mysql".to_string(), "seed".to_string()),
("postgresql".to_string(), "seed".to_string()),
],
"precondition: both ship as seed members"
);
let mk = |text: &str, emb: &[f32]| {
db.record(
text,
"semantic",
0.7,
0.0,
604800.0,
&empty_meta(),
emb,
"default",
0.8,
"work",
"user",
None,
)
.unwrap()
};
let a = mk("the service stores rows in postgresql", &vec_seed(1.0, 8));
let b = mk("the service stores rows in mysql", &vec_seed(1.1, 8));
db.conn()
.execute(
"INSERT INTO conflicts \
(conflict_id, conflict_type, priority, status, memory_a, memory_b, \
detected_at, detected_by, detection_reason, hlc, origin_actor) \
VALUES ('cf_seed', 'redundancy', 'medium', 'open', ?1, ?2, 2000.0, 'test', \
'same attribute, different value', X'00', 'test')",
rusqlite::params![a, b],
)
.unwrap();
db.reclassify_conflict("cf_seed", "semantic").unwrap();
assert_eq!(
seed_sources(&db),
vec![
("mysql".to_string(), "seed".to_string()),
("postgresql".to_string(), "seed".to_string()),
],
"seed provenance must survive a user_confirmed reinforcement"
);
let removed = db.reset_category_to_seed("databases").unwrap();
let survivors: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM substitution_members \
WHERE token_normalized IN ('postgresql', 'mysql')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
survivors, 2,
"reset_category_to_seed deleted seed members that reclassify had rebranded \
(removed {removed})"
);
}
#[test]
fn member_source_rank_agrees_with_sql() {
let db = YantrikDB::new(":memory:", 8).unwrap();
let conn = db.conn();
conn.execute(
"INSERT INTO substitution_categories \
(id, name, conflict_mode, status, created_at, updated_at, hlc, origin_actor) \
VALUES ('rc', 'rank_probe_cat', 'exclusive', 'active', 1.0, 1.0, X'00', 't')",
[],
)
.unwrap();
for (i, source) in ["seed", "user_confirmed", "llm_suggested", "something_else"]
.iter()
.enumerate()
{
let tok = format!("tok{i}");
conn.execute(
"INSERT INTO substitution_members \
(id, category_id, token_normalized, token_display, confidence, source, \
status, context_hint, created_at, updated_at, hlc, origin_actor) \
VALUES (?1, 'rc', ?2, ?2, 1.0, ?3, 'active', NULL, 1.0, 1.0, X'00', 't')",
rusqlite::params![format!("rp{i}"), tok, source],
)
.unwrap();
let sql_rank: i64 = conn
.query_row(
&format!(
"SELECT {} FROM substitution_members WHERE token_normalized = ?1",
crate::engine::conflict::MEMBER_SOURCE_RANK_SQL
),
rusqlite::params![tok],
|r| r.get(0),
)
.unwrap();
let rust_rank = YantrikDB::member_source_rank(source);
assert_eq!(
sql_rank, rust_rank as i64,
"rank ladders disagree for source '{source}'"
);
}
}