use rusqlite::{params, Connection};
use crate::graph_index::GraphIndex;
const MAX_ANCHOR_ENTITIES: usize = 4;
const MAX_CLAIMS_PER_ENTITY: usize = 24;
const MAX_PATH_SEEDS: usize = 8;
const MAX_PATH_PER_SEED: usize = 4;
const MAX_PATH_CANDIDATES: usize = 16;
const DIRECT_CLAIM_LEX: f64 = 1.0;
const PATH_CLAIM_LEX: f64 = 0.9;
const PATH_MARKER: &str = "(path via ";
const CHAIN_DENY_RELS: &[&str] = &["co_occurs_with", "related_to", "mentions"];
pub(crate) fn chain_traversable(rel_type: &str) -> bool {
!CHAIN_DENY_RELS.contains(&rel_type)
}
pub(crate) struct ClaimCandidate {
pub rid: String,
pub why: String,
pub hops: u8,
}
pub(crate) fn claims_lex_strength(why: &[String]) -> Option<f64> {
let w = why.iter().find(|w| w.starts_with("claims_match"))?;
Some(if w.contains(PATH_MARKER) {
PATH_CLAIM_LEX
} else {
DIRECT_CLAIM_LEX
})
}
pub(crate) const GROUNDING_NONE: i64 = 0;
pub(crate) const GROUNDING_COOPERATIVE: i64 = 1;
pub(crate) const GROUNDING_EXTRACTOR_BOUND: i64 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainGateMode {
Off,
Shadow,
Enforce,
}
impl ChainGateMode {
pub fn parse(s: &str) -> crate::error::Result<Self> {
match s {
"off" => Ok(Self::Off),
"shadow" => Ok(Self::Shadow),
"enforce" => Ok(Self::Enforce),
other => Err(crate::error::YantrikDbError::InvalidInput(format!(
"claim_chain_gate_mode: expected off|shadow|enforce, got {other:?}"
))),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Shadow => "shadow",
Self::Enforce => "enforce",
}
}
pub fn as_u8(self) -> u8 {
match self {
Self::Off => 0,
Self::Shadow => 1,
Self::Enforce => 2,
}
}
pub fn from_u8(v: u8) -> Self {
match v {
2 => Self::Enforce,
1 => Self::Shadow,
_ => Self::Off,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Ineligible {
Ungrounded,
NotYetValid,
Superseded,
Negated,
NonAsserted,
}
impl Ineligible {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Ungrounded => "ungrounded",
Self::NotYetValid => "not_yet_valid",
Self::Superseded => "superseded",
Self::Negated => "negated",
Self::NonAsserted => "non_asserted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Suppression {
pub hop: &'static str,
pub reason: Ineligible,
}
impl Suppression {
pub(crate) fn key(&self) -> String {
format!("{}:{}", self.hop, self.reason.as_str())
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ChainGate {
pub mode: ChainGateMode,
pub as_of: f64,
}
impl ChainGate {
pub(crate) fn new(mode: ChainGateMode, as_of: f64) -> Self {
Self { mode, as_of }
}
#[cfg(test)]
pub(crate) fn off() -> Self {
Self {
mode: ChainGateMode::Off,
as_of: f64::MAX,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ClaimRow {
pub src: String,
pub rel: String,
pub dst: String,
pub rid: String,
pub polarity: i64,
pub modality: String,
pub valid_from: Option<f64>,
pub valid_to: Option<f64>,
pub grounding: i64,
}
fn valid_as_of(row: &ClaimRow, as_of: f64) -> Option<Ineligible> {
if row.valid_from.is_some_and(|from| from > as_of) {
return Some(Ineligible::NotYetValid);
}
if row.valid_to.is_some_and(|to| to < as_of) {
return Some(Ineligible::Superseded);
}
None
}
pub(crate) fn direct_ineligibility(row: &ClaimRow, as_of: f64) -> Option<Ineligible> {
if row.grounding < GROUNDING_COOPERATIVE {
return Some(Ineligible::Ungrounded);
}
valid_as_of(row, as_of)
}
pub(crate) fn traversal_ineligibility(row: &ClaimRow, as_of: f64) -> Option<Ineligible> {
if let Some(reason) = direct_ineligibility(row, as_of) {
return Some(reason);
}
if row.polarity < 1 {
return Some(Ineligible::Negated);
}
if row.modality != "asserted" {
return Some(Ineligible::NonAsserted);
}
None
}
fn claims_touching(conn: &Connection, entity: &str, namespace: Option<&str>) -> Vec<ClaimRow> {
let ns_clause = if namespace.is_some() {
"AND namespace = ?2"
} else {
""
};
let sql = format!(
"SELECT src, rel_type, dst, source_memory_rid, polarity, modality, valid_from, \
valid_to, grounding FROM claims \
WHERE (src = ?1 OR dst = ?1) AND tombstoned = 0 \
AND source_memory_rid IS NOT NULL {ns_clause} \
ORDER BY created_at DESC LIMIT {MAX_CLAIMS_PER_ENTITY}",
);
let legacy_sql = format!(
"SELECT src, rel_type, dst, source_memory_rid, polarity, 'asserted', NULL, NULL, \
{GROUNDING_NONE} FROM claims \
WHERE (src = ?1 OR dst = ?1) AND tombstoned = 0 \
AND source_memory_rid IS NOT NULL {ns_clause} \
ORDER BY created_at DESC LIMIT {MAX_CLAIMS_PER_ENTITY}",
);
let mut stmt = match conn.prepare_cached(&sql) {
Ok(stmt) => stmt,
Err(_) => match conn.prepare_cached(&legacy_sql) {
Ok(stmt) => stmt,
Err(_) => return Vec::new(), },
};
let mapper = |row: &rusqlite::Row| -> rusqlite::Result<ClaimRow> {
Ok(ClaimRow {
src: row.get(0)?,
rel: row.get(1)?,
dst: row.get(2)?,
rid: row.get(3)?,
polarity: row.get(4)?,
modality: row.get(5)?,
valid_from: row.get(6)?,
valid_to: row.get(7)?,
grounding: row.get(8)?,
})
};
let rows: Vec<ClaimRow> = if let Some(ns) = namespace {
stmt.query_map(params![entity, ns], mapper)
.map(|r| r.filter_map(|x| x.ok()).collect())
.unwrap_or_default()
} else {
stmt.query_map(params![entity], mapper)
.map(|r| r.filter_map(|x| x.ok()).collect())
.unwrap_or_default()
};
rows.into_iter()
.filter(|row| {
!(crate::graph::is_rejected_entity_name(&row.src)
|| (crate::graph::is_rejected_entity_name(&row.dst)
&& !(crate::graph::is_value_object(&row.dst)
&& crate::graph::relation_admits_value_object(&row.rel, &row.dst))))
})
.collect()
}
pub(crate) fn claims_candidates(
conn: &Connection,
graph_index: &GraphIndex,
query_tokens: &[String],
namespace: Option<&str>,
gate: &ChainGate,
) -> (Vec<ClaimCandidate>, Vec<Suppression>) {
let mut suppressed: Vec<Suppression> = Vec::new();
let audit = gate.mode != ChainGateMode::Off;
let enforce = gate.mode == ChainGateMode::Enforce;
let mut anchors = graph_index.entity_matches_query(query_tokens);
if anchors.is_empty() {
return (Vec::new(), suppressed);
}
anchors.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
anchors.truncate(MAX_ANCHOR_ENTITIES);
let anchor_names: std::collections::HashSet<&str> =
anchors.iter().map(|(name, _, _)| name.as_str()).collect();
let mut out: Vec<ClaimCandidate> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut path_seeds: Vec<(String, String, String)> = Vec::new();
for (entity, _etype, _mentions) in &anchors {
for row in claims_touching(conn, entity, namespace) {
if audit {
if let Some(reason) = direct_ineligibility(&row, gate.as_of) {
suppressed.push(Suppression {
hop: "hop1",
reason,
});
if enforce {
continue; }
}
}
let neg = if row.polarity < 0 { "NOT " } else { "" };
let far = if row.src == *entity {
&row.dst
} else {
&row.src
};
if chain_traversable(&row.rel)
&& !anchor_names.contains(far.as_str())
&& path_seeds.len() < MAX_PATH_SEEDS
&& !path_seeds.iter().any(|(seed, _, _)| seed == far)
{
let refused = if audit {
traversal_ineligibility(&row, gate.as_of)
} else {
None
};
if let Some(reason) = refused {
suppressed.push(Suppression {
hop: "seed",
reason,
});
}
if refused.is_none() || !enforce {
path_seeds.push((
far.clone(),
entity.clone(),
format!("{} -{neg}{}-> {}", row.src, row.rel, row.dst),
));
}
}
if !seen.insert(row.rid.clone()) {
continue;
}
out.push(ClaimCandidate {
why: format!(
"claims_match: {} -{neg}{}-> {} (anchor {entity})",
row.src, row.rel, row.dst
),
rid: row.rid,
hops: 1,
});
}
}
let mut path_admitted = 0usize;
for (seed, anchor, hop1) in &path_seeds {
if path_admitted >= MAX_PATH_CANDIDATES {
break;
}
let rows = claims_touching(conn, seed, namespace);
if rows.len() >= MAX_CLAIMS_PER_ENTITY {
continue;
}
let mut per_seed = 0usize;
for row in rows {
if per_seed >= MAX_PATH_PER_SEED || path_admitted >= MAX_PATH_CANDIDATES {
break;
}
if !chain_traversable(&row.rel) {
continue; }
if seen.contains(&row.rid) {
continue; }
if audit {
if let Some(reason) = traversal_ineligibility(&row, gate.as_of) {
suppressed.push(Suppression {
hop: "hop2",
reason,
});
if enforce {
continue; }
}
}
seen.insert(row.rid.clone());
let neg = if row.polarity < 0 { "NOT " } else { "" };
out.push(ClaimCandidate {
why: format!(
"claims_match: {hop1} ; {} -{neg}{}-> {} \
{PATH_MARKER}{seed}, anchor {anchor})",
row.src, row.rel, row.dst
),
rid: row.rid,
hops: 2,
});
per_seed += 1;
path_admitted += 1;
}
}
(out, suppressed)
}
impl super::YantrikDB {
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply_claims_lane(
&self,
scored: &mut Vec<crate::types::RecallResult>,
query_embedding: &[f32],
query_text: Option<&str>,
namespace: Option<&str>,
time_window: Option<(f64, f64)>,
include_consolidated: bool,
memory_type: Option<&str>,
domain: Option<&str>,
source: Option<&str>,
certainty_min: Option<f64>,
event_allow: Option<&std::collections::HashSet<String>>,
learned_weights: &crate::types::LearnedWeights,
ts: f64,
query_sentiment: f64,
) -> crate::error::Result<()> {
use crate::base::scoring;
let Some(qt) = query_text else {
return Ok(());
};
let gate = ChainGate::new(
self.claim_chain_gate_mode(),
time_window.map_or(ts, |(_, hi)| hi),
);
let (cands, suppressed) = {
let gi = self.graph_index.read();
let tokens = crate::graph::tokenize(qt);
let conn = self.read_conn();
claims_candidates(&conn, &gi, &tokens, namespace, &gate)
};
self.note_chain_gate_suppressions(&suppressed);
if cands.is_empty() {
return Ok(());
}
let mut by_rid: std::collections::HashMap<&str, &str> = cands
.iter()
.map(|c| (c.rid.as_str(), c.why.as_str()))
.collect();
for result in scored.iter_mut() {
if let Some(why) = by_rid.remove(result.rid.as_str()) {
if !result
.why_retrieved
.iter()
.any(|w| w.starts_with("claims_match"))
{
result.why_retrieved.push(why.to_string());
}
}
}
let new_rids: Vec<(&str, &str)> = cands
.iter()
.filter(|c| by_rid.contains_key(c.rid.as_str()))
.map(|c| (c.rid.as_str(), c.why.as_str()))
.collect();
if new_rids.is_empty() {
return Ok(());
}
let rid_refs: Vec<&str> = new_rids.iter().map(|(r, _)| *r).collect();
let emb_map = self.fetch_embeddings_by_rids(&rid_refs)?;
let cache = self.scoring_cache.read();
for (rid, claim_why) in new_rids {
let Some(row) = cache.get(rid) else { continue };
if !crate::engine::recall::passes_recall_filters(
rid,
row,
include_consolidated,
memory_type,
time_window,
namespace,
domain,
source,
certainty_min,
event_allow,
) {
continue;
}
let Some(emb_blob) = emb_map.get(rid) else {
continue;
};
let mem_emb = crate::serde_helpers::deserialize_f32(emb_blob);
let sim_score = crate::consolidate::cosine_similarity(query_embedding, &mem_emb) as f64;
let decay = scoring::ranking_decay(row.importance, row.created_at, ts);
let age = ts - row.created_at;
let recency = scoring::recency_score(age);
let composite = scoring::adaptive_composite_score(
sim_score,
decay,
recency,
row.importance,
row.valence,
query_sentiment,
learned_weights,
);
let mut why = scoring::build_why(sim_score, recency, decay, row.valence);
why.push(claim_why.to_string());
let contributions = scoring::adaptive_contributions(
sim_score,
decay,
recency,
row.importance,
learned_weights,
);
let valence_multiplier = scoring::query_valence_boost(row.valence, query_sentiment);
scored.push(crate::types::RecallResult {
rid: rid.to_string(),
memory_type: row.memory_type.clone(),
text: String::new(),
created_at: row.created_at,
importance: row.importance,
valence: row.valence,
score: composite,
scores: crate::types::ScoreBreakdown {
similarity: sim_score,
decay,
recency,
importance: row.importance,
graph_proximity: 0.0,
contributions,
valence_multiplier,
},
why_retrieved: why,
metadata: serde_json::Value::Null,
namespace: row.namespace.clone(),
certainty: row.certainty,
domain: row.domain.clone(),
source: row.source.clone(),
emotional_state: row.emotional_state.clone(),
current_status: Default::default(),
superseded_by: None,
disputed_with: Vec::new(),
aged_last_verified: None,
best_span: None,
pack: None,
event_time_min: None,
event_time_max: None,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded_store() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE entities (name TEXT PRIMARY KEY, entity_type TEXT, \
first_seen REAL, last_seen REAL, mention_count INTEGER);
CREATE TABLE memory_entities (memory_rid TEXT, entity_name TEXT);
CREATE TABLE claims (claim_id TEXT PRIMARY KEY, src TEXT NOT NULL, \
dst TEXT NOT NULL, rel_type TEXT NOT NULL, weight REAL DEFAULT 1.0, \
created_at REAL NOT NULL, tombstoned INTEGER NOT NULL DEFAULT 0, \
polarity INTEGER NOT NULL DEFAULT 1, \
modality TEXT NOT NULL DEFAULT 'asserted', valid_from REAL, valid_to REAL, \
extractor TEXT NOT NULL DEFAULT 'manual', source_memory_rid TEXT, \
namespace TEXT NOT NULL DEFAULT 'default', \
grounding INTEGER NOT NULL DEFAULT 0);
CREATE VIEW edges AS SELECT src, dst, weight, tombstoned FROM claims;",
)
.unwrap();
for (name, etype, mc) in [
("DB", "tech", 5),
("Postgres", "tech", 3),
("THE", "unknown", 10),
] {
conn.execute(
"INSERT INTO entities (name, entity_type, first_seen, last_seen, mention_count) \
VALUES (?1, ?2, 0.0, 0.0, ?3)",
params![name, etype, mc],
)
.unwrap();
}
for (cid, src, dst, rel, ts, extractor, rid) in [
("c1", "DB", "THE", "leads", 3.0, "heuristic_v1", "m1"),
("c2", "DB", "Postgres", "uses", 2.0, "heuristic_v1", "m2"),
("c3", "THE", "DB", "leads", 1.0, "manual", "m3"),
] {
conn.execute(
"INSERT INTO claims (claim_id, src, dst, rel_type, created_at, \
extractor, source_memory_rid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![cid, src, dst, rel, ts, extractor, rid],
)
.unwrap();
}
conn
}
#[test]
fn stopword_endpoint_claims_are_suppressed_at_read() {
let conn = seeded_store();
let gi = GraphIndex::build_from_db(&conn).unwrap();
let tokens = crate::graph::tokenize("what does DB use");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
let rids: Vec<&str> = cands.iter().map(|c| c.rid.as_str()).collect();
assert!(
!rids.contains(&"m1"),
"heuristic claim with stopword dst must be suppressed, got {rids:?}"
);
assert!(
rids.contains(&"m2"),
"legitimate claim must still match, got {rids:?}"
);
assert!(
!rids.contains(&"m3"),
"migration-backfilled 'manual' label must NOT exempt a phantom-anchored claim in this lane, got {rids:?}"
);
}
#[test]
fn numeric_endpoint_claims_are_suppressed_at_read() {
let conn = seeded_store();
conn.execute(
"INSERT INTO entities (name, entity_type, first_seen, last_seen, mention_count) \
VALUES ('15', 'unknown', 0.0, 0.0, 4)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO claims (claim_id, src, dst, rel_type, created_at, extractor, \
source_memory_rid) VALUES ('c15', '15', 'LOG', 'leads', 0.0, 'heuristic_v1', 'm9')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO memory_entities (memory_rid, entity_name) VALUES ('m9', '15')",
[],
)
.unwrap();
let gi = GraphIndex::build_from_db(&conn).unwrap();
let tokens = crate::graph::tokenize("release 15 log architecture");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
let rids: Vec<&str> = cands.iter().map(|c| c.rid.as_str()).collect();
assert!(
!rids.contains(&"m9"),
"bare-number-anchored heuristic claim must be suppressed, got {rids:?}"
);
}
#[test]
fn protected_stopword_anchor_surfaces_only_manual_claims() {
let conn = seeded_store();
let gi = GraphIndex::build_from_db(&conn).unwrap();
assert!(
!gi.entity_matches_query(&[String::from("the")]).is_empty(),
"fixture must reproduce the protected-anchor precondition"
);
let tokens = crate::graph::tokenize("the database leads");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
assert!(
!cands.iter().any(|c| c.why.contains("DB -leads-> THE")),
"the exact live phantom why must never be emitted, got {:?}",
cands.iter().map(|c| c.why.as_str()).collect::<Vec<_>>()
);
assert!(
!cands.iter().any(|c| c.rid == "m3"),
"no phantom-anchored claim survives, whatever its extractor label — the migration backfill made 'manual' meaningless for lane rows"
);
}
#[test]
fn direction_provenance_is_spelled_out() {
let c = ClaimCandidate {
rid: "r".into(),
why: format!(
"claims_match: {} -{}{}-> {} (anchor {})",
"Taylor", "", "reports_to", "Carol", "Taylor"
),
hops: 1,
};
assert_eq!(
c.why,
"claims_match: Taylor -reports_to-> Carol (anchor Taylor)"
);
}
fn chain_store() -> Connection {
let conn = seeded_store();
for (name, etype) in [
("Alice Moreau", "person"),
("Fennwick Labs", "org"),
("Berlin", "place"),
] {
conn.execute(
"INSERT INTO entities (name, entity_type, first_seen, last_seen, mention_count) \
VALUES (?1, ?2, 0.0, 0.0, 2)",
params![name, etype],
)
.unwrap();
}
for (cid, src, dst, rel, ts, rid) in [
(
"cA",
"Alice Moreau",
"Fennwick Labs",
"works_at",
10.0,
"mA",
),
(
"cB",
"Fennwick Labs",
"Berlin",
"headquartered_in",
11.0,
"mB",
),
("cC", "Fennwick Labs", "THE", "leads", 12.0, "mC"),
] {
conn.execute(
"INSERT INTO claims (claim_id, src, dst, rel_type, created_at, \
extractor, source_memory_rid) VALUES (?1, ?2, ?3, ?4, ?5, 'heuristic_v1', ?6)",
params![cid, src, dst, rel, ts, rid],
)
.unwrap();
}
for (rid, name) in [
("mA", "Alice Moreau"),
("mA", "Fennwick Labs"),
("mB", "Fennwick Labs"),
("mB", "Berlin"),
] {
conn.execute(
"INSERT INTO memory_entities (memory_rid, entity_name) VALUES (?1, ?2)",
params![rid, name],
)
.unwrap();
}
conn
}
#[test]
fn chain_admits_the_second_hop_with_full_path_provenance() {
let conn = chain_store();
let gi = GraphIndex::build_from_db(&conn).unwrap();
let tokens = crate::graph::tokenize("which city does Alice Moreau work in");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
let direct = cands
.iter()
.find(|c| c.rid == "mA")
.expect("hop-1 record admitted");
assert_eq!(direct.hops, 1);
let hop2 = cands
.iter()
.find(|c| c.rid == "mB")
.expect("hop-2 record admitted");
assert_eq!(hop2.hops, 2);
assert_eq!(
hop2.why,
"claims_match: Alice Moreau -works_at-> Fennwick Labs ; \
Fennwick Labs -headquartered_in-> Berlin (path via Fennwick Labs, anchor Alice Moreau)"
);
assert!(
!cands.iter().any(|c| c.rid == "mC"),
"phantom endpoint on the seed must be suppressed at hop 2 too"
);
let hops: Vec<u8> = cands.iter().map(|c| c.hops).collect();
assert!(hops.windows(2).all(|w| w[0] <= w[1]), "got {hops:?}");
}
#[test]
fn chain_never_readmits_hop_one_provenance_from_the_seed_side() {
let conn = chain_store();
let gi = GraphIndex::build_from_db(&conn).unwrap();
let tokens = crate::graph::tokenize("Alice Moreau");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
assert_eq!(cands.iter().filter(|c| c.rid == "mA").count(), 1);
}
#[test]
fn hub_seed_is_not_traversed() {
let conn = chain_store();
for i in 0..MAX_CLAIMS_PER_ENTITY {
let (cid, dst, rid) = (format!("hub{i}"), format!("Partner{i}"), format!("mh{i}"));
conn.execute(
"INSERT INTO entities (name, entity_type, first_seen, last_seen, mention_count) \
VALUES (?1, 'org', 0.0, 0.0, 1)",
params![dst],
)
.unwrap();
conn.execute(
"INSERT INTO claims (claim_id, src, dst, rel_type, created_at, \
extractor, source_memory_rid) VALUES (?1, 'Fennwick Labs', ?2, 'partners_with', 20.0, \
'heuristic_v1', ?3)",
params![cid, dst, rid],
)
.unwrap();
}
let gi = GraphIndex::build_from_db(&conn).unwrap();
let tokens = crate::graph::tokenize("which city does Alice Moreau work in");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
assert!(
cands.iter().all(|c| c.hops == 1),
"hub must not be traversed: {:?}",
cands.iter().map(|c| &c.why).collect::<Vec<_>>()
);
}
#[test]
fn chain_never_follows_a_denied_relation() {
let conn = chain_store();
conn.execute(
"INSERT INTO entities (name, entity_type, first_seen, last_seen, mention_count) \
VALUES ('Lisbon', 'place', 0.0, 0.0, 1)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO claims (claim_id, src, dst, rel_type, created_at, \
extractor, source_memory_rid) VALUES ('cX', 'Fennwick Labs', 'Lisbon', \
'co_occurs_with', 30.0, 'heuristic_v1', 'mX')",
[],
)
.unwrap();
let gi = GraphIndex::build_from_db(&conn).unwrap();
let tokens = crate::graph::tokenize("which city does Alice Moreau work in");
let cands = claims_candidates(&conn, &gi, &tokens, None, &ChainGate::off()).0;
assert!(
cands.iter().any(|c| c.rid == "mB"),
"real hop-2 still admitted"
);
assert!(
!cands.iter().any(|c| c.rid == "mX"),
"co_occurs_with hop must not be followed"
);
assert!(!chain_traversable("co_occurs_with") && chain_traversable("works_at"));
}
#[test]
fn path_reserve_strength_ranks_below_a_direct_claim() {
let direct = vec!["claims_match: A -works_at-> B (anchor A)".to_string()];
let path = vec![
"recent".to_string(),
"claims_match: A -works_at-> B ; B -headquartered_in-> C (path via B, anchor A)"
.to_string(),
];
assert_eq!(claims_lex_strength(&direct), Some(DIRECT_CLAIM_LEX));
assert_eq!(claims_lex_strength(&path), Some(PATH_CLAIM_LEX));
assert!(PATH_CLAIM_LEX < DIRECT_CLAIM_LEX);
assert_eq!(claims_lex_strength(&["keyword_match".to_string()]), None);
}
fn pypi_store() -> Connection {
let conn = seeded_store();
for (name, etype, mc) in [
("CT128", "tech", 9),
("PyPI", "org", 4),
("Google", "org", 2),
] {
conn.execute(
"INSERT INTO entities (name, entity_type, first_seen, last_seen, mention_count) \
VALUES (?1, ?2, 0.0, 0.0, ?3)",
params![name, etype, mc],
)
.unwrap();
}
for (cid, src, dst, rel, ts, rid) in [
("cRun", "PyPI", "CT128", "runs", 40.0, "mRun"),
("cWork", "PyPI", "Google", "works_at", 41.0, "mWork"),
] {
conn.execute(
"INSERT INTO claims (claim_id, src, dst, rel_type, created_at, \
extractor, source_memory_rid) VALUES (?1, ?2, ?3, ?4, ?5, 'heuristic_v1', ?6)",
params![cid, src, dst, rel, ts, rid],
)
.unwrap();
}
for (rid, name) in [
("mRun", "CT128"),
("mRun", "PyPI"),
("mWork", "PyPI"),
("mWork", "Google"),
] {
conn.execute(
"INSERT INTO memory_entities (memory_rid, entity_name) VALUES (?1, ?2)",
params![rid, name],
)
.unwrap();
}
conn
}
fn run(conn: &Connection, query: &str, gate: ChainGate) -> (Vec<String>, Vec<String>) {
let gi = GraphIndex::build_from_db(conn).unwrap();
let tokens = crate::graph::tokenize(query);
let (cands, sups) = claims_candidates(conn, &gi, &tokens, None, &gate);
(
cands.iter().map(|c| c.rid.clone()).collect(),
sups.iter().map(|s| s.key()).collect(),
)
}
#[test]
fn shadow_counts_what_enforce_refuses_and_admits_everything() {
let conn = pypi_store();
let q = "what does CT128 run";
let (off_rids, off_sups) = run(&conn, q, ChainGate::off());
assert_eq!(
off_rids,
vec!["mRun", "mWork"],
"the pre-gate lane chains the junk"
);
assert!(off_sups.is_empty(), "off evaluates nothing");
let (rids, sups) = run(&conn, q, ChainGate::new(ChainGateMode::Shadow, 1000.0));
assert_eq!(rids, off_rids, "shadow changes no admission");
assert_eq!(
sups,
vec!["hop1:ungrounded", "seed:ungrounded", "hop2:ungrounded"],
"every stage enforce would refuse is counted, in lane order"
);
}
#[test]
fn enforce_never_builds_a_path_through_an_ungrounded_claim() {
let conn = pypi_store();
let (rids, sups) = run(
&conn,
"what does CT128 run",
ChainGate::new(ChainGateMode::Enforce, 1000.0),
);
assert!(rids.is_empty(), "both PyPI edges refused, got {rids:?}");
assert_eq!(
sups,
vec!["hop1:ungrounded"],
"a refused hop-1 row is never a seed"
);
}
#[test]
fn cooperative_claims_pass_and_a_denial_is_evidence_but_never_a_link() {
let conn = chain_store();
conn.execute(
"UPDATE claims SET grounding = 1 WHERE claim_id IN ('cA', 'cB')",
[],
)
.unwrap();
let q = "which city does Alice Moreau work in";
let (rids, sups) = run(&conn, q, ChainGate::new(ChainGateMode::Enforce, 1000.0));
assert_eq!(rids, vec!["mA", "mB"], "grounded chain survives enforce");
assert!(
sups.is_empty(),
"nothing to refuse on a grounded chain: {sups:?}"
);
conn.execute("UPDATE claims SET polarity = -1 WHERE claim_id = 'cA'", [])
.unwrap();
let (rids, sups) = run(&conn, q, ChainGate::new(ChainGateMode::Enforce, 1000.0));
assert_eq!(
rids,
vec!["mA"],
"the denial is admitted at hop 1, the path is not built"
);
assert!(sups.contains(&"seed:negated".to_string()), "{sups:?}");
conn.execute(
"UPDATE claims SET polarity = 1, modality = 'reported' WHERE claim_id = 'cA'",
[],
)
.unwrap();
let (rids, sups) = run(&conn, q, ChainGate::new(ChainGateMode::Enforce, 1000.0));
assert_eq!(rids, vec!["mA"]);
assert!(sups.contains(&"seed:non_asserted".to_string()), "{sups:?}");
}
#[test]
fn validity_is_judged_as_of_the_query_time() {
let conn = chain_store();
conn.execute(
"UPDATE claims SET grounding = 1, valid_from = 100.0, valid_to = 200.0 \
WHERE claim_id = 'cA'",
[],
)
.unwrap();
conn.execute("UPDATE claims SET grounding = 1 WHERE claim_id = 'cB'", [])
.unwrap();
let q = "Alice Moreau";
for (as_of, expect_rid, expect_sup) in [
(150.0, true, None),
(300.0, false, Some("hop1:superseded")),
(50.0, false, Some("hop1:not_yet_valid")),
] {
let (rids, sups) = run(&conn, q, ChainGate::new(ChainGateMode::Enforce, as_of));
assert_eq!(
rids.contains(&"mA".to_string()),
expect_rid,
"as_of {as_of}: {rids:?}"
);
match expect_sup {
Some(key) => assert!(sups.contains(&key.to_string()), "as_of {as_of}: {sups:?}"),
None => assert!(sups.is_empty(), "as_of {as_of}: {sups:?}"),
}
}
}
#[test]
fn a_pre_v53_claims_table_reads_as_ungrounded_not_as_an_error() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE entities (name TEXT PRIMARY KEY, entity_type TEXT, \
first_seen REAL, last_seen REAL, mention_count INTEGER);
CREATE TABLE memory_entities (memory_rid TEXT, entity_name TEXT);
CREATE TABLE claims (claim_id TEXT PRIMARY KEY, src TEXT NOT NULL, \
dst TEXT NOT NULL, rel_type TEXT NOT NULL, weight REAL DEFAULT 1.0, \
created_at REAL NOT NULL, tombstoned INTEGER NOT NULL DEFAULT 0, \
polarity INTEGER NOT NULL DEFAULT 1, \
extractor TEXT NOT NULL DEFAULT 'manual', source_memory_rid TEXT, \
namespace TEXT NOT NULL DEFAULT 'default');
CREATE VIEW edges AS SELECT src, dst, weight, tombstoned FROM claims;
INSERT INTO entities VALUES ('Taylor', 'person', 0.0, 0.0, 3);
INSERT INTO claims (claim_id, src, dst, rel_type, created_at, source_memory_rid) \
VALUES ('c', 'Taylor', 'Carol', 'reports_to', 1.0, 'm');
INSERT INTO memory_entities VALUES ('m', 'Taylor');",
)
.unwrap();
let (rids, sups) = run(&conn, "Taylor", ChainGate::new(ChainGateMode::Shadow, 10.0));
assert_eq!(
rids,
vec!["m"],
"legacy rows still reach the lane under shadow"
);
assert_eq!(
sups,
vec!["hop1:ungrounded", "seed:ungrounded"],
"read as ungrounded at both stages, never as an error"
);
}
#[test]
fn gate_mode_round_trips_and_refuses_garbage() {
for (text, mode) in [
("off", ChainGateMode::Off),
("shadow", ChainGateMode::Shadow),
("enforce", ChainGateMode::Enforce),
] {
let parsed = ChainGateMode::parse(text).unwrap();
assert_eq!(parsed, mode);
assert_eq!(parsed.as_str(), text);
assert_eq!(ChainGateMode::from_u8(parsed.as_u8()), mode);
}
assert!(ChainGateMode::parse("warn").is_err());
}
}