1use serde::{Deserialize, Serialize};
42use surrealdb::Surreal;
43
44use super::confidence::{Provenance, ProvenanceWeights};
45use super::edge_view::{self, EdgeView, NameCache};
46use super::error::GraphError;
47use super::store::Db;
48use super::types::Relationship;
49
50const MAX_CANDIDATES: usize = 5;
52
53const CANDIDATE_FLOOR: f64 = 0.34;
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum CorrectTarget {
60 Entity { name: String },
62 Edge {
64 from: String,
65 rel_type: String,
66 to: String,
67 },
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum Correction {
74 Wrong {
76 #[serde(default)]
79 all_edges: bool,
80 },
81 Forget {
83 #[serde(default)]
84 confirmed: bool,
85 },
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct EntityMatch {
91 pub id: String,
92 pub name: String,
93 pub entity_type: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct EdgeCorrection {
99 pub edge: EdgeView,
101 pub confidence_before: f64,
103 pub evidence_before: f64,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct Removal {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub entity: Option<EntityMatch>,
113 pub edges: Vec<EdgeView>,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(tag = "outcome", rename_all = "snake_case")]
124pub enum CorrectionReport {
125 UnknownEntity {
127 query: String,
128 candidates: Vec<EntityMatch>,
129 },
130 NoSuchEdge {
133 from: String,
134 to: String,
135 rel_type: String,
136 existing: Vec<EdgeView>,
137 },
138 Ambiguous {
141 entity: String,
142 edges: Vec<EdgeView>,
143 },
144 NothingToCorrect { entity: String },
147 Contradicted { edges: Vec<EdgeCorrection> },
149 Planned { removal: Removal },
151 Removed { removal: Removal },
153}
154
155impl CorrectionReport {
156 #[must_use]
158 pub fn applied(&self) -> bool {
159 matches!(
160 self,
161 Self::Contradicted { .. } | Self::Removed { .. } | Self::Planned { .. }
162 )
163 }
164}
165
166pub async fn correct(
168 db: &Surreal<Db>,
169 weights: &ProvenanceWeights,
170 target: &CorrectTarget,
171 correction: Correction,
172) -> Result<CorrectionReport, GraphError> {
173 match target {
174 CorrectTarget::Entity { name } => correct_entity(db, weights, name, correction).await,
175 CorrectTarget::Edge { from, rel_type, to } => {
176 correct_edge(db, weights, from, rel_type, to, correction).await
177 }
178 }
179}
180
181async fn correct_entity(
184 db: &Surreal<Db>,
185 weights: &ProvenanceWeights,
186 name: &str,
187 correction: Correction,
188) -> Result<CorrectionReport, GraphError> {
189 let entity = match resolve(db, name).await? {
190 Resolution::Found(entity) => entity,
191 Resolution::Unresolved(report) => return Ok(report),
192 };
193
194 match correction {
195 Correction::Wrong { all_edges } => contradict_entity(db, weights, &entity, all_edges).await,
196 Correction::Forget { confirmed } => forget_entity(db, &entity, confirmed).await,
197 }
198}
199
200async fn contradict_entity(
206 db: &Surreal<Db>,
207 weights: &ProvenanceWeights,
208 entity: &EntityMatch,
209 all_edges: bool,
210) -> Result<CorrectionReport, GraphError> {
211 let edges = edge_view::live_edges_of(db, &entity.id).await?;
212
213 if edges.is_empty() {
214 return Ok(CorrectionReport::NothingToCorrect {
215 entity: entity.name.clone(),
216 });
217 }
218 if edges.len() > 1 && !all_edges {
219 let mut cache = NameCache::new();
220 return Ok(CorrectionReport::Ambiguous {
221 entity: entity.name.clone(),
222 edges: edge_view::views(db, &mut cache, &edges).await?,
223 });
224 }
225
226 contradict(db, weights, &edges).await
227}
228
229async fn forget_entity(
230 db: &Surreal<Db>,
231 entity: &EntityMatch,
232 confirmed: bool,
233) -> Result<CorrectionReport, GraphError> {
234 let edges = edge_view::all_edges_of(db, &entity.id).await?;
235 let mut cache = NameCache::new();
236 let removal = Removal {
237 entity: Some(entity.clone()),
238 edges: edge_view::views(db, &mut cache, &edges).await?,
239 };
240
241 if !confirmed {
242 return Ok(CorrectionReport::Planned { removal });
243 }
244 super::crud::delete_entity(db, &entity.id).await?;
245 Ok(CorrectionReport::Removed { removal })
246}
247
248async fn correct_edge(
251 db: &Surreal<Db>,
252 weights: &ProvenanceWeights,
253 from: &str,
254 rel_type: &str,
255 to: &str,
256 correction: Correction,
257) -> Result<CorrectionReport, GraphError> {
258 let source = match resolve(db, from).await? {
259 Resolution::Found(entity) => entity,
260 Resolution::Unresolved(report) => return Ok(report),
261 };
262 let target = match resolve(db, to).await? {
263 Resolution::Found(entity) => entity,
264 Resolution::Unresolved(report) => return Ok(report),
265 };
266
267 let between = edge_view::live_edges_between(db, &source.id, &target.id).await?;
268 let matched: Vec<Relationship> = between
269 .iter()
270 .filter(|edge| edge.rel_type.eq_ignore_ascii_case(rel_type))
271 .cloned()
272 .collect();
273
274 if matched.is_empty() {
275 let mut cache = NameCache::new();
276 return Ok(CorrectionReport::NoSuchEdge {
277 from: source.name,
278 to: target.name,
279 rel_type: rel_type.to_string(),
280 existing: edge_view::views(db, &mut cache, &between).await?,
281 });
282 }
283
284 match correction {
285 Correction::Wrong { .. } => contradict(db, weights, &matched).await,
286 Correction::Forget { confirmed } => forget_edges(db, &matched, confirmed).await,
287 }
288}
289
290async fn forget_edges(
291 db: &Surreal<Db>,
292 edges: &[Relationship],
293 confirmed: bool,
294) -> Result<CorrectionReport, GraphError> {
295 let mut cache = NameCache::new();
296 let removal = Removal {
297 entity: None,
298 edges: edge_view::views(db, &mut cache, edges).await?,
299 };
300
301 if !confirmed {
302 return Ok(CorrectionReport::Planned { removal });
303 }
304 for edge in edges {
305 super::crud::delete_relationship(db, &edge.id_string()).await?;
306 }
307 Ok(CorrectionReport::Removed { removal })
308}
309
310async fn contradict(
314 db: &Surreal<Db>,
315 weights: &ProvenanceWeights,
316 edges: &[Relationship],
317) -> Result<CorrectionReport, GraphError> {
318 let mut cache = NameCache::new();
319 let mut corrections = Vec::with_capacity(edges.len());
320
321 for edge in edges {
322 let mut evidence = edge.edge_evidence();
323 evidence.contradict(Provenance::User, weights);
324 super::crud::contradict_relationship(db, &edge.id_string(), evidence).await?;
325
326 let counts = evidence.evidence();
327 let from = cache
328 .name_of(db, &edge_view::record_id(&edge.from_id))
329 .await?;
330 let to = cache
331 .name_of(db, &edge_view::record_id(&edge.to_id))
332 .await?;
333 let mut view = EdgeView::of(edge, from, to);
334 let confidence_before = view.confidence;
335 let evidence_before = view.evidence;
336 view.confidence = counts.mean();
337 view.evidence = counts.concentration();
338
339 corrections.push(EdgeCorrection {
340 edge: view,
341 confidence_before,
342 evidence_before,
343 });
344 }
345
346 Ok(CorrectionReport::Contradicted { edges: corrections })
347}
348
349enum Resolution {
354 Found(EntityMatch),
355 Unresolved(CorrectionReport),
356}
357
358async fn resolve(db: &Surreal<Db>, name: &str) -> Result<Resolution, GraphError> {
364 let index = entity_index(db).await?;
365 let query = name.trim();
366
367 if let Some(entity) = index.iter().find(|entity| entity.name == query) {
368 return Ok(Resolution::Found(entity.clone()));
369 }
370
371 let folded: Vec<&EntityMatch> = index
372 .iter()
373 .filter(|entity| entity.name.eq_ignore_ascii_case(query))
374 .collect();
375 if folded.len() == 1 {
376 return Ok(Resolution::Found(folded[0].clone()));
377 }
378 if folded.len() > 1 {
379 return Ok(Resolution::Unresolved(CorrectionReport::UnknownEntity {
380 query: query.to_string(),
381 candidates: folded.into_iter().cloned().collect(),
382 }));
383 }
384
385 Ok(Resolution::Unresolved(CorrectionReport::UnknownEntity {
386 query: query.to_string(),
387 candidates: nearest(query, &index),
388 }))
389}
390
391async fn entity_index(db: &Surreal<Db>) -> Result<Vec<EntityMatch>, GraphError> {
393 #[derive(serde::Deserialize)]
394 struct Row {
395 id: serde_json::Value,
396 name: String,
397 entity_type: String,
398 }
399
400 let mut response = db
401 .query("SELECT id, name, entity_type FROM entity ORDER BY name")
402 .await?;
403 let rows: Vec<Row> = super::deserialize_take(&mut response, 0)?;
404 Ok(rows
405 .into_iter()
406 .map(|row| EntityMatch {
407 id: edge_view::record_id(&row.id),
408 name: row.name,
409 entity_type: row.entity_type,
410 })
411 .collect())
412}
413
414fn nearest(query: &str, index: &[EntityMatch]) -> Vec<EntityMatch> {
416 let query = query.to_lowercase();
417 let mut scored: Vec<(f64, &EntityMatch)> = index
418 .iter()
419 .map(|entity| (similarity(&query, &entity.name.to_lowercase()), entity))
420 .filter(|(score, _)| *score >= CANDIDATE_FLOOR)
421 .collect();
422
423 scored.sort_by(|left, right| {
424 right
425 .0
426 .partial_cmp(&left.0)
427 .unwrap_or(std::cmp::Ordering::Equal)
428 .then_with(|| left.1.name.cmp(&right.1.name))
429 });
430 scored
431 .into_iter()
432 .take(MAX_CANDIDATES)
433 .map(|(_, entity)| entity.clone())
434 .collect()
435}
436
437fn similarity(query: &str, name: &str) -> f64 {
443 if query.is_empty() || name.is_empty() {
444 return 0.0;
445 }
446 if query == name {
447 return 1.0;
448 }
449
450 let dice = dice_coefficient(query, name);
451 if name.contains(query) || query.contains(name) {
452 return dice.max(0.9);
453 }
454 dice
455}
456
457fn dice_coefficient(left: &str, right: &str) -> f64 {
458 let left: Vec<[char; 2]> = bigrams(left);
459 let right: Vec<[char; 2]> = bigrams(right);
460 if left.is_empty() || right.is_empty() {
461 return 0.0;
462 }
463
464 let mut remaining = right.clone();
465 let mut shared = 0usize;
466 for bigram in &left {
467 if let Some(position) = remaining.iter().position(|other| other == bigram) {
468 remaining.swap_remove(position);
469 shared += 1;
470 }
471 }
472 (2.0 * shared as f64) / (left.len() + right.len()) as f64
473}
474
475fn bigrams(text: &str) -> Vec<[char; 2]> {
476 let chars: Vec<char> = text.chars().collect();
477 chars.windows(2).map(|pair| [pair[0], pair[1]]).collect()
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn index() -> Vec<EntityMatch> {
485 ["recall-echo", "pulse-null", "Rust", "rust", "Cargo"]
486 .into_iter()
487 .map(|name| EntityMatch {
488 id: format!("entity:{}", name.to_lowercase()),
489 name: name.to_string(),
490 entity_type: "tool".to_string(),
491 })
492 .collect()
493 }
494
495 #[test]
496 fn a_name_is_most_like_itself() {
497 assert_eq!(similarity("rust", "rust"), 1.0);
498 assert!(similarity("rust", "cargo") < CANDIDATE_FLOOR);
499 }
500
501 #[test]
502 fn a_typo_stays_a_near_miss() {
503 assert!(
504 similarity("recall-eco", "recall-echo") > CANDIDATE_FLOOR,
505 "{}",
506 similarity("recall-eco", "recall-echo")
507 );
508 }
509
510 #[test]
511 fn a_prefix_surfaces_the_whole_name() {
512 assert!(similarity("recall", "recall-echo") >= 0.9);
513 }
514
515 #[test]
516 fn candidates_are_the_closest_names_and_no_others() {
517 let candidates = nearest("recall", &index());
518 assert_eq!(
519 candidates.first().map(|entity| entity.name.as_str()),
520 Some("recall-echo")
521 );
522 assert!(
523 candidates.len() <= MAX_CANDIDATES,
524 "a wall of names is not a choice"
525 );
526 assert!(
527 !candidates.iter().any(|entity| entity.name == "Cargo"),
528 "unrelated names are not near-misses: {candidates:?}"
529 );
530 }
531
532 #[test]
533 fn nothing_is_close_to_nonsense() {
534 assert!(nearest("zzzzqqqq", &index()).is_empty());
535 }
536
537 #[test]
538 fn a_report_that_changed_nothing_says_so() {
539 assert!(!CorrectionReport::NothingToCorrect {
540 entity: "Rust".into()
541 }
542 .applied());
543 assert!(!CorrectionReport::UnknownEntity {
544 query: "Rust".into(),
545 candidates: Vec::new(),
546 }
547 .applied());
548 assert!(CorrectionReport::Contradicted { edges: Vec::new() }.applied());
549 }
550
551 #[test]
552 fn corrections_survive_the_daemon_wire_format() {
553 let target = CorrectTarget::Edge {
554 from: "D".into(),
555 rel_type: "USES".into(),
556 to: "Vim".into(),
557 };
558 let line = serde_json::to_string(&target).unwrap();
559 assert_eq!(
560 serde_json::from_str::<CorrectTarget>(&line).unwrap(),
561 target
562 );
563
564 assert_eq!(
566 serde_json::from_str::<Correction>(r#"{"kind":"wrong"}"#).unwrap(),
567 Correction::Wrong { all_edges: false }
568 );
569 assert_eq!(
570 serde_json::from_str::<Correction>(r#"{"kind":"forget"}"#).unwrap(),
571 Correction::Forget { confirmed: false }
572 );
573 }
574}