|
1 |
+ |
//! Exemplar k-NN tag suggestion (Layer B of the hybrid classifier).
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! The user's labeled library *is* the model: every sample carrying a non-ML tag is an
|
|
4 |
+ |
//! exemplar. A query sample is scored by its nearest labeled neighbors in standardized
|
|
5 |
+ |
//! feature space; each candidate tag gets a soft, similarity-weighted multi-label score
|
|
6 |
+ |
//! in [0, 1]. Per-tag dual thresholds (`tag_policy`) split scores into auto-apply /
|
|
7 |
+ |
//! review / ignore.
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! ML-applied tags (`source = 'ml'`) are excluded from the exemplar labels so the layer
|
|
10 |
+ |
//! never trains on its own output. Suggestions carry their driving neighbors for
|
|
11 |
+ |
//! explainability ("looks like these kicks you have"). See [`crate::rules`] for provenance.
|
|
12 |
+ |
|
|
13 |
+ |
use std::collections::HashMap;
|
|
14 |
+ |
|
|
15 |
+ |
use crate::db::Database;
|
|
16 |
+ |
use crate::error::{CoreError, Result};
|
|
17 |
+ |
use tracing::instrument;
|
|
18 |
+ |
|
|
19 |
+ |
use super::classify::{FEATURE_VERSION, NUM_FEATURES};
|
|
20 |
+ |
|
|
21 |
+ |
/// Default neighbor count.
|
|
22 |
+ |
pub const DEFAULT_K: usize = 15;
|
|
23 |
+ |
/// Default score at/above which a tag is queued for review.
|
|
24 |
+ |
pub const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5;
|
|
25 |
+ |
/// Default score at/above which a tag is auto-applied.
|
|
26 |
+ |
pub const DEFAULT_AUTO_THRESHOLD: f64 = 0.85;
|
|
27 |
+ |
|
|
28 |
+ |
// ── Per-tag policy ──
|
|
29 |
+ |
|
|
30 |
+ |
/// Confidence thresholds for a tag. `auto >= review`; setting them equal collapses to
|
|
31 |
+ |
/// binary behaviour (auto-apply above, ignore below, no review band).
|
|
32 |
+ |
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
33 |
+ |
pub struct TagPolicy {
|
|
34 |
+ |
pub review_threshold: f64,
|
|
35 |
+ |
pub auto_threshold: f64,
|
|
36 |
+ |
}
|
|
37 |
+ |
|
|
38 |
+ |
impl Default for TagPolicy {
|
|
39 |
+ |
fn default() -> Self {
|
|
40 |
+ |
Self {
|
|
41 |
+ |
review_threshold: DEFAULT_REVIEW_THRESHOLD,
|
|
42 |
+ |
auto_threshold: DEFAULT_AUTO_THRESHOLD,
|
|
43 |
+ |
}
|
|
44 |
+ |
}
|
|
45 |
+ |
}
|
|
46 |
+ |
|
|
47 |
+ |
/// Fetch a tag's policy, or the default if none is configured.
|
|
48 |
+ |
#[instrument(skip_all)]
|
|
49 |
+ |
pub fn get_policy(db: &Database, tag: &str) -> Result<TagPolicy> {
|
|
50 |
+ |
let policy = db
|
|
51 |
+ |
.conn()
|
|
52 |
+ |
.query_row(
|
|
53 |
+ |
"SELECT review_threshold, auto_threshold FROM tag_policy WHERE tag = ?1",
|
|
54 |
+ |
[tag],
|
|
55 |
+ |
|row| Ok(TagPolicy { review_threshold: row.get(0)?, auto_threshold: row.get(1)? }),
|
|
56 |
+ |
)
|
|
57 |
+ |
.ok()
|
|
58 |
+ |
.unwrap_or_default();
|
|
59 |
+ |
Ok(policy)
|
|
60 |
+ |
}
|
|
61 |
+ |
|
|
62 |
+ |
/// Set a tag's thresholds. `auto` is clamped to be >= `review`, both into [0, 1].
|
|
63 |
+ |
#[instrument(skip_all)]
|
|
64 |
+ |
pub fn set_policy(db: &Database, tag: &str, review: f64, auto: f64) -> Result<()> {
|
|
65 |
+ |
let review = review.clamp(0.0, 1.0);
|
|
66 |
+ |
let auto = auto.clamp(review, 1.0);
|
|
67 |
+ |
db.conn().execute(
|
|
68 |
+ |
"INSERT INTO tag_policy (tag, review_threshold, auto_threshold) VALUES (?1, ?2, ?3)
|
|
69 |
+ |
ON CONFLICT(tag) DO UPDATE SET review_threshold = ?2, auto_threshold = ?3",
|
|
70 |
+ |
rusqlite::params![tag, review, auto],
|
|
71 |
+ |
)?;
|
|
72 |
+ |
Ok(())
|
|
73 |
+ |
}
|
|
74 |
+ |
|
|
75 |
+ |
/// All configured per-tag policies.
|
|
76 |
+ |
#[instrument(skip_all)]
|
|
77 |
+ |
pub fn list_policies(db: &Database) -> Result<Vec<(String, TagPolicy)>> {
|
|
78 |
+ |
let mut stmt = db
|
|
79 |
+ |
.conn()
|
|
80 |
+ |
.prepare("SELECT tag, review_threshold, auto_threshold FROM tag_policy ORDER BY tag")?;
|
|
81 |
+ |
let rows = stmt.query_map([], |row| {
|
|
82 |
+ |
Ok((
|
|
83 |
+ |
row.get::<_, String>(0)?,
|
|
84 |
+ |
TagPolicy { review_threshold: row.get(1)?, auto_threshold: row.get(2)? },
|
|
85 |
+ |
))
|
|
86 |
+ |
})?;
|
|
87 |
+ |
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
|
88 |
+ |
}
|
|
89 |
+ |
|
|
90 |
+ |
// ── Index + scoring ──
|
|
91 |
+ |
|
|
92 |
+ |
struct Exemplar {
|
|
93 |
+ |
hash: String,
|
|
94 |
+ |
/// Standardized feature vector.
|
|
95 |
+ |
vector: Vec<f64>,
|
|
96 |
+ |
tags: Vec<String>,
|
|
97 |
+ |
}
|
|
98 |
+ |
|
|
99 |
+ |
/// In-memory k-NN index over the labeled library. Rebuild after tag changes.
|
|
100 |
+ |
pub struct ExemplarIndex {
|
|
101 |
+ |
means: Vec<f64>,
|
|
102 |
+ |
stds: Vec<f64>,
|
|
103 |
+ |
exemplars: Vec<Exemplar>,
|
|
104 |
+ |
pub feature_version: u32,
|
|
105 |
+ |
}
|
|
106 |
+ |
|
|
107 |
+ |
/// A neighbor that contributed to a tag's score.
|
|
108 |
+ |
#[derive(Debug, Clone)]
|
|
109 |
+ |
pub struct NeighborContribution {
|
|
110 |
+ |
pub hash: String,
|
|
111 |
+ |
pub weight: f64,
|
|
112 |
+ |
}
|
|
113 |
+ |
|
|
114 |
+ |
/// A candidate tag with its similarity-weighted score and the neighbors behind it.
|
|
115 |
+ |
#[derive(Debug, Clone)]
|
|
116 |
+ |
pub struct TagScore {
|
|
117 |
+ |
pub tag: String,
|
|
118 |
+ |
pub score: f64,
|
|
119 |
+ |
pub neighbors: Vec<NeighborContribution>,
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
/// Whether a scored tag should be auto-applied or queued for review.
|
|
123 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
124 |
+ |
pub enum MlAction {
|
|
125 |
+ |
AutoApply,
|
|
126 |
+ |
Review,
|
|
127 |
+ |
}
|
|
128 |
+ |
|
|
129 |
+ |
/// A suggestion with its disposition under the per-tag policy.
|
|
130 |
+ |
#[derive(Debug, Clone)]
|
|
131 |
+ |
pub struct MlSuggestion {
|
|
132 |
+ |
pub tag: String,
|
|
133 |
+ |
pub score: f64,
|
|
134 |
+ |
pub action: MlAction,
|
|
135 |
+ |
pub neighbors: Vec<NeighborContribution>,
|
|
136 |
+ |
}
|
|
137 |
+ |
|
|
138 |
+ |
/// Auto-applied vs review-queued suggestions for a sample.
|
|
139 |
+ |
#[derive(Debug, Clone, Default)]
|
|
140 |
+ |
pub struct MlOutcome {
|
|
141 |
+ |
pub auto_applied: Vec<MlSuggestion>,
|
|
142 |
+ |
pub pending_review: Vec<MlSuggestion>,
|
|
143 |
+ |
}
|
|
144 |
+ |
|
|
145 |
+ |
/// Build the index from every sample carrying at least one non-ML tag.
|
|
146 |
+ |
#[instrument(skip_all)]
|
|
147 |
+ |
pub fn build_index(db: &Database) -> Result<ExemplarIndex> {
|
|
148 |
+ |
let conn = db.conn();
|
|
149 |
+ |
|
|
150 |
+ |
// Non-ML tags per sample (the trusted labels).
|
|
151 |
+ |
let mut tags_by_hash: HashMap<String, Vec<String>> = HashMap::new();
|
|
152 |
+ |
{
|
|
153 |
+ |
let mut stmt = conn.prepare(
|
|
154 |
+ |
"SELECT t.sample_hash, t.tag FROM tags t
|
|
155 |
+ |
WHERE NOT EXISTS (
|
|
156 |
+ |
SELECT 1 FROM tag_provenance p
|
|
157 |
+ |
WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')",
|
|
158 |
+ |
)?;
|
|
159 |
+ |
let rows = stmt.query_map([], |row| {
|
|
160 |
+ |
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
161 |
+ |
})?;
|
|
162 |
+ |
for r in rows {
|
|
163 |
+ |
let (hash, tag) = r?;
|
|
164 |
+ |
tags_by_hash.entry(hash).or_default().push(tag);
|
|
165 |
+ |
}
|
|
166 |
+ |
}
|
|
167 |
+ |
|
|
168 |
+ |
// Raw vectors for those samples.
|
|
169 |
+ |
let mut raw: Vec<(String, Vec<f64>)> = Vec::new();
|
|
170 |
+ |
{
|
|
171 |
+ |
let mut stmt =
|
|
172 |
+ |
conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?;
|
|
173 |
+ |
let rows = stmt.query_map([FEATURE_VERSION], |row| {
|
|
174 |
+ |
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
175 |
+ |
})?;
|
|
176 |
+ |
for r in rows {
|
|
177 |
+ |
let (hash, json) = r?;
|
|
178 |
+ |
if !tags_by_hash.contains_key(&hash) {
|
|
179 |
+ |
continue;
|
|
180 |
+ |
}
|
|
181 |
+ |
let v: Vec<f64> = serde_json::from_str(&json)
|
|
182 |
+ |
.map_err(|e| CoreError::Serialization(e.to_string()))?;
|
|
183 |
+ |
if v.len() == NUM_FEATURES {
|
|
184 |
+ |
raw.push((hash, v));
|
|
185 |
+ |
}
|
|
186 |
+ |
}
|
|
187 |
+ |
}
|
|
188 |
+ |
|
|
189 |
+ |
// Standardization params over the exemplar set.
|
|
190 |
+ |
let (means, stds) = standardization_params(&raw);
|
|
191 |
+ |
let exemplars = raw
|
|
192 |
+ |
.into_iter()
|
|
193 |
+ |
.map(|(hash, v)| {
|
|
194 |
+ |
let vector = standardize_vec(&v, &means, &stds);
|
|
195 |
+ |
let tags = tags_by_hash.remove(&hash).unwrap_or_default();
|
|
196 |
+ |
Exemplar { hash, vector, tags }
|
|
197 |
+ |
})
|
|
198 |
+ |
.collect();
|
|
199 |
+ |
|
|
200 |
+ |
Ok(ExemplarIndex { means, stds, exemplars, feature_version: FEATURE_VERSION })
|
|
201 |
+ |
}
|
|
202 |
+ |
|
|
203 |
+ |
fn standardization_params(raw: &[(String, Vec<f64>)]) -> (Vec<f64>, Vec<f64>) {
|
|
204 |
+ |
if raw.is_empty() {
|
|
205 |
+ |
return (vec![0.0; NUM_FEATURES], vec![1.0; NUM_FEATURES]);
|
|
206 |
+ |
}
|
|
207 |
+ |
let n = raw.len() as f64;
|
|
208 |
+ |
let dims = raw[0].1.len();
|
|
209 |
+ |
let mut means = vec![0.0; dims];
|
|
210 |
+ |
let mut stds = vec![1.0; dims];
|
|
211 |
+ |
for d in 0..dims {
|
|
212 |
+ |
let mean = raw.iter().map(|(_, v)| v[d]).sum::<f64>() / n;
|
|
213 |
+ |
let var = raw.iter().map(|(_, v)| (v[d] - mean).powi(2)).sum::<f64>() / n;
|
|
214 |
+ |
means[d] = mean;
|
|
215 |
+ |
let s = var.sqrt();
|
|
216 |
+ |
stds[d] = if s > f64::EPSILON { s } else { 1.0 };
|
|
217 |
+ |
}
|
|
218 |
+ |
(means, stds)
|
|
219 |
+ |
}
|
|
220 |
+ |
|
|
221 |
+ |
fn standardize_vec(v: &[f64], means: &[f64], stds: &[f64]) -> Vec<f64> {
|
|
222 |
+ |
v.iter()
|
|
223 |
+ |
.zip(means)
|
|
224 |
+ |
.zip(stds)
|
|
225 |
+ |
.map(|((x, m), s)| (x - m) / s)
|
|
226 |
+ |
.collect()
|
|
227 |
+ |
}
|
|
228 |
+ |
|
|
229 |
+ |
fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
|
|
230 |
+ |
a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum()
|
|
231 |
+ |
}
|
|
232 |
+ |
|
|
233 |
+ |
impl ExemplarIndex {
|
|
234 |
+ |
pub fn len(&self) -> usize {
|
|
235 |
+ |
self.exemplars.len()
|
|
236 |
+ |
}
|
|
237 |
+ |
|
|
238 |
+ |
pub fn is_empty(&self) -> bool {
|
|
239 |
+ |
self.exemplars.is_empty()
|
|
240 |
+ |
}
|
|
241 |
+ |
|
|
242 |
+ |
/// Score candidate tags for a raw (un-standardized) query vector. `exclude_hash`
|
|
243 |
+ |
/// drops the query's own exemplar (when scoring an already-labeled sample).
|
|
244 |
+ |
pub fn score(&self, raw_query: &[f64], k: usize, exclude_hash: Option<&str>) -> Vec<TagScore> {
|
|
245 |
+ |
if self.exemplars.is_empty() || raw_query.len() != NUM_FEATURES || k == 0 {
|
|
246 |
+ |
return Vec::new();
|
|
247 |
+ |
}
|
|
248 |
+ |
let q = standardize_vec(raw_query, &self.means, &self.stds);
|
|
249 |
+ |
|
|
250 |
+ |
// Distances to all eligible exemplars.
|
|
251 |
+ |
let mut dists: Vec<(usize, f64)> = self
|
|
252 |
+ |
.exemplars
|
|
253 |
+ |
.iter()
|
|
254 |
+ |
.enumerate()
|
|
255 |
+ |
.filter(|(_, e)| exclude_hash != Some(e.hash.as_str()))
|
|
256 |
+ |
.map(|(i, e)| (i, sq_dist(&q, &e.vector)))
|
|
257 |
+ |
.collect();
|
|
258 |
+ |
if dists.is_empty() {
|
|
259 |
+ |
return Vec::new();
|
|
260 |
+ |
}
|
|
261 |
+ |
dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
|
262 |
+ |
dists.truncate(k);
|
|
263 |
+ |
|
|
264 |
+ |
// Adaptive Gaussian bandwidth = mean neighbor distance.
|
|
265 |
+ |
let mean_d = (dists.iter().map(|(_, d2)| d2.sqrt()).sum::<f64>() / dists.len() as f64)
|
|
266 |
+ |
.max(f64::EPSILON);
|
|
267 |
+ |
let denom = 2.0 * mean_d * mean_d;
|
|
268 |
+ |
|
|
269 |
+ |
let mut total_weight = 0.0;
|
|
270 |
+ |
let weighted: Vec<(usize, f64)> = dists
|
|
271 |
+ |
.iter()
|
|
272 |
+ |
.map(|&(i, d2)| {
|
|
273 |
+ |
let w = (-d2 / denom).exp();
|
|
274 |
+ |
total_weight += w;
|
|
275 |
+ |
(i, w)
|
|
276 |
+ |
})
|
|
277 |
+ |
.collect();
|
|
278 |
+ |
if total_weight <= f64::EPSILON {
|
|
279 |
+ |
return Vec::new();
|
|
280 |
+ |
}
|
|
281 |
+ |
|
|
282 |
+ |
// Accumulate per-tag weight + contributing neighbors.
|
|
283 |
+ |
let mut acc: HashMap<&str, (f64, Vec<NeighborContribution>)> = HashMap::new();
|
|
284 |
+ |
for &(i, w) in &weighted {
|
|
285 |
+ |
let e = &self.exemplars[i];
|
|
286 |
+ |
for tag in &e.tags {
|
|
287 |
+ |
let slot = acc.entry(tag.as_str()).or_insert((0.0, Vec::new()));
|
|
288 |
+ |
slot.0 += w;
|
|
289 |
+ |
slot.1.push(NeighborContribution { hash: e.hash.clone(), weight: w });
|
|
290 |
+ |
}
|
|
291 |
+ |
}
|
|
292 |
+ |
|
|
293 |
+ |
let mut scores: Vec<TagScore> = acc
|
|
294 |
+ |
.into_iter()
|
|
295 |
+ |
.map(|(tag, (sum_w, neighbors))| TagScore {
|
|
296 |
+ |
tag: tag.to_string(),
|
|
297 |
+ |
score: sum_w / total_weight,
|
|
298 |
+ |
neighbors,
|
|
299 |
+ |
})
|
|
300 |
+ |
.collect();
|
|
301 |
+ |
scores.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap().then(a.tag.cmp(&b.tag)));
|
|
302 |
+ |
scores
|
|
303 |
+ |
}
|
|
304 |
+ |
}
|
|
305 |
+ |
|
|
306 |
+ |
/// Load a sample's raw feature vector (current version), if present.
|
|
307 |
+ |
fn load_query_vector(db: &Database, hash: &str) -> Result<Option<Vec<f64>>> {
|
|
308 |
+ |
let json: Option<String> = db
|
|
309 |
+ |
.conn()
|
|
310 |
+ |
.query_row(
|
|
311 |
+ |
"SELECT vector FROM sample_features WHERE hash = ?1 AND feat_version = ?2",
|
|
312 |
+ |
rusqlite::params![hash, FEATURE_VERSION],
|
|
313 |
+ |
|row| row.get(0),
|
|
314 |
+ |
)
|
|
315 |
+ |
.ok();
|
|
316 |
+ |
match json {
|
|
317 |
+ |
Some(j) => {
|
|
318 |
+ |
let v: Vec<f64> =
|
|
319 |
+ |
serde_json::from_str(&j).map_err(|e| CoreError::Serialization(e.to_string()))?;
|
|
320 |
+ |
Ok(Some(v))
|
|
321 |
+ |
}
|
|
322 |
+ |
None => Ok(None),
|
|
323 |
+ |
}
|
|
324 |
+ |
}
|
|
325 |
+ |
|
|
326 |
+ |
/// Split scored tags into auto-apply / review under each tag's policy, dropping tags the
|
|
327 |
+ |
/// sample already has and anything below its review threshold.
|
|
328 |
+ |
fn apply_policy(
|
|
329 |
+ |
db: &Database,
|
|
330 |
+ |
scores: Vec<TagScore>,
|
|
331 |
+ |
existing: &std::collections::HashSet<String>,
|
|
332 |
+ |
) -> Result<MlOutcome> {
|
|
333 |
+ |
let mut outcome = MlOutcome::default();
|
|
334 |
+ |
for s in scores {
|
|
335 |
+ |
if existing.contains(&s.tag) {
|
|
336 |
+ |
continue;
|
|
337 |
+ |
}
|
|
338 |
+ |
let policy = get_policy(db, &s.tag)?;
|
|
339 |
+ |
if s.score >= policy.auto_threshold {
|
|
340 |
+ |
outcome.auto_applied.push(MlSuggestion {
|
|
341 |
+ |
tag: s.tag,
|
|
342 |
+ |
score: s.score,
|
|
343 |
+ |
action: MlAction::AutoApply,
|
|
344 |
+ |
neighbors: s.neighbors,
|
|
345 |
+ |
});
|
|
346 |
+ |
} else if s.score >= policy.review_threshold {
|
|
347 |
+ |
outcome.pending_review.push(MlSuggestion {
|
|
348 |
+ |
tag: s.tag,
|
|
349 |
+ |
score: s.score,
|
|
350 |
+ |
action: MlAction::Review,
|
|
351 |
+ |
neighbors: s.neighbors,
|
|
352 |
+ |
});
|
|
353 |
+ |
}
|
|
354 |
+ |
}
|
|
355 |
+ |
Ok(outcome)
|
|
356 |
+ |
}
|
|
357 |
+ |
|
|
358 |
+ |
fn sample_tag_set(db: &Database, hash: &str) -> Result<std::collections::HashSet<String>> {
|
|
359 |
+ |
Ok(crate::tags::get_sample_tags(db, hash)?.into_iter().collect())
|
|
360 |
+ |
}
|
|
361 |
+ |
|
|
362 |
+ |
/// Score a stored sample against the index without writing anything.
|
|
363 |
+ |
#[instrument(skip_all)]
|
|
364 |
+ |
pub fn preview_sample(
|
|
365 |
+ |
db: &Database,
|
|
366 |
+ |
hash: &str,
|
|
367 |
+ |
index: &ExemplarIndex,
|
|
368 |
+ |
k: usize,
|
|
369 |
+ |
) -> Result<MlOutcome> {
|
|
370 |
+ |
let Some(raw) = load_query_vector(db, hash)? else {
|
|
371 |
+ |
return Ok(MlOutcome::default());
|
|
372 |
+ |
};
|
|
373 |
+ |
let scores = index.score(&raw, k, Some(hash));
|
|
374 |
+ |
let existing = sample_tag_set(db, hash)?;
|
|
375 |
+ |
apply_policy(db, scores, &existing)
|
|
376 |
+ |
}
|
|
377 |
+ |
|
|
378 |
+ |
/// Score a stored sample and **auto-apply** the above-threshold tags (`source = 'ml'`),
|
|
379 |
+ |
/// returning what was applied and what's pending review.
|
|
380 |
+ |
#[instrument(skip_all)]
|
|
381 |
+ |
pub fn apply_ml_suggestions(
|
|
382 |
+ |
db: &Database,
|
|
383 |
+ |
hash: &str,
|
|
384 |
+ |
index: &ExemplarIndex,
|
|
385 |
+ |
k: usize,
|
|
386 |
+ |
) -> Result<MlOutcome> {
|
|
387 |
+ |
let outcome = preview_sample(db, hash, index, k)?;
|
|
388 |
+ |
for s in &outcome.auto_applied {
|
|
389 |
+ |
crate::rules::apply_tag_sourced(db, hash, &s.tag, "ml")?;
|
|
390 |
+ |
}
|
|
391 |
+ |
Ok(outcome)
|
|
392 |
+ |
}
|
|
393 |
+ |
|
|
394 |
+ |
#[cfg(test)]
|
|
395 |
+ |
mod tests {
|
|
396 |
+ |
use super::*;
|
|
397 |
+ |
|
|
398 |
+ |
fn insert(db: &Database, hash: &str, vec: [f64; NUM_FEATURES], tags: &[&str]) {
|
|
399 |
+ |
db.conn()
|
|
400 |
+ |
.execute(
|
|
401 |
+ |
"INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
|
|
402 |
+ |
VALUES (?1, ?1, 'wav', 1, 0, 0)",
|
|
403 |
+ |
[hash],
|
|
404 |
+ |
)
|
|
405 |
+ |
.unwrap();
|
|
406 |
+ |
let json = serde_json::to_string(&vec.to_vec()).unwrap();
|
|
407 |
+ |
db.conn()
|
|
408 |
+ |
.execute(
|
|
409 |
+ |
"INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
|
|
410 |
+ |
rusqlite::params![hash, FEATURE_VERSION, json],
|
|
411 |
+ |
)
|
|
412 |
+ |
.unwrap();
|
|
413 |
+ |
for t in tags {
|
|
414 |
+ |
crate::tags::add_tag(db, hash, t).unwrap();
|
|
415 |
+ |
}
|
|
416 |
+ |
}
|
|
417 |
+ |
|
|
418 |
+ |
fn vec_at(base: f64) -> [f64; NUM_FEATURES] {
|
|
419 |
+ |
[base; NUM_FEATURES]
|
|
420 |
+ |
}
|
|
421 |
+ |
|
|
422 |
+ |
#[test]
|
|
423 |
+ |
fn policy_defaults_and_clamping() {
|
|
424 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
425 |
+ |
assert_eq!(get_policy(&db, "x").unwrap(), TagPolicy::default());
|
|
426 |
+ |
// auto clamped to >= review.
|
|
427 |
+ |
set_policy(&db, "x", 0.7, 0.3).unwrap();
|
|
428 |
+ |
let p = get_policy(&db, "x").unwrap();
|
|
429 |
+ |
assert_eq!(p.review_threshold, 0.7);
|
|
430 |
+ |
assert_eq!(p.auto_threshold, 0.7);
|
|
431 |
+ |
}
|
|
432 |
+ |
|
|
433 |
+ |
#[test]
|
|
434 |
+ |
fn empty_index_yields_nothing() {
|
|
435 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
436 |
+ |
let index = build_index(&db).unwrap();
|
|
437 |
+ |
assert!(index.is_empty());
|
|
438 |
+ |
insert(&db, "q", vec_at(0.0), &[]);
|
|
439 |
+ |
assert!(apply_ml_suggestions(&db, "q", &index, DEFAULT_K).unwrap().auto_applied.is_empty());
|
|
440 |
+ |
}
|
|
441 |
+ |
|
|
442 |
+ |
#[test]
|
|
443 |
+ |
fn auto_applies_dominant_neighbor_tag() {
|
|
444 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
445 |
+ |
// A tight cluster of kicks, plus one far-away snare.
|
|
446 |
+ |
insert(&db, "k1", vec_at(0.0), &["instrument.drum.kick"]);
|
|
447 |
+ |
insert(&db, "k2", vec_at(0.05), &["instrument.drum.kick"]);
|
|
448 |
+ |
insert(&db, "k3", vec_at(-0.05), &["instrument.drum.kick"]);
|
|
449 |
+ |
insert(&db, "s1", vec_at(100.0), &["instrument.drum.snare"]);
|
|
450 |
+ |
// Unlabeled query right next to the kicks.
|
|
451 |
+ |
insert(&db, "q", vec_at(0.02), &[]);
|
|
452 |
+ |
|
|
453 |
+ |
let index = build_index(&db).unwrap();
|
|
454 |
+ |
let outcome = apply_ml_suggestions(&db, "q", &index, DEFAULT_K).unwrap();
|
|
455 |
+ |
let applied: Vec<&str> = outcome.auto_applied.iter().map(|s| s.tag.as_str()).collect();
|
|
456 |
+ |
assert!(applied.contains(&"instrument.drum.kick"), "got {applied:?}");
|
|
457 |
+ |
// The applied tag is provenance 'ml'.
|
|
458 |
+ |
let prov = crate::rules::sample_tag_provenance(&db, "q").unwrap();
|
|
459 |
+ |
assert!(prov.iter().any(|(t, s, _)| t == "instrument.drum.kick" && s == "ml"));
|
|
460 |
+ |
// Neighbors are surfaced for explainability.
|
|
461 |
+ |
let kick = outcome.auto_applied.iter().find(|s| s.tag == "instrument.drum.kick").unwrap();
|
|
462 |
+ |
assert!(!kick.neighbors.is_empty());
|
|
463 |
+ |
}
|
|
464 |
+ |
|
|
465 |
+ |
#[test]
|
|
466 |
+ |
fn ml_tags_excluded_from_exemplars() {
|
|
467 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
468 |
+ |
// One real exemplar + one whose only tag is ML-sourced (must not count as label).
|
|
469 |
+ |
insert(&db, "k1", vec_at(0.0), &["instrument.drum.kick"]);
|
|
470 |
+ |
insert(&db, "m1", vec_at(0.0), &[]);
|
|
471 |
+ |
crate::rules::apply_tag_sourced(&db, "m1", "bogus.ml", "ml").unwrap();
|
|
472 |
+ |
|
|
473 |
+ |
let index = build_index(&db).unwrap();
|
|
474 |
+ |
// Only k1 is a labeled exemplar (m1's lone tag is ML).
|
|
475 |
+ |
assert_eq!(index.len(), 1);
|
|
476 |
+ |
}
|
|
477 |
+ |
|
|
478 |
+ |
#[test]
|
|
479 |
+ |
fn review_band_between_thresholds() {
|
|
480 |
+ |
let db = Database::open_in_memory().unwrap();
|
|
481 |
+ |
// Two equidistant neighbors with different tags -> each ~0.5 score.
|
|
482 |
+ |
insert(&db, "a", vec_at(0.0), &["x.a"]);
|
|
483 |
+ |
insert(&db, "b", vec_at(10.0), &["x.b"]);
|
|
484 |
+ |
insert(&db, "q", vec_at(5.0), &[]);
|
|
485 |
+ |
// Lower review threshold so the ~0.5 tags surface for review, never auto.
|
|
486 |
+ |
set_policy(&db, "x.a", 0.2, 0.99).unwrap();
|
|
487 |
+ |
set_policy(&db, "x.b", 0.2, 0.99).unwrap();
|
|
488 |
+ |
|
|
489 |
+ |
let index = build_index(&db).unwrap();
|
|
490 |
+ |
let outcome = preview_sample(&db, "q", &index, DEFAULT_K).unwrap();
|
|
491 |
+ |
assert!(outcome.auto_applied.is_empty());
|
|
492 |
+ |
assert!(!outcome.pending_review.is_empty());
|
|
493 |
+ |
}
|
|
494 |
+ |
}
|