diff --git a/lci-maps/src/edge.rs b/lci-maps/src/edge.rs index 79c5298f..b08da3bf 100644 --- a/lci-maps/src/edge.rs +++ b/lci-maps/src/edge.rs @@ -7,10 +7,15 @@ //! rule 1): a consumer that wants a different order computes it at walk time. use crate::{ - Cost, EdgeKind, MapDescriptor, MapId, MapResponse, MapStatus, NodeKind, Observed, SourceLabel, + Cost, EdgeKind, Evidence, MapDescriptor, MapHit, MapId, MapRequest, MapResponse, MapStatus, + NodeKind, Observed, SnapshotView, SourceLabel, }; use lci_core::node::NodeId; -use std::{cmp::Reverse, time::Duration}; +use std::{ + cmp::Reverse, + collections::HashSet, + time::{Duration, Instant}, +}; /// How far a witness can be trusted, from the strongest to the weakest /// source. Consumers read `Compiler` and `Exact` by default and report @@ -232,6 +237,66 @@ pub fn cap_edges(edges: &mut Vec, cap: usize) { edges.truncate(cap); } +/// The seeded answer of a map whose edges carry a SCIP site: every seed's +/// capped edges (`edges_of` gives them and how many there were before the +/// cap), merged, sorted, one hit per destination, cut to the request's limit. +/// Hits score `conf` as a unit interval and cite the site through `detail`. +/// `cap` is only reported, not applied here (`edges_of` applies it): a cap +/// that dropped edges makes the status `Partial`, its note naming `noun` and +/// `cap`. +pub fn scip_ranked_hits( + descriptor: &MapDescriptor, + request: &MapRequest<'_>, + view: &SnapshotView<'_>, + cap: usize, + noun: &str, + edges_of: impl Fn(&NodeId) -> (Expanded, usize), + detail: impl Fn(Site) -> String, +) -> MapResponse { + let started = Instant::now(); + let mut edges = Vec::new(); + let mut dropped = 0; + for (seed, _) in request.seeds { + let (found, total) = edges_of(seed); + dropped += total - found.edges.len(); + edges.extend(found.edges); + } + cap_edges(&mut edges, usize::MAX); + let mut seen = HashSet::new(); + edges.retain(|edge| seen.insert(edge.dst.clone())); + edges.truncate(request.limit); + let mut response = MapResponse::empty(descriptor, view, MapStatus::Ok); + if dropped > 0 { + response.status = MapStatus::Partial { + note: Some(format!( + "{dropped} more {noun} than the cap of {cap} were not offered" + )), + warning: None, + }; + } + response.cost = Cost { + elapsed_ms: started.elapsed().as_secs_f64() * 1000.0, + nodes_touched: edges.len() as u64, + ..Cost::default() + }; + response.hits = edges + .into_iter() + .zip(1..) + .map(|(edge, rank)| MapHit { + node: edge.dst, + rank, + score: Some(f32::from(edge.conf) / f32::from(CONF_MAX)), + evidence: vec![Evidence { + via: Some(edge.src), + edge: edge.kind, + label: SourceLabel::Scip, + detail: edge.site.map(&detail), + }], + }) + .collect(); + response +} + /// The edges a seeded query's answer stands for, as [`crate::Map::expand`]'s /// default yields them: one per hit that is not the seed itself, witness from /// the hit's evidence label (the descriptor's first label when the hit has diff --git a/lci-maps/src/scip_impact.rs b/lci-maps/src/scip_impact.rs index 12a0a841..4be16605 100644 --- a/lci-maps/src/scip_impact.rs +++ b/lci-maps/src/scip_impact.rs @@ -45,9 +45,12 @@ //! which reads the same file. use crate::{ - Budget, Contribution, Cost, EXPAND_LIMIT, EdgeKind, Evidence, FreshnessModel, Map, - MapDescriptor, MapHit, MapId, MapRequest, MapResponse, MapStatus, SnapshotView, SourceLabel, - edge::{CONF_MAX, Edge, Expanded, MapCard, QuestionType, Site, WitnessClass, cap_edges}, + Budget, Contribution, Cost, EXPAND_LIMIT, EdgeKind, FreshnessModel, Map, MapDescriptor, MapId, + MapRequest, MapResponse, SnapshotView, SourceLabel, + edge::{ + CONF_MAX, Edge, Expanded, MapCard, QuestionType, Site, WitnessClass, cap_edges, + scip_ranked_hits, + }, impact::ImpactIndex, live_grep::CostLog, }; @@ -319,51 +322,15 @@ impl Map for ImpactMap { } async fn query(&self, request: &MapRequest<'_>, view: &SnapshotView<'_>) -> MapResponse { - let started = Instant::now(); - let mut edges = Vec::new(); - let mut dropped = 0; - for (seed, _) in request.seeds { - let (found, total) = self.edges_of(seed); - dropped += total - found.edges.len(); - edges.extend(found.edges); - } - cap_edges(&mut edges, usize::MAX); - let mut seen = HashSet::new(); - edges.retain(|edge| seen.insert(edge.dst.clone())); - edges.truncate(request.limit); - let mut response = MapResponse::empty(&self.descriptor, view, MapStatus::Ok); - if dropped > 0 { - response.status = MapStatus::Partial { - note: Some(format!( - "{dropped} more users than the cap of {} were not offered", - self.options.cap - )), - warning: None, - }; - } - response.cost = Cost { - elapsed_ms: started.elapsed().as_secs_f64() * 1000.0, - nodes_touched: edges.len() as u64, - ..Cost::default() - }; - response.hits = edges - .into_iter() - .zip(1..) - .map(|(edge, rank)| MapHit { - node: edge.dst, - rank, - score: Some(f32::from(edge.conf) / f32::from(CONF_MAX)), - evidence: vec![Evidence { - via: Some(edge.src), - edge: edge.kind, - label: SourceLabel::Scip, - detail: edge - .site - .map(|s| format!("references the seed at {}:{}", s.path, s.line + 1)), - }], - }) - .collect(); - response + scip_ranked_hits( + &self.descriptor, + request, + view, + self.options.cap, + "users", + |seed| self.edges_of(seed), + |s| format!("references the seed at {}:{}", s.path, s.line + 1), + ) } async fn expand(&self, node: &NodeId, _budget: Budget, _view: &SnapshotView<'_>) -> Expanded { diff --git a/lci-maps/src/scip_impact/tests.rs b/lci-maps/src/scip_impact/tests.rs index 50cadf68..be3e2868 100644 --- a/lci-maps/src/scip_impact/tests.rs +++ b/lci-maps/src/scip_impact/tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::{ - Registry, SnapshotRef, + MapStatus, Registry, SnapshotRef, audit::audit_edge_sources, kit_c3::check_expand, scip_graph::{Range, RawDocument, RawOccurrence}, @@ -543,3 +543,94 @@ async fn graph_scip_and_scip_impact_over_one_index_corroborate_once() { ); assert_eq!(reached.n_maps(), 1, "one index, one piece of evidence"); } + +// ---- pins: the seeded `query` output ------------------------------------------------- + +async fn ask(map: &ImpactMap, seeds: &[(NodeId, f32)], limit: usize) -> MapResponse { + let params = serde_json::Value::Null; + map.query( + &MapRequest { + text: None, + seeds, + limit, + budget: budget(), + params: ¶ms, + as_of: None, + }, + &view(), + ) + .await +} + +#[tokio::test] +async fn query_pins_the_partial_note_and_the_first_hit() { + let (ix, target) = crowd(100); + let map = ImpactMap::new(ix, &|_| None, ImpactOptions { cap: 5 }); + let node = map.node_of(&target).unwrap().clone(); + let response = ask(&map, &[(node.clone(), 1.0)], 100).await; + assert_eq!( + response.status, + MapStatus::Partial { + note: Some("95 more users than the cap of 5 were not offered".to_string()), + warning: None, + } + ); + assert_eq!(response.hits.len(), 5); + let hit = &response.hits[0]; + assert_eq!(hit.rank, 1); + assert_eq!(hit.score, Some(1.0)); + assert_eq!(response.hits[4].rank, 5); + assert_eq!(hit.evidence.len(), 1); + let evidence = &hit.evidence[0]; + assert_eq!(evidence.via, Some(node)); + assert_eq!(evidence.edge, EdgeKind::CallsOrRefs); + assert_eq!(evidence.label, crate::SourceLabel::Scip); + assert_eq!( + evidence.detail.as_deref(), + Some("references the seed at a.rs:2") + ); +} + +#[tokio::test] +async fn query_dedupes_a_shared_destination_and_truncates_to_the_limit() { + let map = map(); + let foo = map.node_of(&sym("a/Foo#")).unwrap().clone(); + let run = method("run"); + // Both seeds reach `use_foo` (Foo at a.rs:3 col 15, run at a.rs:3 col 20); + // only Foo reaches the enum E. The seed whose edge wins the dedupe is listed + // second, so only the global sort by site keeps it. + let seeds = [(run, 1.0), (foo.clone(), 1.0)]; + let enum_e = NodeId::Symbol(SymbolKey { + language: "rust".into(), + module_path: rust_module_path("a.rs"), + container: Vec::new(), + name: "E".into(), + kind: SymbolKind::Enum, + disambiguator: 0, + }); + let all = ask(&map, &seeds, 10).await; + assert_eq!(all.status, MapStatus::Ok); + let dsts: Vec<&NodeId> = all.hits.iter().map(|h| &h.node).collect(); + assert_eq!(dsts, vec![&enum_e, &function("use_foo")]); + let use_foo = all + .hits + .iter() + .find(|h| h.node == function("use_foo")) + .expect("use_foo is offered once"); + // The first edge in sort order survives: the one with the smaller site. + assert_eq!(use_foo.evidence[0].via, Some(foo)); + assert_eq!( + use_foo.evidence[0].detail.as_deref(), + Some("references the seed at a.rs:3") + ); + assert_eq!( + all.hits.iter().map(|h| h.rank).collect::>(), + vec![1, 2] + ); + + let one = ask(&map, &seeds, 1).await; + assert_eq!(one.hits.len(), 1); + assert_eq!(one.hits[0].rank, 1); + assert_eq!(one.hits[0].node, enum_e); + assert_eq!(one.status, MapStatus::Ok); +} diff --git a/lci-maps/src/scip_implements.rs b/lci-maps/src/scip_implements.rs index 8f5dc9ad..88124198 100644 --- a/lci-maps/src/scip_implements.rs +++ b/lci-maps/src/scip_implements.rs @@ -87,9 +87,12 @@ //! `graph.scip`. use crate::{ - Budget, Contribution, Cost, EXPAND_LIMIT, EdgeKind, Evidence, FreshnessModel, Map, - MapDescriptor, MapHit, MapId, MapRequest, MapResponse, MapStatus, SnapshotView, SourceLabel, - edge::{CONF_MAX, Edge, Expanded, MapCard, QuestionType, Site, WitnessClass, cap_edges}, + Budget, Contribution, Cost, EXPAND_LIMIT, EdgeKind, FreshnessModel, Map, MapDescriptor, MapId, + MapRequest, MapResponse, SnapshotView, SourceLabel, + edge::{ + CONF_MAX, Edge, Expanded, MapCard, QuestionType, Site, WitnessClass, cap_edges, + scip_ranked_hits, + }, impact::chain_of, live_grep::CostLog, scip_graph::{GraphEdgeKind, ScipGraph, Witness}, @@ -447,51 +450,15 @@ impl Map for ImplementsMap { } async fn query(&self, request: &MapRequest<'_>, view: &SnapshotView<'_>) -> MapResponse { - let started = Instant::now(); - let mut edges = Vec::new(); - let mut dropped = 0; - for (seed, _) in request.seeds { - let (found, total) = self.edges_of(seed); - dropped += total - found.edges.len(); - edges.extend(found.edges); - } - cap_edges(&mut edges, usize::MAX); - let mut seen = HashSet::new(); - edges.retain(|edge| seen.insert(edge.dst.clone())); - edges.truncate(request.limit); - let mut response = MapResponse::empty(&self.descriptor, view, MapStatus::Ok); - if dropped > 0 { - response.status = MapStatus::Partial { - note: Some(format!( - "{dropped} more implementations than the cap of {} were not offered", - self.options.cap - )), - warning: None, - }; - } - response.cost = Cost { - elapsed_ms: started.elapsed().as_secs_f64() * 1000.0, - nodes_touched: edges.len() as u64, - ..Cost::default() - }; - response.hits = edges - .into_iter() - .zip(1..) - .map(|(edge, rank)| MapHit { - node: edge.dst, - rank, - score: Some(f32::from(edge.conf) / f32::from(CONF_MAX)), - evidence: vec![Evidence { - via: Some(edge.src), - edge: edge.kind, - label: SourceLabel::Scip, - detail: edge - .site - .map(|s| format!("implements at {}:{}", s.path, s.line + 1)), - }], - }) - .collect(); - response + scip_ranked_hits( + &self.descriptor, + request, + view, + self.options.cap, + "implementations", + |seed| self.edges_of(seed), + |s| format!("implements at {}:{}", s.path, s.line + 1), + ) } async fn expand(&self, node: &NodeId, _budget: Budget, _view: &SnapshotView<'_>) -> Expanded { diff --git a/lci-maps/src/scip_implements/tests.rs b/lci-maps/src/scip_implements/tests.rs index f1f506fa..676dc038 100644 --- a/lci-maps/src/scip_implements/tests.rs +++ b/lci-maps/src/scip_implements/tests.rs @@ -3,7 +3,7 @@ use super::*; use crate::{ - SnapshotRef, + MapStatus, SnapshotRef, kit_c3::check_expand, scip_graph::{DirSource, Range, RawDocument, RawOccurrence, SourceText}, }; @@ -289,6 +289,77 @@ fn the_card_is_dependent_and_good_for_impact_and_flow() { ); } +// ---- pins: the seeded `query` output ------------------------------------------------- + +async fn ask(map: &ImplementsMap, seeds: &[(NodeId, f32)], limit: usize) -> MapResponse { + let params = serde_json::Value::Null; + map.query( + &MapRequest { + text: None, + seeds, + limit, + budget: budget(), + params: ¶ms, + as_of: None, + }, + &view(), + ) + .await +} + +#[tokio::test] +async fn query_pins_the_partial_note_and_the_first_hit() { + let map = map(1); + let response = ask(&map, &[(tr(), 1.0)], 10).await; + assert_eq!( + response.status, + MapStatus::Partial { + note: Some("1 more implementations than the cap of 1 were not offered".to_string()), + warning: None, + } + ); + assert_eq!(response.hits.len(), 1); + let hit = &response.hits[0]; + assert_eq!(hit.rank, 1); + assert_eq!(hit.score, Some(1.0)); + assert_eq!(hit.node, a()); + assert_eq!(hit.evidence.len(), 1); + let evidence = &hit.evidence[0]; + assert_eq!(evidence.via, Some(tr())); + assert_eq!(evidence.edge, EdgeKind::Implements); + assert_eq!(evidence.label, crate::SourceLabel::Scip); + assert_eq!(evidence.detail.as_deref(), Some("implements at a.rs:6")); +} + +#[tokio::test] +async fn query_dedupes_a_shared_destination_and_truncates_to_the_limit() { + let map = map(64); + // A and B both reach `Tr` (A at a.rs:6, B at a.rs:9): one hit, from A. + // The seed whose edge wins is listed second, so only the global sort by + // site keeps it. + let both = ask(&map, &[(b(), 1.0), (a(), 1.0)], 10).await; + assert_eq!(both.status, MapStatus::Ok); + assert_eq!(both.hits.len(), 1, "{:?}", both.hits); + assert_eq!(both.hits[0].node, tr()); + assert_eq!(both.hits[0].rank, 1); + assert_eq!(both.hits[0].evidence[0].via, Some(a())); + assert_eq!( + both.hits[0].evidence[0].detail.as_deref(), + Some("implements at a.rs:6") + ); + + // With `Tr` as a third seed the edges to A and B join it: three hits, cut to one. + let seeds = [(b(), 1.0), (a(), 1.0), (tr(), 1.0)]; + let all = ask(&map, &seeds, 10).await; + let nodes: Vec<&NodeId> = all.hits.iter().map(|h| &h.node).collect(); + assert_eq!(nodes, vec![&a(), &b(), &tr()]); + let one = ask(&map, &seeds, 1).await; + assert_eq!(one.hits.len(), 1); + assert_eq!(one.hits[0].rank, 1); + assert_eq!(one.hits[0].node, a()); + assert_eq!(one.status, MapStatus::Ok); +} + #[test] fn without_source_the_map_has_no_edges() { let docs = vec![RawDocument { diff --git a/lci-maps/src/tested_by.rs b/lci-maps/src/tested_by.rs index 5293dc04..e3db3e4d 100644 --- a/lci-maps/src/tested_by.rs +++ b/lci-maps/src/tested_by.rs @@ -50,9 +50,11 @@ //! is [`FreshnessModel::PerFile`], like `scip.impact`. use crate::{ - Budget, Contribution, Cost, EXPAND_LIMIT, EdgeKind, Evidence, FreshnessModel, Map, - MapDescriptor, MapHit, MapId, MapRequest, MapResponse, MapStatus, SnapshotView, SourceLabel, - edge::{CONF_MAX, Edge, Expanded, MapCard, QuestionType, WitnessClass, cap_edges}, + Budget, Contribution, Cost, EXPAND_LIMIT, EdgeKind, FreshnessModel, Map, MapDescriptor, MapId, + MapRequest, MapResponse, SnapshotView, SourceLabel, + edge::{ + CONF_MAX, Edge, Expanded, MapCard, QuestionType, WitnessClass, cap_edges, scip_ranked_hits, + }, impact::Chain, live_grep::CostLog, scip_impact::ImpactMap, @@ -61,7 +63,7 @@ use lci_core::{ filter::{SourceRole, classify_source_role}, node::{NodeId, NodeKind}, }; -use std::{collections::HashSet, sync::Arc, time::Instant}; +use std::{sync::Arc, time::Instant}; /// The id of [`TestedByMap`]. pub const TESTED_BY_ID: MapId = "tests.tested_by"; @@ -175,50 +177,15 @@ impl Map for TestedByMap { } async fn query(&self, request: &MapRequest<'_>, view: &SnapshotView<'_>) -> MapResponse { - let started = Instant::now(); - let mut edges = Vec::new(); - let mut dropped = 0; - for (seed, _) in request.seeds { - let (found, total) = self.edges_of(seed); - dropped += total - found.edges.len(); - edges.extend(found.edges); - } - cap_edges(&mut edges, usize::MAX); - let mut seen = HashSet::new(); - edges.retain(|edge| seen.insert(edge.dst.clone())); - edges.truncate(request.limit); - let mut response = MapResponse::empty(&self.descriptor, view, MapStatus::Ok); - if dropped > 0 { - response.status = MapStatus::Partial { - note: Some(format!( - "{dropped} more test units than the cap of {EXPAND_LIMIT} were not offered" - )), - warning: None, - }; - } - response.cost = Cost { - elapsed_ms: started.elapsed().as_secs_f64() * 1000.0, - nodes_touched: edges.len() as u64, - ..Cost::default() - }; - response.hits = edges - .into_iter() - .zip(1..) - .map(|(edge, rank)| MapHit { - node: edge.dst, - rank, - score: Some(f32::from(edge.conf) / f32::from(CONF_MAX)), - evidence: vec![Evidence { - via: Some(edge.src), - edge: edge.kind, - label: SourceLabel::Scip, - detail: edge - .site - .map(|s| format!("tests the seed at {}:{}", s.path, s.line + 1)), - }], - }) - .collect(); - response + scip_ranked_hits( + &self.descriptor, + request, + view, + EXPAND_LIMIT, + "test units", + |seed| self.edges_of(seed), + |s| format!("tests the seed at {}:{}", s.path, s.line + 1), + ) } async fn expand(&self, node: &NodeId, _budget: Budget, _view: &SnapshotView<'_>) -> Expanded { diff --git a/lci-maps/src/tested_by/tests.rs b/lci-maps/src/tested_by/tests.rs index d4b8daeb..8f5accad 100644 --- a/lci-maps/src/tested_by/tests.rs +++ b/lci-maps/src/tested_by/tests.rs @@ -3,7 +3,7 @@ use super::*; use crate::{ - SnapshotRef, + MapStatus, SnapshotRef, edge::sort_edges, impact::ImpactIndex, kit_c3::check_expand, @@ -288,6 +288,119 @@ async fn query_offers_one_hit_per_test_unit_with_scip_evidence() { ); } +// ---- pins: the seeded `query` output ------------------------------------------------- + +async fn ask(map: &TestedByMap, seeds: &[(NodeId, f32)], limit: usize) -> MapResponse { + let params = serde_json::Value::Null; + map.query( + &MapRequest { + text: None, + seeds, + limit, + budget: budget(), + params: ¶ms, + as_of: None, + }, + &view(), + ) + .await +} + +#[tokio::test] +async fn query_pins_the_partial_note_and_the_first_hit() { + let (ix, target) = crowd(EXPAND_LIMIT + 5); + let shared = impact(ix); + let map = TestedByMap::new(shared.clone()); + let node = shared.node_of(&target).unwrap().clone(); + let response = ask(&map, &[(node.clone(), 1.0)], 3).await; + assert_eq!( + response.status, + MapStatus::Partial { + note: Some("5 more test units than the cap of 256 were not offered".to_string()), + warning: None, + } + ); + assert_eq!(response.hits.len(), 3); + let hit = &response.hits[0]; + assert_eq!(hit.rank, 1); + assert_eq!(hit.score, Some(1.0)); + assert_eq!(response.hits[2].rank, 3); + assert_eq!(name_of(&hit.node), "t_000"); + assert_eq!(hit.evidence.len(), 1); + let evidence = &hit.evidence[0]; + assert_eq!(evidence.via, Some(node)); + assert_eq!(evidence.edge, EdgeKind::Tests); + assert_eq!(evidence.label, SourceLabel::Scip); + assert_eq!( + evidence.detail.as_deref(), + Some("tests the seed at tests/crowd.rs:1") + ); +} + +#[tokio::test] +async fn query_dedupes_a_shared_destination_and_truncates_to_the_limit() { + let foo = sym("a/foo()."); + let bar = sym("a/bar()."); + let t_both = sym("a_test/t_both()."); + let t_foo = sym("a_test/t_foo()."); + let docs = vec![ + RawDocument { + path: "src/a.rs".into(), + occurrences: vec![ + occ((0, 3, 0, 6), &foo, 1, Some((0, 0, 0, 12))), + occ((1, 3, 1, 6), &bar, 1, Some((1, 0, 1, 12))), + ], + }, + RawDocument { + path: "tests/a_test.rs".into(), + occurrences: vec![ + occ((0, 3, 0, 9), &t_both, 1, Some((0, 0, 0, 30))), + occ((0, 12, 0, 15), &foo, 0, None), + occ((0, 20, 0, 23), &bar, 0, None), + occ((1, 3, 1, 9), &t_foo, 1, Some((1, 0, 1, 30))), + occ((1, 12, 1, 15), &foo, 0, None), + ], + }, + ]; + let kinds: HashMap = [foo.clone(), bar.clone(), t_both, t_foo] + .into_iter() + .map(|s| (s, Kind::Function)) + .collect(); + let shared = impact(ImpactIndex::build(docs, &kinds, None)); + let map = TestedByMap::new(shared.clone()); + let foo = shared.node_of(&foo).unwrap().clone(); + let bar = shared.node_of(&bar).unwrap().clone(); + // Both seeds reach t_both (foo at tests/a_test.rs:1 col 12, bar at col 20); + // only foo reaches t_foo. The seed whose edge wins the dedupe is listed + // second, so only the global sort by site keeps it. + let seeds = [(bar, 1.0), (foo.clone(), 1.0)]; + let all = ask(&map, &seeds, 10).await; + assert_eq!(all.status, MapStatus::Ok); + let hit_names: Vec<&str> = all.hits.iter().map(|h| name_of(&h.node)).collect(); + assert_eq!(hit_names, ["t_both", "t_foo"]); + assert_eq!( + all.hits.iter().map(|h| h.rank).collect::>(), + vec![1, 2] + ); + let both = all + .hits + .iter() + .find(|h| name_of(&h.node) == "t_both") + .expect("t_both is offered once"); + // The first edge in sort order survives: the one with the smaller site. + assert_eq!(both.evidence[0].via, Some(foo)); + assert_eq!( + both.evidence[0].detail.as_deref(), + Some("tests the seed at tests/a_test.rs:1") + ); + + let one = ask(&map, &seeds, 1).await; + assert_eq!(one.hits.len(), 1); + assert_eq!(one.hits[0].rank, 1); + assert_eq!(name_of(&one.hits[0].node), "t_both"); + assert_eq!(one.status, MapStatus::Ok); +} + #[test] fn the_two_rules_and_their_limits() { let chain = |names: &[&str]| Chain {