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}