Skip to main content

max / audiofiles

25.4 KB · 715 lines History Blame Raw
1 //! `.afcl` classifier-layer file sharing (Phase 7 of the hybrid classifier).
2 //!
3 //! A `.afcl` ("AF classifier") is a portable, self-describing classifier-layer artifact.
4 //! It carries **no audio**, only the non-reversible 35-d feature vectors plus labels and
5 //! deterministic rules, so sharing one exposes nothing of the source library's audio. The
6 //! same mechanism serves user-to-user sharing and the eventual bundled official classifier
7 //! (which ships as a `kind = "official"` `.afcl`).
8 //!
9 //! The container is a single self-describing **JSON document** (dep-free; the design's
10 //! optional zip/gzip is skipped, these files are small and a plain JSON `.afcl` stays
11 //! diffable). An import creates a removable [`ClassifierLayer`]:
12 //!
13 //! - **Exemplars** land in `classifier_exemplars` (vector + tags only, no sample row),
14 //! join the k-NN index weighted *below* the user's own `local` data, and only fill gaps
15 //! or break ties (see [`super::exemplar::build_index`]).
16 //! - **Rules** are added as ordinary `tag_rules` rows, **disabled**, grouped under the
17 //! layer via `classifier_layer_rules` so the user reviews before enabling.
18 //! - **Policy** thresholds are applied only for tags the user hasn't already configured
19 //! (never clobbers the user's own thresholds).
20 //!
21 //! `feat_version` is gated at import: a `.afcl` built under a different feature layout is
22 //! rejected. The whole layer is removable in one action.
23
24 use std::collections::HashSet;
25 use std::path::Path;
26 use std::sync::atomic::{AtomicU64, Ordering};
27
28 use serde::{Deserialize, Serialize};
29
30 use crate::db::Database;
31 use crate::error::{CoreError, Result, io_err, unix_now};
32 use crate::rules::{self, NewRule, Rule};
33 use tracing::instrument;
34
35 use super::classify::{FEATURE_VERSION, NUM_FEATURES};
36
37 /// On-disk `.afcl` format version (independent of `FEATURE_VERSION`).
38 pub const AFCL_VERSION: u32 = 1;
39
40 /// Default k-NN weight for an imported layer, below the user's own `local` (1.0), so
41 /// imported exemplars fill gaps and break ties without overriding the user's labels.
42 pub const DEFAULT_IMPORT_WEIGHT: f64 = 0.5;
43
44 // ── File format ──
45
46 /// `.afcl` manifest. `feat_version` MUST match the importing app's [`FEATURE_VERSION`].
47 #[derive(Debug, Clone, Serialize, Deserialize)]
48 pub struct AfclManifest {
49 pub afcl_version: u32,
50 pub feat_version: u32,
51 pub name: String,
52 pub description: String,
53 /// `"imported"` (user share) or `"official"` (bundled default classifier).
54 pub kind: String,
55 pub created_at: i64,
56 /// Free-text note about the rights to the shared labels (UI surfaces it on export).
57 pub license_note: String,
58 pub exemplar_count: usize,
59 pub rule_count: usize,
60 pub policy_count: usize,
61 }
62
63 /// One shared exemplar: a feature vector and its labels (sample hash stripped).
64 #[derive(Debug, Clone, Serialize, Deserialize)]
65 pub struct AfclExemplar {
66 pub vector: Vec<f64>,
67 pub tags: Vec<String>,
68 }
69
70 /// One shared per-tag policy.
71 #[derive(Debug, Clone, Serialize, Deserialize)]
72 pub struct AfclPolicy {
73 pub tag: String,
74 pub review_threshold: f64,
75 pub auto_threshold: f64,
76 }
77
78 /// A complete `.afcl` document.
79 #[derive(Debug, Clone, Serialize, Deserialize)]
80 pub struct Afcl {
81 pub manifest: AfclManifest,
82 #[serde(default)]
83 pub exemplars: Vec<AfclExemplar>,
84 #[serde(default)]
85 pub rules: Vec<Rule>,
86 #[serde(default)]
87 pub policy: Vec<AfclPolicy>,
88 }
89
90 /// What to include when exporting.
91 #[derive(Debug, Clone)]
92 pub struct ExportOptions {
93 pub name: String,
94 pub description: String,
95 pub license_note: String,
96 pub include_exemplars: bool,
97 pub include_rules: bool,
98 pub include_policy: bool,
99 }
100
101 impl Default for ExportOptions {
102 fn default() -> Self {
103 Self {
104 name: "My classifier".to_string(),
105 description: String::new(),
106 license_note: String::new(),
107 include_exemplars: true,
108 include_rules: true,
109 include_policy: true,
110 }
111 }
112 }
113
114 /// A persisted classifier layer (a group of imported exemplars/rules).
115 #[derive(Debug, Clone)]
116 pub struct ClassifierLayer {
117 pub id: String,
118 pub name: String,
119 pub kind: String,
120 pub weight: f64,
121 pub enabled: bool,
122 pub source: Option<String>,
123 pub imported_at: i64,
124 /// Live counts (joined at list time).
125 pub exemplar_count: usize,
126 pub rule_count: usize,
127 }
128
129 /// Outcome of an import.
130 #[derive(Debug, Clone)]
131 pub struct ImportSummary {
132 pub layer_id: String,
133 pub name: String,
134 pub exemplars: usize,
135 pub rules: usize,
136 pub policies: usize,
137 }
138
139 // ── Layer ids ──
140
141 static LAYER_COUNTER: AtomicU64 = AtomicU64::new(0);
142
143 fn new_layer_id() -> String {
144 let n = LAYER_COUNTER.fetch_add(1, Ordering::Relaxed);
145 format!("layer-{:x}{:x}", unix_now(), n)
146 }
147
148 // ── Export ──
149
150 /// Build an [`Afcl`] from the user's own (`local`) classifier data per `opts`.
151 #[instrument(skip_all)]
152 pub fn build_export(db: &Database, opts: &ExportOptions) -> Result<Afcl> {
153 let exemplars = if opts.include_exemplars {
154 local_exemplars(db)?
155 } else {
156 Vec::new()
157 };
158 let rules = if opts.include_rules {
159 local_rules(db)?
160 } else {
161 Vec::new()
162 };
163 let policy = if opts.include_policy {
164 local_policy(db)?
165 } else {
166 Vec::new()
167 };
168
169 let manifest = AfclManifest {
170 afcl_version: AFCL_VERSION,
171 feat_version: FEATURE_VERSION,
172 name: opts.name.clone(),
173 description: opts.description.clone(),
174 kind: "imported".to_string(),
175 created_at: unix_now(),
176 license_note: opts.license_note.clone(),
177 exemplar_count: exemplars.len(),
178 rule_count: rules.len(),
179 policy_count: policy.len(),
180 };
181 Ok(Afcl {
182 manifest,
183 exemplars,
184 rules,
185 policy,
186 })
187 }
188
189 /// Serialize the user's classifier data to a `.afcl` JSON string.
190 pub fn export_to_string(db: &Database, opts: &ExportOptions) -> Result<String> {
191 let afcl = build_export(db, opts)?;
192 serde_json::to_string_pretty(&afcl).map_err(|e| CoreError::Serialization(e.to_string()))
193 }
194
195 /// Write the user's classifier data to a `.afcl` file.
196 pub fn export_to_path(db: &Database, path: &Path, opts: &ExportOptions) -> Result<()> {
197 let json = export_to_string(db, opts)?;
198 std::fs::write(path, json).map_err(|e| io_err(path, e))?;
199 Ok(())
200 }
201
202 /// The user's own (`local`) exemplars: current-version feature vectors carrying >=1 non-ML
203 /// tag. Sample hashes are intentionally dropped, only vector + tags travel.
204 fn local_exemplars(db: &Database) -> Result<Vec<AfclExemplar>> {
205 let conn = db.conn();
206 // Non-ML tags per local sample.
207 let mut tags_by_hash: std::collections::HashMap<String, Vec<String>> =
208 std::collections::HashMap::new();
209 {
210 let mut stmt = conn.prepare(
211 "SELECT t.sample_hash, t.tag FROM tags t
212 WHERE NOT EXISTS (
213 SELECT 1 FROM tag_provenance p
214 WHERE p.sample_hash = t.sample_hash AND p.tag = t.tag AND p.source = 'ml')",
215 )?;
216 let rows = stmt.query_map([], |row| {
217 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
218 })?;
219 for r in rows {
220 let (hash, tag) = r?;
221 tags_by_hash.entry(hash).or_default().push(tag);
222 }
223 }
224 let mut out = Vec::new();
225 let mut stmt =
226 conn.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1")?;
227 let rows = stmt.query_map([FEATURE_VERSION], |row| {
228 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
229 })?;
230 for r in rows {
231 let (hash, json) = r?;
232 let Some(tags) = tags_by_hash.remove(&hash) else {
233 continue;
234 };
235 let vector: Vec<f64> =
236 serde_json::from_str(&json).map_err(|e| CoreError::Serialization(e.to_string()))?;
237 if vector.len() == NUM_FEATURES {
238 out.push(AfclExemplar { vector, tags });
239 }
240 }
241 Ok(out)
242 }
243
244 /// The user's own rules (those not belonging to any imported layer).
245 fn local_rules(db: &Database) -> Result<Vec<Rule>> {
246 let imported: HashSet<String> = imported_rule_ids(db)?;
247 Ok(rules::list_rules(db)?
248 .into_iter()
249 .filter(|r| !imported.contains(&r.id))
250 .collect())
251 }
252
253 fn local_policy(db: &Database) -> Result<Vec<AfclPolicy>> {
254 Ok(super::exemplar::list_policies(db)?
255 .into_iter()
256 .map(|(tag, p)| AfclPolicy {
257 tag,
258 review_threshold: p.review_threshold,
259 auto_threshold: p.auto_threshold,
260 })
261 .collect())
262 }
263
264 fn imported_rule_ids(db: &Database) -> Result<HashSet<String>> {
265 let mut stmt = db
266 .conn()
267 .prepare("SELECT rule_id FROM classifier_layer_rules")?;
268 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
269 Ok(rows.collect::<std::result::Result<HashSet<_>, _>>()?)
270 }
271
272 // ── Import ──
273
274 /// Parse and import a `.afcl` JSON string as a new removable layer.
275 #[instrument(skip_all)]
276 pub fn import_from_string(
277 db: &Database,
278 json: &str,
279 source: Option<&str>,
280 ) -> Result<ImportSummary> {
281 let afcl: Afcl =
282 serde_json::from_str(json).map_err(|e| CoreError::Serialization(e.to_string()))?;
283 import(db, &afcl, source)
284 }
285
286 /// Read and import a `.afcl` file as a new removable layer.
287 pub fn import_from_path(db: &Database, path: &Path) -> Result<ImportSummary> {
288 let json = std::fs::read_to_string(path).map_err(|e| io_err(path, e))?;
289 let source = path.file_name().and_then(|s| s.to_str());
290 import_from_string(db, &json, source)
291 }
292
293 /// Import a parsed `.afcl`, creating a new layer. Gated on `afcl_version`/`feat_version`.
294 pub fn import(db: &Database, afcl: &Afcl, source: Option<&str>) -> Result<ImportSummary> {
295 if afcl.manifest.afcl_version > AFCL_VERSION {
296 return Err(CoreError::Incompatible(format!(
297 "this .afcl needs a newer version of audiofiles (format v{}, supported v{})",
298 afcl.manifest.afcl_version, AFCL_VERSION
299 )));
300 }
301 if afcl.manifest.feat_version != FEATURE_VERSION {
302 return Err(CoreError::Incompatible(format!(
303 "this .afcl was built for a different analysis version (features v{}, this app v{})",
304 afcl.manifest.feat_version, FEATURE_VERSION
305 )));
306 }
307
308 let layer_id = new_layer_id();
309 let kind = if afcl.manifest.kind == "official" {
310 "official"
311 } else {
312 "imported"
313 };
314 db.conn().execute(
315 "INSERT INTO classifier_layers (id, name, kind, weight, enabled, source, imported_at)
316 VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6)",
317 rusqlite::params![
318 layer_id,
319 afcl.manifest.name,
320 kind,
321 DEFAULT_IMPORT_WEIGHT,
322 source,
323 unix_now(),
324 ],
325 )?;
326
327 // Exemplars (vector + tags only).
328 let mut exemplars = 0;
329 for ex in &afcl.exemplars {
330 if ex.vector.len() != NUM_FEATURES {
331 continue;
332 }
333 // Reject non-finite vectors at the trust boundary. A crafted or corrupt
334 // `.afcl` carrying a NaN/Inf component would otherwise flow into the k-NN
335 // index, where it poisons standardization means (one NaN -> every
336 // standardized vector NaN) and reaches the distance-sort comparators.
337 if ex.vector.iter().any(|v| !v.is_finite()) {
338 tracing::warn!("skipping imported exemplar with non-finite feature vector");
339 continue;
340 }
341 let vector = serde_json::to_string(&ex.vector)
342 .map_err(|e| CoreError::Serialization(e.to_string()))?;
343 let tags =
344 serde_json::to_string(&ex.tags).map_err(|e| CoreError::Serialization(e.to_string()))?;
345 // Globally-unique TEXT id (layer id is unique) so it survives cross-device sync.
346 let ex_id = format!("{layer_id}#{exemplars}");
347 db.conn().execute(
348 "INSERT INTO classifier_exemplars (id, layer_id, feat_version, vector, tags)
349 VALUES (?1, ?2, ?3, ?4, ?5)",
350 rusqlite::params![ex_id, layer_id, FEATURE_VERSION, vector, tags],
351 )?;
352 exemplars += 1;
353 }
354
355 // Rules: created disabled, grouped under the layer for the user to review.
356 let mut rule_count = 0;
357 for r in &afcl.rules {
358 let created = rules::create_rule(
359 db,
360 NewRule {
361 name: r.name.clone(),
362 enabled: false,
363 priority: None,
364 match_mode: r.match_mode,
365 conditions: r.conditions.clone(),
366 actions: r.actions.clone(),
367 },
368 )?;
369 db.conn().execute(
370 "INSERT INTO classifier_layer_rules (layer_id, rule_id) VALUES (?1, ?2)",
371 rusqlite::params![layer_id, created.id],
372 )?;
373 rule_count += 1;
374 }
375
376 // Policy: only for tags the user hasn't configured (never clobber the user's own).
377 let mut policies = 0;
378 let configured: HashSet<String> = super::exemplar::list_policies(db)?
379 .into_iter()
380 .map(|(t, _)| t)
381 .collect();
382 for p in &afcl.policy {
383 if configured.contains(&p.tag) {
384 continue;
385 }
386 super::exemplar::set_policy(db, &p.tag, p.review_threshold, p.auto_threshold)?;
387 policies += 1;
388 }
389
390 Ok(ImportSummary {
391 layer_id,
392 name: afcl.manifest.name.clone(),
393 exemplars,
394 rules: rule_count,
395 policies,
396 })
397 }
398
399 // ── Layer management ──
400
401 /// All imported/official layers with live exemplar + rule counts, newest first.
402 #[instrument(skip_all)]
403 pub fn list_layers(db: &Database) -> Result<Vec<ClassifierLayer>> {
404 let conn = db.conn();
405 let mut stmt = conn.prepare(
406 "SELECT l.id, l.name, l.kind, l.weight, l.enabled, l.source, l.imported_at,
407 (SELECT COUNT(*) FROM classifier_exemplars e WHERE e.layer_id = l.id),
408 (SELECT COUNT(*) FROM classifier_layer_rules r WHERE r.layer_id = l.id)
409 FROM classifier_layers l ORDER BY l.imported_at DESC, l.id DESC",
410 )?;
411 let rows = stmt.query_map([], |row| {
412 Ok(ClassifierLayer {
413 id: row.get(0)?,
414 name: row.get(1)?,
415 kind: row.get(2)?,
416 weight: row.get(3)?,
417 enabled: row.get::<_, i64>(4)? != 0,
418 source: row.get(5)?,
419 imported_at: row.get(6)?,
420 exemplar_count: row.get::<_, i64>(7)? as usize,
421 rule_count: row.get::<_, i64>(8)? as usize,
422 })
423 })?;
424 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
425 }
426
427 /// Remove a layer and everything it brought in: its imported rules (from `tag_rules`,
428 /// reconciling their tags away), then the layer row (cascading exemplars + membership).
429 #[instrument(skip_all)]
430 pub fn remove_layer(db: &Database, layer_id: &str) -> Result<()> {
431 let rule_ids: Vec<String> = {
432 let mut stmt = db
433 .conn()
434 .prepare("SELECT rule_id FROM classifier_layer_rules WHERE layer_id = ?1")?;
435 let rows = stmt.query_map([layer_id], |row| row.get::<_, String>(0))?;
436 rows.collect::<std::result::Result<Vec<_>, _>>()?
437 };
438 // Delete children explicitly (not via FK cascade): recursive_triggers is off, so cascade
439 // deletes wouldn't fire the sync triggers and the removal wouldn't propagate to other
440 // devices. Membership first, then the rules (delete_rule reconciles their tags away and
441 // is sync-logged), then the exemplars, then the layer.
442 db.conn().execute(
443 "DELETE FROM classifier_layer_rules WHERE layer_id = ?1",
444 [layer_id],
445 )?;
446 for id in &rule_ids {
447 rules::delete_rule(db, id)?;
448 }
449 db.conn().execute(
450 "DELETE FROM classifier_exemplars WHERE layer_id = ?1",
451 [layer_id],
452 )?;
453 db.conn()
454 .execute("DELETE FROM classifier_layers WHERE id = ?1", [layer_id])?;
455 Ok(())
456 }
457
458 /// Enable or disable a layer (disabled layers contribute nothing to the k-NN index).
459 pub fn set_layer_enabled(db: &Database, layer_id: &str, enabled: bool) -> Result<()> {
460 db.conn().execute(
461 "UPDATE classifier_layers SET enabled = ?2 WHERE id = ?1",
462 rusqlite::params![layer_id, enabled as i64],
463 )?;
464 Ok(())
465 }
466
467 /// Set a layer's k-NN weight (clamped to `[0, 1]`, at or below the user's own data).
468 pub fn set_layer_weight(db: &Database, layer_id: &str, weight: f64) -> Result<()> {
469 let weight = weight.clamp(0.0, 1.0);
470 db.conn().execute(
471 "UPDATE classifier_layers SET weight = ?2 WHERE id = ?1",
472 rusqlite::params![layer_id, weight],
473 )?;
474 Ok(())
475 }
476
477 /// An imported exemplar joining the k-NN index: a synthetic id (`layer:<id>#<rowid>`, no
478 /// audio), its raw feature vector, tags, and the owning layer's weight.
479 pub struct ImportedExemplar {
480 pub id: String,
481 pub vector: Vec<f64>,
482 pub tags: Vec<String>,
483 pub weight: f64,
484 }
485
486 /// Every current-version exemplar from **enabled** layers, unioned into the k-NN index by
487 /// [`super::exemplar::build_index`], weighted below the user's own `local` data.
488 pub fn enabled_imported_exemplars(db: &Database) -> Result<Vec<ImportedExemplar>> {
489 let conn = db.conn();
490 let mut stmt = conn.prepare(
491 "SELECT e.id, e.vector, e.tags, l.weight
492 FROM classifier_exemplars e
493 JOIN classifier_layers l ON l.id = e.layer_id
494 WHERE l.enabled = 1 AND e.feat_version = ?1",
495 )?;
496 let rows = stmt.query_map([FEATURE_VERSION], |row| {
497 Ok((
498 row.get::<_, String>(0)?,
499 row.get::<_, String>(1)?,
500 row.get::<_, String>(2)?,
501 row.get::<_, f64>(3)?,
502 ))
503 })?;
504 let mut out = Vec::new();
505 for r in rows {
506 let (id, vec_json, tags_json, weight) = r?;
507 let vector: Vec<f64> =
508 serde_json::from_str(&vec_json).map_err(|e| CoreError::Serialization(e.to_string()))?;
509 let tags: Vec<String> = serde_json::from_str(&tags_json)
510 .map_err(|e| CoreError::Serialization(e.to_string()))?;
511 if vector.len() == NUM_FEATURES {
512 out.push(ImportedExemplar {
513 id,
514 vector,
515 tags,
516 weight,
517 });
518 }
519 }
520 Ok(out)
521 }
522
523 #[cfg(test)]
524 mod tests {
525 use super::*;
526 use crate::analysis::classify::NUM_FEATURES;
527
528 fn insert(db: &Database, hash: &str, fill: f64, tags: &[&str]) {
529 db.conn()
530 .execute(
531 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
532 VALUES (?1, ?1, 'wav', 1, 0, 0)",
533 [hash],
534 )
535 .unwrap();
536 let v = vec![fill; NUM_FEATURES];
537 let json = serde_json::to_string(&v).unwrap();
538 db.conn()
539 .execute(
540 "INSERT INTO sample_features (hash, feat_version, vector, computed_at) VALUES (?1, ?2, ?3, 0)",
541 rusqlite::params![hash, FEATURE_VERSION, json],
542 )
543 .unwrap();
544 for t in tags {
545 crate::tags::add_tag(db, hash, t).unwrap();
546 }
547 }
548
549 fn seed_library(db: &Database) {
550 insert(db, "k1", 0.0, &["instrument.drum.kick"]);
551 insert(db, "k2", 0.1, &["instrument.drum.kick"]);
552 insert(db, "s1", 9.0, &["instrument.drum.snare"]);
553 super::super::exemplar::set_policy(db, "instrument.drum.kick", 0.4, 0.8).unwrap();
554 rules::create_rule(
555 db,
556 NewRule {
557 name: "kicks folder".to_string(),
558 enabled: true,
559 priority: None,
560 match_mode: crate::rules::MatchMode::All,
561 conditions: vec![],
562 actions: vec![crate::rules::RuleAction::AddTag(
563 "instrument.drum.kick".to_string(),
564 )],
565 },
566 )
567 .unwrap();
568 }
569
570 #[test]
571 fn export_carries_exemplars_rules_policy_no_audio() {
572 let db = Database::open_in_memory().unwrap();
573 seed_library(&db);
574 let afcl = build_export(&db, &ExportOptions::default()).unwrap();
575 assert_eq!(afcl.manifest.feat_version, FEATURE_VERSION);
576 assert_eq!(afcl.exemplars.len(), 3);
577 assert_eq!(afcl.rules.len(), 1);
578 assert!(afcl.policy.iter().any(|p| p.tag == "instrument.drum.kick"));
579 // Vectors travel, hashes do not (AfclExemplar has no hash field).
580 assert!(
581 afcl.exemplars
582 .iter()
583 .all(|e| e.vector.len() == NUM_FEATURES)
584 );
585 }
586
587 #[test]
588 fn export_selects_components() {
589 let db = Database::open_in_memory().unwrap();
590 seed_library(&db);
591 let opts = ExportOptions {
592 include_exemplars: false,
593 include_rules: true,
594 include_policy: false,
595 ..ExportOptions::default()
596 };
597 let afcl = build_export(&db, &opts).unwrap();
598 assert!(afcl.exemplars.is_empty());
599 assert_eq!(afcl.rules.len(), 1);
600 assert!(afcl.policy.is_empty());
601 }
602
603 #[test]
604 fn round_trip_import_creates_layer() {
605 let src = Database::open_in_memory().unwrap();
606 seed_library(&src);
607 let json = export_to_string(&src, &ExportOptions::default()).unwrap();
608
609 let dst = Database::open_in_memory().unwrap();
610 let summary = import_from_string(&dst, &json, Some("friend.afcl")).unwrap();
611 assert_eq!(summary.exemplars, 3);
612 assert_eq!(summary.rules, 1);
613 assert_eq!(summary.policies, 1);
614
615 let layers = list_layers(&dst).unwrap();
616 assert_eq!(layers.len(), 1);
617 assert_eq!(layers[0].exemplar_count, 3);
618 assert_eq!(layers[0].rule_count, 1);
619 assert!((layers[0].weight - DEFAULT_IMPORT_WEIGHT).abs() < 1e-9);
620
621 // Imported rules arrive disabled.
622 let imported_ids = imported_rule_ids(&dst).unwrap();
623 let rule = rules::list_rules(&dst).unwrap();
624 assert!(
625 rule.iter()
626 .any(|r| imported_ids.contains(&r.id) && !r.enabled)
627 );
628
629 // Imported exemplars are available to the k-NN index.
630 let imp = enabled_imported_exemplars(&dst).unwrap();
631 assert_eq!(imp.len(), 3);
632 assert!(
633 imp.iter()
634 .all(|e| (e.weight - DEFAULT_IMPORT_WEIGHT).abs() < 1e-9)
635 );
636 }
637
638 #[test]
639 fn import_rejects_non_finite_exemplar_vectors() {
640 let src = Database::open_in_memory().unwrap();
641 seed_library(&src);
642 let mut afcl = build_export(&src, &ExportOptions::default()).unwrap();
643 assert_eq!(afcl.exemplars.len(), 3);
644 // Poison one exemplar's vector. A crafted `.afcl` with an overflowing
645 // exponent (`1e999`) parses to inf; it must be skipped at the trust
646 // boundary rather than stored and later poisoning the k-NN index.
647 afcl.exemplars[0].vector = vec![f64::INFINITY; NUM_FEATURES];
648
649 let dst = Database::open_in_memory().unwrap();
650 let summary = import(&dst, &afcl, Some("crafted.afcl")).unwrap();
651 assert_eq!(summary.exemplars, 2, "the non-finite exemplar is rejected");
652
653 let imp = enabled_imported_exemplars(&dst).unwrap();
654 assert_eq!(imp.len(), 2);
655 assert!(imp.iter().all(|e| e.vector.iter().all(|x| x.is_finite())));
656 }
657
658 #[test]
659 fn import_does_not_clobber_existing_policy() {
660 let src = Database::open_in_memory().unwrap();
661 seed_library(&src); // kick policy 0.4/0.8
662 let json = export_to_string(&src, &ExportOptions::default()).unwrap();
663
664 let dst = Database::open_in_memory().unwrap();
665 // The user already set their own kick policy.
666 super::super::exemplar::set_policy(&dst, "instrument.drum.kick", 0.6, 0.95).unwrap();
667 import_from_string(&dst, &json, None).unwrap();
668 let p = super::super::exemplar::get_policy(&dst, "instrument.drum.kick").unwrap();
669 assert!(
670 (p.review_threshold - 0.6).abs() < 1e-9,
671 "user policy preserved"
672 );
673 assert!((p.auto_threshold - 0.95).abs() < 1e-9);
674 }
675
676 #[test]
677 fn feat_version_mismatch_is_rejected() {
678 let db = Database::open_in_memory().unwrap();
679 let mut afcl = build_export(&db, &ExportOptions::default()).unwrap();
680 afcl.manifest.feat_version = FEATURE_VERSION + 1;
681 let json = serde_json::to_string(&afcl).unwrap();
682 assert!(import_from_string(&db, &json, None).is_err());
683 }
684
685 #[test]
686 fn remove_layer_undoes_everything() {
687 let src = Database::open_in_memory().unwrap();
688 seed_library(&src);
689 let json = export_to_string(&src, &ExportOptions::default()).unwrap();
690
691 let dst = Database::open_in_memory().unwrap();
692 let summary = import_from_string(&dst, &json, None).unwrap();
693 let rules_before = rules::list_rules(&dst).unwrap().len();
694
695 remove_layer(&dst, &summary.layer_id).unwrap();
696 assert!(list_layers(&dst).unwrap().is_empty());
697 assert!(enabled_imported_exemplars(&dst).unwrap().is_empty());
698 // The layer's imported rule is gone too.
699 assert_eq!(rules::list_rules(&dst).unwrap().len(), rules_before - 1);
700 }
701
702 #[test]
703 fn disabled_layer_contributes_no_exemplars() {
704 let src = Database::open_in_memory().unwrap();
705 seed_library(&src);
706 let json = export_to_string(&src, &ExportOptions::default()).unwrap();
707 let dst = Database::open_in_memory().unwrap();
708 let summary = import_from_string(&dst, &json, None).unwrap();
709 set_layer_enabled(&dst, &summary.layer_id, false).unwrap();
710 assert!(enabled_imported_exemplars(&dst).unwrap().is_empty());
711 set_layer_enabled(&dst, &summary.layer_id, true).unwrap();
712 assert_eq!(enabled_imported_exemplars(&dst).unwrap().len(), 3);
713 }
714 }
715