1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! Recall demand log + knowledge-gap surface (v0.9.0).
//!
//! The substrate's "known unknowns." Every user-facing recall records, in a
//! cheap O(1) aggregate keyed by the *normalized* query, how often that query
//! is asked and how well it's answered (result count + best-hit score). From
//! that, [`YantrikDB::knowledge_gaps`] surfaces the queries people keep asking
//! that return little or nothing — the demand the memory *should* satisfy but
//! doesn't.
//!
//! This closes the retrieval-demand loop: no other memory layer tells you what
//! it's missing. Bounded by distinct-query cardinality (not total recalls);
//! the write is one indexed UPSERT, in the same spirit as the per-recall
//! `reinforce` the engine already does, and gated off for internal/eval recalls
//! (`skip_reinforce`).
use rusqlite::{params, OptionalExtension};
use crate::error::Result;
use super::{now, YantrikDB};
/// A frequently-asked, poorly-answered query — a demand the substrate should
/// satisfy but can't.
#[derive(Debug, Clone, serde::Serialize)]
pub struct KnowledgeGap {
/// A recent raw form of the query.
pub query: String,
/// How many times it has been asked.
pub count: u64,
/// Mean best-hit score across those asks (low = poorly answered).
pub avg_top_score: f64,
/// Mean number of results returned.
pub avg_results: f64,
pub last_seen: f64,
}
/// Normalize a query into a cluster key: lowercase, whitespace-collapsed,
/// trailing terminal punctuation stripped. So "Who owns the rotation?" and
/// "who owns the rotation" aggregate together.
pub(crate) fn normalize_query(q: &str) -> String {
q.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_end_matches(['?', '.', '!', ' '])
.to_string()
}
impl YantrikDB {
/// Record one recall's outcome into the demand aggregate. Best-effort and
/// cheap; called automatically on user-facing recalls (not internal ones).
pub(crate) fn record_recall_demand(
&self,
query_text: &str,
result_count: usize,
top_score: f64,
) -> Result<()> {
let norm = normalize_query(query_text);
if norm.is_empty() {
return Ok(());
}
let conn = self.conn();
conn.execute(
"INSERT INTO recall_demand \
(query_norm, sample_text, count, sum_top_score, sum_results, last_seen) \
VALUES (?1, ?2, 1, ?3, ?4, ?5) \
ON CONFLICT(query_norm) DO UPDATE SET \
count = count + 1, \
sample_text = ?2, \
sum_top_score = sum_top_score + ?3, \
sum_results = sum_results + ?4, \
last_seen = ?5",
params![norm, query_text, top_score, result_count as i64, now()],
)?;
Ok(())
}
/// Surface knowledge gaps: queries asked at least `min_count` times whose
/// mean best-hit score is at or below `max_avg_top_score` (i.e. frequently
/// asked, poorly answered), most-asked first.
pub fn knowledge_gaps(
&self,
min_count: u64,
max_avg_top_score: f64,
limit: usize,
) -> Result<Vec<KnowledgeGap>> {
let conn = self.conn();
let mut stmt = conn.prepare(
"SELECT sample_text, count, sum_top_score, sum_results, last_seen \
FROM recall_demand \
WHERE count >= ?1 AND (sum_top_score / count) <= ?2 \
ORDER BY count DESC, (sum_top_score / count) ASC \
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![min_count as i64, max_avg_top_score, limit as i64],
|r| {
let count: i64 = r.get(1)?;
let sum_top: f64 = r.get(2)?;
let sum_res: i64 = r.get(3)?;
let n = count.max(1) as f64;
Ok(KnowledgeGap {
query: r.get(0)?,
count: count.max(0) as u64,
avg_top_score: sum_top / n,
avg_results: sum_res as f64 / n,
last_seen: r.get(4)?,
})
},
)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Point lookup of the demand stats for one query (mainly for tests/ops):
/// returns (count, avg_top_score) or None.
pub fn recall_demand_for(&self, query_text: &str) -> Result<Option<(u64, f64)>> {
let norm = normalize_query(query_text);
let conn = self.conn();
let row = conn
.query_row(
"SELECT count, sum_top_score FROM recall_demand WHERE query_norm = ?1",
params![norm],
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, f64>(1)?)),
)
.optional()?;
Ok(row.map(|(c, s)| (c.max(0) as u64, s / c.max(1) as f64)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_clusters_punctuation_and_case() {
assert_eq!(
normalize_query("Who Owns the rotation?"),
"who owns the rotation"
);
assert_eq!(
normalize_query("who owns the rotation"),
"who owns the rotation"
);
}
#[cfg(feature = "bundled-embedder")]
#[test]
fn knowledge_gaps_surface_frequent_low_yield_queries() {
let db = YantrikDB::with_default(":memory:").unwrap();
// A frequently-asked, poorly-answered query.
for _ in 0..5 {
db.record_recall_demand("how do I configure the widget", 0, 0.0)
.unwrap();
}
// A frequently-asked, WELL-answered query (should NOT be a gap).
for _ in 0..5 {
db.record_recall_demand("who is alice", 3, 1.1).unwrap();
}
// A rare poorly-answered query (below min_count, not yet a gap).
db.record_recall_demand("obscure one-off question", 0, 0.0)
.unwrap();
let gaps = db.knowledge_gaps(3, 0.4, 10).unwrap();
assert_eq!(gaps.len(), 1, "only the frequent low-yield query: {gaps:?}");
assert_eq!(gaps[0].query, "how do I configure the widget");
assert_eq!(gaps[0].count, 5);
assert!(gaps[0].avg_top_score <= 0.4);
}
}