Skip to main content

pedant_graph/cache/
analysis.rs

1//! Bounded reuse of the indexes and the products one immutable graph produces.
2//!
3//! The retained state belongs to the graph rather than to the cache that built
4//! it: a handle a caller kept answers from it after the top-level cache is
5//! cleared or dropped. Every entry is admitted work — an index set one
6//! selection produced, or one derived answer over it — so evicting one costs a
7//! recomputation and never turns an answer into a failure.
8//!
9//! Every ceiling that still applies is proved before a retained value is
10//! returned. A product computed under a generous budget is not evidence that a
11//! stricter budget admits it, so the node, selected-edge, depth, and
12//! betweenness-work checks run again on the way in.
13
14use std::convert::Infallible;
15use std::sync::{Arc, Mutex, MutexGuard};
16
17use crate::analysis::index::{AnalysisContext, SharedAnalysis};
18use crate::analysis::{
19    BetweennessCentrality, CondensationGraph, DeclaredModulePartition, DegreeCentrality,
20    GraphAnalysisError, GraphAnalysisLimits, GraphComponents, GraphDirection, GraphDivergence,
21    GraphEdgeSelection, GraphNeighbor, GraphPath, GraphSubgraph, LayoutAssistMetadata, betweenness,
22    components, degree, divergence, layout, traversal,
23};
24use crate::graph::CodeGraph;
25use crate::id::GraphNodeId;
26
27use super::graph::CachedGraphState;
28use super::limits::GraphCacheLimits;
29use super::product::{Product, ProductClaim, ProductOperation, ProductStore};
30use super::state::{CacheCategory, CacheCounters, CacheEvent};
31use super::storage::BoundedStore;
32
33/// The two bounded categories one cached graph retains.
34struct Derived {
35    indexes: BoundedStore<GraphEdgeSelection, SharedAnalysis>,
36    products: ProductStore,
37}
38
39impl Derived {
40    /// Two empty stores, bounded by the per-graph ceilings a host chose.
41    fn new(limits: GraphCacheLimits) -> Self {
42        Self {
43            indexes: BoundedStore::new(limits.max_selected_indexes_per_graph()),
44            products: ProductStore::new(limits.max_derived_products_per_graph()),
45        }
46    }
47
48    /// Drop every retained index set and every retained product.
49    ///
50    /// The cache owns strong references, never the only ones: an analysis or a
51    /// product a caller still holds stays valid and immutable afterwards.
52    fn clear(&mut self) {
53        self.indexes.clear();
54        self.products.clear();
55    }
56}
57
58/// The synchronized derived state one cached graph owns.
59///
60/// Standard-library synchronization, because cloned handles are queried from
61/// several threads. The counts live outside the lock, so reading what the cache
62/// did never waits on a computation.
63pub(crate) struct DerivedState {
64    retained: Mutex<Derived>,
65    counters: Arc<CacheCounters>,
66}
67
68impl DerivedState {
69    /// Empty derived state for one graph, sharing its cache's counters.
70    pub(crate) fn new(limits: GraphCacheLimits, counters: Arc<CacheCounters>) -> Self {
71        Self {
72            retained: Mutex::new(Derived::new(limits)),
73            counters,
74        }
75    }
76
77    /// Drop every retained index set and product.
78    pub(crate) fn clear(&self) {
79        self.locked().clear();
80    }
81
82    /// The shared indexes and partition one selection produces, reused when
83    /// this graph already holds them.
84    ///
85    /// The counts arrive already proved against the caller's current ceilings,
86    /// so an index set admitted under a looser budget cannot answer a stricter
87    /// one, and a miss indexes exactly what those ceilings admitted.
88    pub(crate) fn indexed(
89        &self,
90        graph: &CodeGraph,
91        selection: GraphEdgeSelection,
92        admitted: (u32, u32),
93    ) -> Result<SharedAnalysis, GraphAnalysisError> {
94        let mut retained = self.locked();
95        match retained.indexes.get(&selection) {
96            Some(held) => {
97                self.count(CacheCategory::SelectedIndex, CacheEvent::Hit, 1);
98                Ok(held)
99            }
100            None => {
101                self.count(CacheCategory::SelectedIndex, CacheEvent::Miss, 1);
102                let built = SharedAnalysis::from_admitted(graph, selection, admitted)?;
103                let evicted = retained.indexes.retain(selection, built.clone());
104                self.count(CacheCategory::SelectedIndex, CacheEvent::Eviction, evicted);
105                Ok(built)
106            }
107        }
108    }
109
110    /// One derived answer, computed only when this graph holds none for the
111    /// exact claim.
112    ///
113    /// The one hit, miss, retention, and eviction owner for every published
114    /// operation: the claim selects the entry, the shape states how it is read
115    /// and held, and `derive` runs the algorithm that owns the answer. The claim
116    /// carries the operation, so the entry the key selects already has the shape
117    /// this call expects. Derivation happens under the same guard that missed,
118    /// so two threads asking one question converge on one allocation rather
119    /// than racing to retain two.
120    ///
121    /// The refusal type is the derivation's own, so an operation that cannot
122    /// refuse states that in its type instead of carrying an error arm nothing
123    /// reaches.
124    pub(crate) fn product<T: Product, E>(
125        &self,
126        claim: ProductClaim,
127        derive: impl FnOnce() -> Result<T, E>,
128    ) -> Result<T, E> {
129        let mut retained = self.locked();
130        let found = retained.products.get(&claim).and_then(T::read);
131        match found {
132            Some(answer) => {
133                self.count(CacheCategory::DerivedProduct, CacheEvent::Hit, 1);
134                Ok(answer)
135            }
136            None => {
137                self.count(CacheCategory::DerivedProduct, CacheEvent::Miss, 1);
138                let answer = derive()?;
139                let evicted = retained.products.retain(claim, answer.clone().state());
140                self.count(CacheCategory::DerivedProduct, CacheEvent::Eviction, evicted);
141                Ok(answer)
142            }
143        }
144    }
145
146    /// The counters every classification this graph makes is recorded against.
147    ///
148    /// One set of counters per cache, shared with the graphs it produced, so a
149    /// handle reads what the cache did rather than a second tally kept beside
150    /// it.
151    pub(crate) fn counters(&self) -> &CacheCounters {
152        &self.counters
153    }
154
155    /// Count one classification against this graph's cache.
156    fn count(&self, category: CacheCategory, event: CacheEvent, amount: u64) {
157        self.counters.record(category, event, amount);
158    }
159
160    /// The one acquisition of this graph's derived store.
161    ///
162    /// A guard poisoned by a panic says nothing about the graph, which is
163    /// immutable, and everything about state a partial computation may have
164    /// left behind. Ownership is recovered and every retained entry is dropped
165    /// before the store is read, so the next operation recomputes from the
166    /// graph and answers exactly as a cache with nothing retained would. No
167    /// arm here aborts, and no cache-specific refusal is added to the analysis
168    /// error vocabulary.
169    ///
170    /// The flag is reset in the same arm, so exactly one operation pays the
171    /// discard. Recovering ownership alone leaves the mutex poisoned for the
172    /// life of the graph, which would drop every later retention too and turn
173    /// one panic into a cache that never retains again.
174    fn locked(&self) -> MutexGuard<'_, Derived> {
175        match self.retained.lock() {
176            Ok(held) => held,
177            Err(poisoned) => {
178                let mut discarded = poisoned.into_inner();
179                discarded.clear();
180                self.retained.clear_poison();
181                discarded
182            }
183        }
184    }
185}
186
187/// One shared analysis over one cached graph.
188///
189/// Distinct from the borrowed [`crate::GraphAnalysis`]: this handle owns shared
190/// state, so it can be cloned, sent between threads, and outlive the cache that
191/// produced it. Cloning shares the admitted indexes rather than rebuilding
192/// them.
193#[derive(Clone)]
194pub struct CachedGraphAnalysis {
195    state: Arc<CachedGraphState>,
196    selection: GraphEdgeSelection,
197    limits: GraphAnalysisLimits,
198    shared: SharedAnalysis,
199}
200
201impl CachedGraphAnalysis {
202    /// One handle on indexes this graph already admitted.
203    pub(crate) fn new(
204        state: Arc<CachedGraphState>,
205        selection: GraphEdgeSelection,
206        limits: GraphAnalysisLimits,
207        shared: SharedAnalysis,
208    ) -> Self {
209        Self {
210            state,
211            selection,
212            limits,
213            shared,
214        }
215    }
216
217    /// The graph this analysis reads. Every identity it answers with indexes
218    /// back into this value.
219    pub fn graph(&self) -> &CodeGraph {
220        self.state.graph()
221    }
222
223    /// The selection every index was built from.
224    pub fn selection(&self) -> GraphEdgeSelection {
225        self.selection
226    }
227
228    /// The ceilings this analysis was admitted under.
229    pub fn limits(&self) -> GraphAnalysisLimits {
230        self.limits
231    }
232
233    /// The declared module partition, derived once with the indexes.
234    pub fn declared_partition(&self) -> &DeclaredModulePartition {
235        self.shared.partition()
236    }
237
238    /// Every node connected to `node` within `depth` selected steps in
239    /// `direction`, at its minimum distance, in distance then node order.
240    ///
241    /// # Errors
242    ///
243    /// The same refusals [`crate::GraphAnalysis::neighbors`] returns, in the
244    /// same order. The seed and the depth ceiling are proved before any
245    /// retained neighborhood is examined.
246    pub fn neighbors(
247        &self,
248        node: GraphNodeId,
249        depth: u32,
250        direction: GraphDirection,
251    ) -> Result<Arc<[GraphNeighbor]>, GraphAnalysisError> {
252        self.admits_walk(node, depth)?;
253        self.product(
254            ProductOperation::Neighbors {
255                node,
256                depth,
257                direction,
258            },
259            |context| traversal::neighbors(context, node, depth, direction).map(Arc::from),
260        )
261    }
262
263    /// The shortest route from `source` to `target` over selected outgoing
264    /// edges, or `None` when no route exists.
265    ///
266    /// The proven absence of a route is retained too, so a repeated
267    /// unreachable pair is answered rather than walked again.
268    ///
269    /// # Errors
270    ///
271    /// The same refusals [`crate::GraphAnalysis::path`] returns, in the same
272    /// order. Both endpoints are proved before any retained route is examined,
273    /// so a pair this graph holds no record for costs no store lookup and is
274    /// never counted as a product nothing derived and nothing retained.
275    pub fn path(
276        &self,
277        source: GraphNodeId,
278        target: GraphNodeId,
279    ) -> Result<Option<Arc<GraphPath>>, GraphAnalysisError> {
280        traversal::admitted_route(self.shared.indexes(), (source, target))?;
281        self.product(ProductOperation::Path { source, target }, |context| {
282            traversal::path(context, (source, target)).map(|found| found.map(Arc::new))
283        })
284    }
285
286    /// The selection into this graph induced by the nodes `seed` reaches
287    /// within `depth` selected steps.
288    ///
289    /// # Errors
290    ///
291    /// The same refusals [`crate::GraphAnalysis::subgraph`] returns, in the
292    /// same order. The seed and the depth ceiling are proved before any
293    /// retained selection is examined.
294    pub fn subgraph(
295        &self,
296        seed: GraphNodeId,
297        depth: u32,
298        direction: GraphDirection,
299    ) -> Result<Arc<GraphSubgraph>, GraphAnalysisError> {
300        self.admits_walk(seed, depth)?;
301        self.product(
302            ProductOperation::Subgraph {
303                seed,
304                depth,
305                direction,
306            },
307            |context| traversal::subgraph(context, seed, depth, direction).map(Arc::new),
308        )
309    }
310
311    /// How many selected edges arrive at and leave each node, in raw node-id
312    /// order.
313    pub fn degree_centrality(&self) -> Arc<[DegreeCentrality]> {
314        settled(self.product(ProductOperation::Degree, |context| {
315            Ok(Arc::from(degree::degree(context)))
316        }))
317    }
318
319    /// How much of the selected topology's shortest routing passes through each
320    /// node, in raw node-id order.
321    ///
322    /// # Errors
323    ///
324    /// The same refusals [`crate::GraphAnalysis::betweenness_centrality`]
325    /// returns. The work bound is proved before any retained score is examined.
326    pub fn betweenness_centrality(
327        &self,
328    ) -> Result<Arc<[BetweennessCentrality]>, GraphAnalysisError> {
329        self.admits_centrality_work()?;
330        self.product(ProductOperation::Betweenness, |context| {
331            betweenness::betweenness(context).map(Arc::from)
332        })
333    }
334
335    /// Which nodes reach each other over selected edges.
336    pub fn strongly_connected_components(&self) -> Arc<GraphComponents> {
337        settled(self.product(ProductOperation::Components, |context| {
338            Ok(Arc::new(components::discover(context)))
339        }))
340    }
341
342    /// The same selected topology with every component contracted, ordered, and
343    /// still carrying every raw edge it coalesced.
344    pub fn condensation(&self) -> Arc<CondensationGraph> {
345        settled(self.product(ProductOperation::Condensation, |context| {
346            Ok(Arc::new(components::condense(context)))
347        }))
348    }
349
350    /// What the selected topology says about the module partition this graph
351    /// declares.
352    pub fn divergence(&self) -> Arc<GraphDivergence> {
353        settled(self.product(ProductOperation::Divergence, |context| {
354            Ok(Arc::new(divergence::divergence(context)))
355        }))
356    }
357
358    /// Every admitted node and selected edge, described for a consumer that
359    /// ranks or draws them.
360    ///
361    /// # Errors
362    ///
363    /// The same refusals [`crate::GraphAnalysis::layout_assist`] returns. The
364    /// bounded pass behind it is admitted before any retained metadata is
365    /// examined.
366    pub fn layout_assist(&self) -> Result<Arc<LayoutAssistMetadata>, GraphAnalysisError> {
367        self.admits_centrality_work()?;
368        self.product(ProductOperation::Layout, |context| {
369            layout::layout_assist(context).map(Arc::new)
370        })
371    }
372
373    /// One derived answer, keyed by this handle's selection and the operation's
374    /// complete arguments.
375    fn product<T: Product, E>(
376        &self,
377        operation: ProductOperation,
378        derive: impl FnOnce(&AnalysisContext<'_>) -> Result<T, E>,
379    ) -> Result<T, E> {
380        let claim = ProductClaim::new(self.selection, operation);
381        self.derived().product(claim, || derive(&self.context()))
382    }
383
384    /// The bounded derived state this handle's graph owns.
385    fn derived(&self) -> &DerivedState {
386        self.state.derived()
387    }
388
389    /// The seed and the depth a bounded walk states, proved against this
390    /// handle's ceilings.
391    ///
392    /// Proved through the owner the borrowed walks reach, so a cached handle
393    /// cannot state a refusal the direct analysis would not state, or state the
394    /// same two refusals in the other order.
395    fn admits_walk(&self, node: GraphNodeId, depth: u32) -> Result<(), GraphAnalysisError> {
396        traversal::admitted_walk(self.shared.indexes(), self.limits, node, depth)
397    }
398
399    /// The betweenness work bound, proved against this handle's ceilings.
400    fn admits_centrality_work(&self) -> Result<(), GraphAnalysisError> {
401        betweenness::admitted_work(self.shared.indexes(), self.limits)
402    }
403
404    /// What every algorithm this handle delegates to reads.
405    fn context(&self) -> AnalysisContext<'_> {
406        self.shared.context(self.state.graph(), self.limits)
407    }
408}
409
410/// The answer of an operation whose derivation states no refusal.
411///
412/// Three metrics always answer, and their derivations say so by refusing with
413/// [`Infallible`]. The error arm is discharged by matching a type with no
414/// values rather than by inventing one: nothing is recovered, defaulted, or
415/// aborted, because nothing can reach it.
416fn settled<T>(answer: Result<T, Infallible>) -> T {
417    answer.unwrap_or_else(|never| match never {})
418}