Skip to main content

oxgraph_algo/
pagerank.rs

1//! `PageRank` algorithms over `OxGraph` capability views.
2//!
3//! The module provides ordinary directed graph `PageRank` and directed
4//! hypergraph incidence/bipartite `PageRank`. Property layers are not read here;
5//! callers select named layers into topology weight capability views before
6//! invoking weighted variants.
7// kani-skip: PageRank uses unbounded caller-supplied iteration counts and floating-point
8// convergence; deterministic unit tests and Criterion benches cover this slice.
9#![cfg_attr(
10    not(test),
11    expect(
12        clippy::missing_docs_in_private_items,
13        reason = "PageRank helper functions are private implementation details behind documented public API tiers"
14    )
15)]
16
17#[cfg(feature = "alloc")]
18use alloc::{vec, vec::Vec};
19#[cfg(feature = "alloc")]
20use core::marker::PhantomData;
21use core::{
22    error::Error,
23    fmt,
24    ops::{Add, AddAssign, Div, Mul, Sub},
25};
26
27use oxgraph_graph::ForwardGraph;
28use oxgraph_hyper::{DirectedHyperedgeIncidences, DirectedVertexHyperedges};
29
30/// Capability bundle the hypergraph `PageRank` surface requires.
31///
32/// Bundles directed vertex-to-hyperedge adjacency, directed hyperedge
33/// incidences, incidence-to-vertex resolution, and dense element/relation
34/// indexing. Blanket-implemented for every topology providing the parts, so
35/// callers never implement it directly.
36///
37/// # Performance
38///
39/// `perf: unspecified`; this is a bound bundle.
40pub trait PageRankHypergraph:
41    DirectedVertexHyperedges
42    + DirectedHyperedgeIncidences
43    + IncidenceElement
44    + DenseElementIndex
45    + DenseRelationIndex
46{
47}
48
49impl<T> PageRankHypergraph for T where
50    T: DirectedVertexHyperedges
51        + DirectedHyperedgeIncidences
52        + IncidenceElement
53        + DenseElementIndex
54        + DenseRelationIndex
55{
56}
57use oxgraph_topology::{
58    DenseElementIndex, DenseIncidenceIndex, DenseRelationIndex, ElementId, IncidenceElement,
59    IncidenceWeight, RelationId, RelationWeight,
60};
61
62/// Rank scalar accepted by `OxGraph` `PageRank` entry points.
63///
64/// The trait is owned by `OxGraph` so topology weights do not inherit arithmetic
65/// semantics and public algorithms do not expose a broad numeric dependency.
66/// Implementations define the numeric operations `PageRank` needs for rank state,
67/// damping, tolerance, personalization, and converted weights.
68///
69/// # Scalar laws
70///
71/// Implementations must use ordinary finite numeric ordering and arithmetic:
72/// `ZERO` is the additive identity, `ONE` is the multiplicative identity,
73/// division by a positive count is finite for supported topology sizes, and
74/// `abs(a - b)` is non-negative and finite whenever `a` and `b` are finite.
75pub trait PageRankScalar:
76    Copy
77    + fmt::Debug
78    + PartialOrd
79    + Add<Output = Self>
80    + Sub<Output = Self>
81    + Mul<Output = Self>
82    + Div<Output = Self>
83    + AddAssign
84    + 'static
85{
86    /// Additive identity.
87    const ZERO: Self;
88    /// Multiplicative identity.
89    const ONE: Self;
90    /// Positive infinity sentinel used before the first iteration delta.
91    const INFINITY: Self;
92
93    /// Converts a row degree or visible-state count into this scalar.
94    fn from_usize(value: usize) -> Self;
95
96    /// Converts a Rust float literal/default into this scalar.
97    fn from_f64(value: f64) -> Self;
98
99    /// Absolute value.
100    #[must_use]
101    fn abs(self) -> Self;
102
103    /// Returns whether this value is finite.
104    fn is_finite(self) -> bool;
105}
106
107impl PageRankScalar for f64 {
108    const ZERO: Self = 0.0;
109    const ONE: Self = 1.0;
110    #[expect(
111        clippy::use_self,
112        reason = "primitive inherent infinity constant is clearer here"
113    )]
114    const INFINITY: Self = f64::INFINITY;
115
116    #[expect(
117        clippy::cast_precision_loss,
118        reason = "PageRank degree conversion is a documented scalar-boundary conversion"
119    )]
120    fn from_usize(value: usize) -> Self {
121        value as Self
122    }
123
124    fn from_f64(value: f64) -> Self {
125        value
126    }
127
128    fn abs(self) -> Self {
129        self.abs()
130    }
131
132    fn is_finite(self) -> bool {
133        self.is_finite()
134    }
135}
136
137impl PageRankScalar for f32 {
138    const ZERO: Self = 0.0;
139    const ONE: Self = 1.0;
140    #[expect(
141        clippy::use_self,
142        reason = "primitive inherent infinity constant is clearer here"
143    )]
144    const INFINITY: Self = f32::INFINITY;
145
146    #[expect(
147        clippy::cast_precision_loss,
148        reason = "PageRank degree conversion is a documented scalar-boundary conversion"
149    )]
150    fn from_usize(value: usize) -> Self {
151        value as Self
152    }
153
154    #[expect(
155        clippy::cast_possible_truncation,
156        reason = "f32 PageRank callers explicitly select f32 rank/config output"
157    )]
158    fn from_f64(value: f64) -> Self {
159        value as Self
160    }
161
162    fn abs(self) -> Self {
163        self.abs()
164    }
165
166    fn is_finite(self) -> bool {
167        self.is_finite()
168    }
169}
170
171/// Explicit conversion from a topology weight into a `PageRank` rank scalar.
172///
173/// Implementations are deliberately limited to documented primitive conversions;
174/// downstream topology weights stay semantic-free and algorithms opt into the
175/// numeric interpretation at this boundary.
176pub trait IntoPageRankScalar<S: PageRankScalar> {
177    /// Converts `self` into rank scalar `S`.
178    fn into_pagerank_scalar(self) -> S;
179}
180
181impl<S: PageRankScalar> IntoPageRankScalar<S> for S {
182    fn into_pagerank_scalar(self) -> S {
183        self
184    }
185}
186
187/// Implements lossless primitive conversions into a `PageRank` scalar.
188macro_rules! impl_weight_into_pagerank_scalar_from {
189    ($target:ty; $($type:ty),* $(,)?) => {
190        $(
191            impl IntoPageRankScalar<$target> for $type {
192                fn into_pagerank_scalar(self) -> $target { <$target>::from(self) }
193            }
194        )*
195    };
196}
197
198/// Implements explicitly lossy primitive conversions into a `PageRank` scalar.
199macro_rules! impl_weight_into_pagerank_scalar_cast {
200    ($target:ty; $($type:ty),* $(,)?) => {
201        $(
202            impl IntoPageRankScalar<$target> for $type {
203                #[expect(
204                    clippy::cast_precision_loss,
205                    reason = "PageRank primitive weight conversions are explicit algorithm-boundary numeric interpretations"
206                )]
207                fn into_pagerank_scalar(self) -> $target { self as $target }
208            }
209        )*
210    };
211}
212
213impl_weight_into_pagerank_scalar_from!(f64; u8, u16, u32, i8, i16, i32, f32);
214impl_weight_into_pagerank_scalar_cast!(f64; u64, usize, i64, isize);
215impl_weight_into_pagerank_scalar_from!(f32; u8, u16, i8, i16);
216impl_weight_into_pagerank_scalar_cast!(f32; u32, u64, usize, i32, i64, isize);
217
218impl IntoPageRankScalar<f32> for f64 {
219    #[expect(
220        clippy::cast_possible_truncation,
221        reason = "f32 PageRank callers explicitly select f32 rank and configuration output"
222    )]
223    fn into_pagerank_scalar(self) -> f32 {
224        self as f32
225    }
226}
227
228/// Per-element outgoing rank-distribution rule used by graph `PageRank`.
229///
230/// Implementations decide how an element's current `rank` is split across its
231/// outgoing visible neighbors and how much of it (if any) flows to the
232/// dangling reservoir. Built-in [`Uniform`] divides equally over visible
233/// out-degree; built-in [`Weighted`] divides proportionally to relation
234/// weights. User crates can implement this trait for custom rules — for
235/// example, attenuated decay, top-k filtering, or caller-supplied
236/// per-element shares — without forking the kernel.
237///
238/// # Contract
239///
240/// On success, `distribute_outgoing` writes per-target shares into `next`
241/// (visible-masked) and returns the dangling contribution. The sum of `next`
242/// increments plus the returned dangling contribution must equal `rank` when
243/// at least one visible target exists; when no visible targets exist, the
244/// implementation must return `rank` and write nothing. Failures are surfaced
245/// as [`PageRankError`].
246///
247/// # Performance
248///
249/// `perf: unspecified`; built-in implementations document their per-element
250/// cost. Most do `O(degree)` work per element with one or two passes over
251/// the outgoing-edge iterator.
252pub trait OutgoingDistribution<G, S>
253where
254    G: ForwardGraph + DenseElementIndex,
255    S: PageRankScalar,
256{
257    /// Distributes `rank` from `element` to its outgoing visible neighbors.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`PageRankError`] when a topology index is invalid, when a
262    /// caller-supplied weight is invalid, or when an arithmetic boundary is
263    /// crossed.
264    ///
265    /// # Performance
266    ///
267    /// `perf: unspecified`; implementations document their cost.
268    #[expect(
269        clippy::too_many_arguments,
270        reason = "distribution kernel needs graph, element, rank, output buffer, and visibility mask explicitly"
271    )]
272    fn distribute_outgoing(
273        &self,
274        graph: &G,
275        element: G::ElementId,
276        rank: S,
277        next: &mut [S],
278        visible: &[u8],
279    ) -> Result<S, PageRankError<S>>;
280}
281
282/// Distributes outgoing rank uniformly across visible out-degree.
283///
284/// Built-in [`OutgoingDistribution`] implementation matching the unweighted
285/// `PageRank` semantics. Two passes over the outgoing-edge iterator: one to
286/// count visible degree, one to write the share `rank / degree`.
287///
288/// # Performance
289///
290/// Each call is `O(degree)`.
291#[derive(Clone, Copy, Debug, Default)]
292pub struct Uniform;
293
294impl<G, S> OutgoingDistribution<G, S> for Uniform
295where
296    G: ForwardGraph + DenseElementIndex,
297    S: PageRankScalar,
298{
299    fn distribute_outgoing(
300        &self,
301        graph: &G,
302        element: G::ElementId,
303        rank: S,
304        next: &mut [S],
305        visible: &[u8],
306    ) -> Result<S, PageRankError<S>> {
307        let mut degree = 0_usize;
308        for edge in graph.outgoing_edges(element) {
309            let target = graph.target(edge);
310            let target_index = checked_element_index(graph, target)?;
311            if is_visible(visible, target_index) {
312                degree += 1;
313            }
314        }
315        if degree == 0 {
316            return Ok(rank);
317        }
318        let share = rank / S::from_usize(degree);
319        for edge in graph.outgoing_edges(element) {
320            let target = graph.target(edge);
321            let target_index = checked_element_index(graph, target)?;
322            if is_visible(visible, target_index) {
323                next[target_index] += share;
324            }
325        }
326        Ok(S::ZERO)
327    }
328}
329
330/// Distributes outgoing rank proportionally to caller-supplied weights.
331///
332/// Built-in [`OutgoingDistribution`] implementation matching the weighted
333/// `PageRank` semantics. Holds a borrowed [`RelationWeight`] adapter and uses
334/// two passes over the outgoing-edge iterator: one to compute the visible
335/// outgoing-weight total, one to write per-edge shares `rank * weight /
336/// total`. Dangling rows (zero or non-positive total) flow the entire `rank`
337/// to the dangling reservoir.
338///
339/// # Performance
340///
341/// Each call is `O(degree)`.
342#[derive(Clone, Copy, Debug)]
343pub struct Weighted<'w, W> {
344    /// Borrowed relation-weight adapter.
345    weights: &'w W,
346}
347
348impl<'w, W> Weighted<'w, W> {
349    /// Constructs a [`Weighted`] distribution borrowing `weights`.
350    ///
351    /// # Performance
352    ///
353    /// This function is `O(1)`.
354    #[must_use]
355    pub const fn new(weights: &'w W) -> Self {
356        Self { weights }
357    }
358}
359
360impl<G, W, S> OutgoingDistribution<G, S> for Weighted<'_, W>
361where
362    G: ForwardGraph + DenseElementIndex + DenseRelationIndex,
363    W: RelationWeight<ElementId = G::ElementId, RelationId = G::RelationId>,
364    W::Weight: IntoPageRankScalar<S>,
365    S: PageRankScalar,
366{
367    fn distribute_outgoing(
368        &self,
369        graph: &G,
370        element: G::ElementId,
371        rank: S,
372        next: &mut [S],
373        visible: &[u8],
374    ) -> Result<S, PageRankError<S>> {
375        let total = outgoing_weight_total(graph, self.weights, element, visible)?;
376        if total <= S::ZERO {
377            return Ok(rank);
378        }
379        for edge in graph.outgoing_edges(element) {
380            let target = graph.target(edge);
381            let target_index = checked_element_index(graph, target)?;
382            if is_visible(visible, target_index) {
383                let weight = checked_relation_weight(graph, self.weights, edge)?;
384                next[target_index] += rank * (weight / total);
385            }
386        }
387        Ok(S::ZERO)
388    }
389}
390
391/// Bipartite outgoing rank-distribution rule used by hypergraph `PageRank`.
392///
393/// Implementations decide how an element's current `rank` is split across its
394/// outgoing visible relations, and how a relation's current `rank` is split
395/// across its visible target elements. Built-in [`Uniform`] divides each leg
396/// evenly over the visible out-degree; built-in [`HyperWeighted`] divides
397/// element-to-relation by [`RelationWeight`] values and relation-to-element by
398/// [`IncidenceWeight`] values. User crates can implement this trait for custom
399/// bipartite policies — e.g. attenuated decay, top-k filtering, or
400/// caller-supplied per-state shares — without forking the kernel.
401///
402/// # Contract
403///
404/// On success, both methods write per-target shares into the appropriate next
405/// buffer (visible-masked) and return the dangling contribution for the
406/// source state. The sum of `next_*` increments plus the returned dangling
407/// contribution must equal `rank` when at least one visible target exists;
408/// when no visible target exists, the implementation must return `rank` and
409/// write nothing. Failures are surfaced as [`PageRankError`].
410///
411/// # Performance
412///
413/// `perf: unspecified`; built-in implementations document their per-state
414/// cost. Most do `O(degree)` work per state with one or two passes over the
415/// outgoing-incidence iterator.
416pub trait HypergraphOutgoingDistribution<H, S>
417where
418    H: PageRankHypergraph,
419    S: PageRankScalar,
420{
421    /// Distributes `rank` from `element` to its outgoing visible relations.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`PageRankError`] when a topology index is invalid, when a
426    /// caller-supplied weight is invalid, or when an arithmetic boundary is
427    /// crossed.
428    ///
429    /// # Performance
430    ///
431    /// `perf: unspecified`; implementations document their cost.
432    #[expect(
433        clippy::too_many_arguments,
434        reason = "distribution kernel needs hypergraph, element, rank, output buffer, and visibility mask explicitly"
435    )]
436    fn distribute_from_element(
437        &self,
438        hypergraph: &H,
439        element: H::ElementId,
440        rank: S,
441        next_relations: &mut [S],
442        visible_relations: &[u8],
443    ) -> Result<S, PageRankError<S>>;
444
445    /// Distributes `rank` from `relation` to its visible target elements.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`PageRankError`] when a topology index is invalid, when a
450    /// caller-supplied weight is invalid, or when an arithmetic boundary is
451    /// crossed.
452    ///
453    /// # Performance
454    ///
455    /// `perf: unspecified`; implementations document their cost.
456    #[expect(
457        clippy::too_many_arguments,
458        reason = "distribution kernel needs hypergraph, relation, rank, output buffer, and visibility mask explicitly"
459    )]
460    fn distribute_from_relation(
461        &self,
462        hypergraph: &H,
463        relation: H::RelationId,
464        rank: S,
465        next_elements: &mut [S],
466        visible_elements: &[u8],
467    ) -> Result<S, PageRankError<S>>;
468}
469
470impl<H, S> HypergraphOutgoingDistribution<H, S> for Uniform
471where
472    H: PageRankHypergraph,
473    S: PageRankScalar,
474{
475    fn distribute_from_element(
476        &self,
477        hypergraph: &H,
478        element: H::ElementId,
479        rank: S,
480        next_relations: &mut [S],
481        visible_relations: &[u8],
482    ) -> Result<S, PageRankError<S>> {
483        let mut degree = 0_usize;
484        for relation in hypergraph.outgoing_hyperedges(element) {
485            let relation_index = checked_relation_index_for(hypergraph, relation)?;
486            if is_visible(visible_relations, relation_index) {
487                degree += 1;
488            }
489        }
490        if degree == 0 {
491            return Ok(rank);
492        }
493        let share = rank / S::from_usize(degree);
494        for relation in hypergraph.outgoing_hyperedges(element) {
495            let relation_index = checked_relation_index_for(hypergraph, relation)?;
496            if is_visible(visible_relations, relation_index) {
497                next_relations[relation_index] += share;
498            }
499        }
500        Ok(S::ZERO)
501    }
502
503    fn distribute_from_relation(
504        &self,
505        hypergraph: &H,
506        relation: H::RelationId,
507        rank: S,
508        next_elements: &mut [S],
509        visible_elements: &[u8],
510    ) -> Result<S, PageRankError<S>> {
511        let mut degree = 0_usize;
512        for incidence in hypergraph.target_incidences(relation) {
513            let target = hypergraph.incidence_element(incidence);
514            let target_index = checked_element_index(hypergraph, target)?;
515            if is_visible(visible_elements, target_index) {
516                degree += 1;
517            }
518        }
519        if degree == 0 {
520            return Ok(rank);
521        }
522        let share = rank / S::from_usize(degree);
523        for incidence in hypergraph.target_incidences(relation) {
524            let target = hypergraph.incidence_element(incidence);
525            let target_index = checked_element_index(hypergraph, target)?;
526            if is_visible(visible_elements, target_index) {
527                next_elements[target_index] += share;
528            }
529        }
530        Ok(S::ZERO)
531    }
532}
533
534/// Distributes bipartite outgoing rank proportionally to caller-supplied
535/// weights.
536///
537/// Built-in [`HypergraphOutgoingDistribution`] implementation matching the
538/// weighted incidence/bipartite `PageRank` semantics. Holds borrowed
539/// [`RelationWeight`] and [`IncidenceWeight`] adapters: element-to-relation
540/// distribution uses relation weights, and relation-to-element distribution
541/// uses target-incidence weights. Source-incidence weights are intentionally
542/// not used by the default policy. Dangling rows (zero or non-positive total)
543/// flow the entire `rank` to the dangling reservoir.
544///
545/// # Performance
546///
547/// Each call is `O(degree)` for the source state's visible neighborhood.
548#[derive(Clone, Copy, Debug)]
549pub struct HyperWeighted<'rw, 'iw, RW, IW> {
550    /// Borrowed relation-weight adapter driving element → relation transitions.
551    relation_weights: &'rw RW,
552    /// Borrowed incidence-weight adapter driving relation → element transitions.
553    incidence_weights: &'iw IW,
554}
555
556impl<'rw, 'iw, RW, IW> HyperWeighted<'rw, 'iw, RW, IW> {
557    /// Constructs a [`HyperWeighted`] distribution borrowing the relation and
558    /// incidence weight adapters.
559    ///
560    /// # Performance
561    ///
562    /// This function is `O(1)`.
563    #[must_use]
564    pub const fn new(relation_weights: &'rw RW, incidence_weights: &'iw IW) -> Self {
565        Self {
566            relation_weights,
567            incidence_weights,
568        }
569    }
570}
571
572impl<H, RW, IW, S> HypergraphOutgoingDistribution<H, S> for HyperWeighted<'_, '_, RW, IW>
573where
574    H: PageRankHypergraph + DenseIncidenceIndex,
575    RW: RelationWeight<ElementId = H::ElementId, RelationId = H::RelationId>,
576    RW::Weight: IntoPageRankScalar<S>,
577    IW: IncidenceWeight<
578            ElementId = H::ElementId,
579            RelationId = H::RelationId,
580            IncidenceId = H::IncidenceId,
581        >,
582    IW::Weight: IntoPageRankScalar<S>,
583    S: PageRankScalar,
584{
585    fn distribute_from_element(
586        &self,
587        hypergraph: &H,
588        element: H::ElementId,
589        rank: S,
590        next_relations: &mut [S],
591        visible_relations: &[u8],
592    ) -> Result<S, PageRankError<S>> {
593        let total = hyper_outgoing_relation_weight(
594            hypergraph,
595            self.relation_weights,
596            element,
597            visible_relations,
598        )?;
599        if total <= S::ZERO {
600            return Ok(rank);
601        }
602        for relation in hypergraph.outgoing_hyperedges(element) {
603            let relation_index = checked_relation_index_for(hypergraph, relation)?;
604            if !is_visible(visible_relations, relation_index) {
605                continue;
606            }
607            let weight = checked_relation_weight(hypergraph, self.relation_weights, relation)?;
608            next_relations[relation_index] += rank * (weight / total);
609        }
610        Ok(S::ZERO)
611    }
612
613    fn distribute_from_relation(
614        &self,
615        hypergraph: &H,
616        relation: H::RelationId,
617        rank: S,
618        next_elements: &mut [S],
619        visible_elements: &[u8],
620    ) -> Result<S, PageRankError<S>> {
621        let total = hyper_target_incidence_weight(
622            hypergraph,
623            self.incidence_weights,
624            relation,
625            visible_elements,
626        )?;
627        if total <= S::ZERO {
628            return Ok(rank);
629        }
630        for incidence in hypergraph.target_incidences(relation) {
631            let target = hypergraph.incidence_element(incidence);
632            let target_index = checked_element_index(hypergraph, target)?;
633            if !is_visible(visible_elements, target_index) {
634                continue;
635            }
636            let weight = checked_incidence_weight(hypergraph, self.incidence_weights, incidence)?;
637            next_elements[target_index] += rank * (weight / total);
638        }
639        Ok(S::ZERO)
640    }
641}
642
643/// `PageRank` configuration shared by graph and hypergraph policies.
644///
645/// # Performance
646///
647/// Copying and debug-formatting are `O(1)`.
648#[derive(Clone, Copy, Debug, PartialEq)]
649#[non_exhaustive]
650pub struct PageRankConfig<S> {
651    /// Damping factor, usually `0.85`.
652    pub damping: S,
653    /// L1 convergence tolerance.
654    pub tolerance: S,
655    /// Maximum power-iteration count.
656    pub max_iterations: usize,
657}
658
659impl<S> PageRankConfig<S> {
660    /// Constructs a `PageRank` configuration.
661    ///
662    /// Validation is performed by the `PageRank` entry point so callers can build
663    /// configs before deciding which algorithm variant to invoke.
664    ///
665    /// # Performance
666    ///
667    /// This function is `O(1)`.
668    #[must_use]
669    pub const fn new(damping: S, tolerance: S, max_iterations: usize) -> Self {
670        Self {
671            damping,
672            tolerance,
673            max_iterations,
674        }
675    }
676}
677
678impl<S: PageRankScalar> Default for PageRankConfig<S> {
679    /// The conventional defaults — damping `0.85`, L1 tolerance `1e-6`, and `100`
680    /// power iterations — so consumers stop hardcoding the same triple.
681    fn default() -> Self {
682        Self {
683            damping: S::from_f64(0.85),
684            tolerance: S::from_f64(1e-6),
685            max_iterations: 100,
686        }
687    }
688}
689
690/// Successful `PageRank` convergence report.
691///
692/// # Performance
693///
694/// Copying and debug-formatting are `O(1)`.
695#[derive(Clone, Copy, Debug, PartialEq)]
696#[non_exhaustive]
697pub struct PageRankReport<S> {
698    /// Number of iterations executed.
699    pub iterations: usize,
700    /// Final L1 rank delta.
701    pub delta: S,
702}
703
704/// `PageRank` input, numeric, scratch, and convergence errors.
705///
706/// # Performance
707///
708/// Formatting is `O(message length)`.
709#[derive(Debug, Clone, PartialEq)]
710#[non_exhaustive]
711pub enum PageRankError<S> {
712    /// `PageRank` is undefined for an empty visible state set.
713    EmptyState,
714    /// Damping must be finite and in `[0, 1]`.
715    InvalidDamping {
716        /// Invalid damping value.
717        damping: S,
718    },
719    /// Tolerance must be finite and non-negative.
720    InvalidTolerance {
721        /// Invalid tolerance value.
722        tolerance: S,
723    },
724    /// At least one iteration is required.
725    InvalidMaxIterations,
726    /// Output rank storage was shorter than the topology index bound.
727    OutputTooShort {
728        /// Required length.
729        required: usize,
730        /// Actual length.
731        actual: usize,
732    },
733    /// Scratch storage was shorter than the required bound.
734    ScratchTooShort {
735        /// Scratch slice name.
736        name: &'static str,
737        /// Required length.
738        required: usize,
739        /// Actual length.
740        actual: usize,
741    },
742    /// Personalization storage was shorter than the topology index bound.
743    PersonalizationTooShort {
744        /// Required length.
745        required: usize,
746        /// Actual length.
747        actual: usize,
748    },
749    /// A personalization entry was negative or non-finite.
750    InvalidPersonalization {
751        /// Invalid index.
752        index: usize,
753        /// Invalid value.
754        value: S,
755    },
756    /// Personalization sum was zero over visible states.
757    ZeroPersonalization,
758    /// A topology element mapped outside the advertised element bound.
759    ElementIndexOutOfBounds {
760        /// Invalid index.
761        index: usize,
762        /// Advertised bound.
763        bound: usize,
764    },
765    /// A visible element was provided more than once.
766    DuplicateElement {
767        /// Duplicate dense element index.
768        index: usize,
769    },
770    /// A visible relation was provided more than once.
771    DuplicateRelation {
772        /// Duplicate dense relation index.
773        index: usize,
774    },
775    /// A topology relation mapped outside the advertised relation bound.
776    RelationIndexOutOfBounds {
777        /// Invalid index.
778        index: usize,
779        /// Advertised bound.
780        bound: usize,
781    },
782    /// A topology incidence mapped outside the advertised incidence bound.
783    IncidenceIndexOutOfBounds {
784        /// Invalid index.
785        index: usize,
786        /// Advertised bound.
787        bound: usize,
788    },
789    /// A relation weight was negative or non-finite.
790    InvalidRelationWeight {
791        /// Dense relation index.
792        index: usize,
793        /// Invalid value.
794        value: S,
795    },
796    /// An incidence weight was negative or non-finite.
797    InvalidIncidenceWeight {
798        /// Dense incidence index.
799        index: usize,
800        /// Invalid value.
801        value: S,
802    },
803    /// Power iteration reached the maximum iteration count before convergence.
804    NonConverged {
805        /// Iterations executed.
806        iterations: usize,
807        /// Final L1 delta.
808        delta: S,
809    },
810}
811
812impl<S: fmt::Debug> fmt::Display for PageRankError<S> {
813    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
814        match self {
815            Self::EmptyState => formatter.write_str("pagerank state set is empty"),
816            Self::InvalidDamping { damping } => {
817                write!(formatter, "invalid pagerank damping {damping:?}")
818            }
819            Self::InvalidTolerance { tolerance } => {
820                write!(formatter, "invalid pagerank tolerance {tolerance:?}")
821            }
822            Self::InvalidMaxIterations => {
823                formatter.write_str("pagerank max_iterations must be positive")
824            }
825            Self::OutputTooShort { required, actual } => write!(
826                formatter,
827                "pagerank output too short: required {required}, got {actual}"
828            ),
829            Self::ScratchTooShort {
830                name,
831                required,
832                actual,
833            } => write!(
834                formatter,
835                "pagerank scratch '{name}' too short: required {required}, got {actual}"
836            ),
837            Self::PersonalizationTooShort { required, actual } => write!(
838                formatter,
839                "pagerank personalization too short: required {required}, got {actual}"
840            ),
841            Self::InvalidPersonalization { index, value } => write!(
842                formatter,
843                "invalid pagerank personalization at {index}: {value:?}"
844            ),
845            Self::ZeroPersonalization => {
846                formatter.write_str("pagerank personalization sum is zero")
847            }
848            Self::ElementIndexOutOfBounds { index, bound } => {
849                write!(formatter, "element index {index} is outside bound {bound}")
850            }
851            Self::DuplicateElement { index } => {
852                write!(formatter, "duplicate pagerank element index {index}")
853            }
854            Self::DuplicateRelation { index } => {
855                write!(formatter, "duplicate pagerank relation index {index}")
856            }
857            Self::RelationIndexOutOfBounds { index, bound } => {
858                write!(formatter, "relation index {index} is outside bound {bound}")
859            }
860            Self::IncidenceIndexOutOfBounds { index, bound } => {
861                write!(
862                    formatter,
863                    "incidence index {index} is outside bound {bound}"
864                )
865            }
866            Self::InvalidRelationWeight { index, value } => {
867                write!(formatter, "invalid relation weight at {index}: {value:?}")
868            }
869            Self::InvalidIncidenceWeight { index, value } => {
870                write!(formatter, "invalid incidence weight at {index}: {value:?}")
871            }
872            Self::NonConverged { iterations, delta } => write!(
873                formatter,
874                "pagerank did not converge after {iterations} iterations; delta {delta:?}"
875            ),
876        }
877    }
878}
879
880impl<S: fmt::Debug> Error for PageRankError<S> {}
881
882/// Borrowed scratch storage for ordinary graph `PageRank`.
883///
884/// # Performance
885///
886/// Construction is `O(1)`. The slices must be at least `graph.element_bound()`
887/// long for the graph passed to a scratch API.
888#[derive(Debug)]
889#[must_use]
890pub struct PageRankScratch<'scratch, S> {
891    /// Teleport/personalization scratch by element index.
892    teleport: &'scratch mut [S],
893    /// Next-rank scratch by element index.
894    next: &'scratch mut [S],
895    /// Visible element bitset by element index.
896    visible_elements: &'scratch mut [u8],
897}
898
899impl<'scratch, S> PageRankScratch<'scratch, S> {
900    /// Constructs borrowed graph `PageRank` scratch from caller-owned slices.
901    ///
902    /// # Performance
903    ///
904    /// This function is `O(1)`.
905    pub const fn new(
906        teleport: &'scratch mut [S],
907        next: &'scratch mut [S],
908        visible_elements: &'scratch mut [u8],
909    ) -> Self {
910        Self {
911            teleport,
912            next,
913            visible_elements,
914        }
915    }
916
917    /// Returns current teleport scratch capacity.
918    ///
919    /// # Performance
920    ///
921    /// This function is `O(1)`.
922    #[must_use]
923    pub const fn teleport_capacity(&self) -> usize {
924        self.teleport.len()
925    }
926
927    /// Returns current next-rank scratch capacity.
928    ///
929    /// # Performance
930    ///
931    /// This function is `O(1)`.
932    #[must_use]
933    pub const fn next_capacity(&self) -> usize {
934        self.next.len()
935    }
936
937    /// Returns current visible-element scratch capacity.
938    ///
939    /// # Performance
940    ///
941    /// This function is `O(1)`.
942    #[must_use]
943    pub const fn visible_element_capacity(&self) -> usize {
944        self.visible_elements.len()
945    }
946}
947
948/// Borrowed scratch storage for incidence/bipartite hypergraph `PageRank`.
949///
950/// # Performance
951///
952/// Construction is `O(1)`. `teleport` must cover `element_bound + relation_bound`,
953/// while `next_elements` and `next_relations` cover their respective bounds.
954#[derive(Debug)]
955#[must_use]
956pub struct HypergraphPageRankScratch<'scratch, S> {
957    /// Teleport/personalization scratch by combined element+relation state index.
958    teleport: &'scratch mut [S],
959    /// Next element ranks by element index.
960    next_elements: &'scratch mut [S],
961    /// Next relation ranks by relation index.
962    next_relations: &'scratch mut [S],
963    /// Visible element bitset by element index.
964    visible_elements: &'scratch mut [u8],
965    /// Visible relation bitset by relation index.
966    visible_relations: &'scratch mut [u8],
967}
968
969impl<'scratch, S> HypergraphPageRankScratch<'scratch, S> {
970    /// Constructs borrowed hypergraph `PageRank` scratch from caller-owned slices.
971    ///
972    /// # Performance
973    ///
974    /// This function is `O(1)`.
975    pub const fn new(
976        teleport: &'scratch mut [S],
977        next_elements: &'scratch mut [S],
978        next_relations: &'scratch mut [S],
979        visible_elements: &'scratch mut [u8],
980        visible_relations: &'scratch mut [u8],
981    ) -> Self {
982        Self {
983            teleport,
984            next_elements,
985            next_relations,
986            visible_elements,
987            visible_relations,
988        }
989    }
990
991    /// Returns current teleport scratch capacity.
992    ///
993    /// # Performance
994    ///
995    /// This function is `O(1)`.
996    #[must_use]
997    pub const fn teleport_capacity(&self) -> usize {
998        self.teleport.len()
999    }
1000}
1001
1002/// Owned reusable workspace for ordinary graph `PageRank`.
1003///
1004/// The `G` parameter brands the workspace to a view type, mirroring
1005/// [`crate::BfsWorkspace`]. The scalar `S` fixes the rank/storage scalar.
1006///
1007/// # Performance
1008///
1009/// Memory usage is `O(b)` for the largest element bound used with the workspace.
1010#[cfg(feature = "alloc")]
1011#[derive(Clone, Debug)]
1012pub struct PageRankWorkspace<G, S> {
1013    /// Teleport/personalization scratch.
1014    teleport: Vec<S>,
1015    /// Next-rank scratch.
1016    next: Vec<S>,
1017    /// Visible element bitset.
1018    visible_elements: Vec<u8>,
1019    /// Brands workspace storage to a topology view type without owning the view.
1020    _graph: PhantomData<fn() -> G>,
1021}
1022
1023#[cfg(feature = "alloc")]
1024impl<G, S: PageRankScalar> Default for PageRankWorkspace<G, S> {
1025    fn default() -> Self {
1026        Self::new()
1027    }
1028}
1029
1030#[cfg(feature = "alloc")]
1031impl<G, S: PageRankScalar> PageRankWorkspace<G, S> {
1032    /// Creates an empty reusable `PageRank` workspace.
1033    ///
1034    /// # Performance
1035    ///
1036    /// This function is `O(1)` and performs no allocation.
1037    #[must_use]
1038    pub const fn new() -> Self {
1039        Self {
1040            teleport: Vec::new(),
1041            next: Vec::new(),
1042            visible_elements: Vec::new(),
1043            _graph: PhantomData,
1044        }
1045    }
1046
1047    /// Creates a workspace sized for `graph.element_bound()`.
1048    ///
1049    /// # Performance
1050    ///
1051    /// Allocates and initializes `O(graph.element_bound())` storage.
1052    #[must_use]
1053    pub fn for_graph(graph: &G) -> Self
1054    where
1055        G: DenseElementIndex,
1056    {
1057        Self::with_element_bound(graph.element_bound())
1058    }
1059
1060    /// Creates a workspace with capacity for `element_bound` element states.
1061    ///
1062    /// # Performance
1063    ///
1064    /// Allocates and initializes `O(element_bound)` storage.
1065    #[must_use]
1066    pub fn with_element_bound(element_bound: usize) -> Self {
1067        Self {
1068            teleport: vec![S::ZERO; element_bound],
1069            next: vec![S::ZERO; element_bound],
1070            visible_elements: vec![0; element_bound],
1071            _graph: PhantomData,
1072        }
1073    }
1074
1075    /// Returns the element-bound capacity currently available without growth.
1076    ///
1077    /// # Performance
1078    ///
1079    /// This function is `O(1)`.
1080    #[must_use]
1081    pub const fn element_bound_capacity(&self) -> usize {
1082        self.teleport.len()
1083    }
1084
1085    /// Ensures workspace storage covers `element_bound`.
1086    fn ensure_element_bound(&mut self, element_bound: usize) {
1087        if self.teleport.len() < element_bound {
1088            self.teleport.resize(element_bound, S::ZERO);
1089        }
1090        if self.next.len() < element_bound {
1091            self.next.resize(element_bound, S::ZERO);
1092        }
1093        if self.visible_elements.len() < element_bound {
1094            self.visible_elements.resize(element_bound, 0);
1095        }
1096    }
1097
1098    /// Borrows this workspace as scratch.
1099    fn as_scratch(&mut self) -> PageRankScratch<'_, S> {
1100        PageRankScratch::new(
1101            &mut self.teleport,
1102            &mut self.next,
1103            &mut self.visible_elements,
1104        )
1105    }
1106}
1107
1108/// Owned reusable workspace for incidence/bipartite hypergraph `PageRank`.
1109///
1110/// # Performance
1111///
1112/// Memory usage is `O(e + r)` for the largest element and relation bounds used.
1113#[cfg(feature = "alloc")]
1114#[derive(Clone, Debug)]
1115pub struct HypergraphPageRankWorkspace<H, S> {
1116    /// Combined element+relation teleport/personalization scratch.
1117    teleport: Vec<S>,
1118    /// Next element ranks.
1119    next_elements: Vec<S>,
1120    /// Next relation ranks.
1121    next_relations: Vec<S>,
1122    /// Visible element bitset.
1123    visible_elements: Vec<u8>,
1124    /// Visible relation bitset.
1125    visible_relations: Vec<u8>,
1126    /// Brands workspace storage to a hypergraph view type without owning the view.
1127    _hypergraph: PhantomData<fn() -> H>,
1128}
1129
1130#[cfg(feature = "alloc")]
1131impl<H, S: PageRankScalar> Default for HypergraphPageRankWorkspace<H, S> {
1132    fn default() -> Self {
1133        Self::new()
1134    }
1135}
1136
1137#[cfg(feature = "alloc")]
1138impl<H, S: PageRankScalar> HypergraphPageRankWorkspace<H, S> {
1139    /// Creates an empty reusable hypergraph `PageRank` workspace.
1140    ///
1141    /// # Performance
1142    ///
1143    /// This function is `O(1)` and performs no allocation.
1144    #[must_use]
1145    pub const fn new() -> Self {
1146        Self {
1147            teleport: Vec::new(),
1148            next_elements: Vec::new(),
1149            next_relations: Vec::new(),
1150            visible_elements: Vec::new(),
1151            visible_relations: Vec::new(),
1152            _hypergraph: PhantomData,
1153        }
1154    }
1155
1156    /// Creates a workspace sized for a hypergraph's element/relation bounds.
1157    ///
1158    /// # Performance
1159    ///
1160    /// Allocates and initializes `O(element_bound + relation_bound)` storage.
1161    #[must_use]
1162    pub fn for_hypergraph(hypergraph: &H) -> Self
1163    where
1164        H: DenseElementIndex + DenseRelationIndex,
1165    {
1166        Self::with_bounds(hypergraph.element_bound(), hypergraph.relation_bound())
1167    }
1168
1169    /// Creates a workspace with capacity for element and relation bounds.
1170    ///
1171    /// # Performance
1172    ///
1173    /// Allocates and initializes `O(element_bound + relation_bound)` storage.
1174    #[must_use]
1175    pub fn with_bounds(element_bound: usize, relation_bound: usize) -> Self {
1176        let state_bound = element_bound.saturating_add(relation_bound);
1177        Self {
1178            teleport: vec![S::ZERO; state_bound],
1179            next_elements: vec![S::ZERO; element_bound],
1180            next_relations: vec![S::ZERO; relation_bound],
1181            visible_elements: vec![0; element_bound],
1182            visible_relations: vec![0; relation_bound],
1183            _hypergraph: PhantomData,
1184        }
1185    }
1186
1187    /// Returns current element-rank capacity.
1188    ///
1189    /// # Performance
1190    ///
1191    /// This function is `O(1)`.
1192    #[must_use]
1193    pub const fn element_bound_capacity(&self) -> usize {
1194        self.next_elements.len()
1195    }
1196
1197    /// Returns current relation-rank capacity.
1198    ///
1199    /// # Performance
1200    ///
1201    /// This function is `O(1)`.
1202    #[must_use]
1203    pub const fn relation_bound_capacity(&self) -> usize {
1204        self.next_relations.len()
1205    }
1206
1207    /// Ensures workspace storage covers the requested bounds.
1208    fn ensure_bounds(&mut self, element_bound: usize, relation_bound: usize, state_bound: usize) {
1209        if self.teleport.len() < state_bound {
1210            self.teleport.resize(state_bound, S::ZERO);
1211        }
1212        if self.next_elements.len() < element_bound {
1213            self.next_elements.resize(element_bound, S::ZERO);
1214        }
1215        if self.next_relations.len() < relation_bound {
1216            self.next_relations.resize(relation_bound, S::ZERO);
1217        }
1218        if self.visible_elements.len() < element_bound {
1219            self.visible_elements.resize(element_bound, 0);
1220        }
1221        if self.visible_relations.len() < relation_bound {
1222            self.visible_relations.resize(relation_bound, 0);
1223        }
1224    }
1225
1226    /// Borrows this workspace as hypergraph scratch.
1227    fn as_scratch(&mut self) -> HypergraphPageRankScratch<'_, S> {
1228        HypergraphPageRankScratch::new(
1229            &mut self.teleport,
1230            &mut self.next_elements,
1231            &mut self.next_relations,
1232            &mut self.visible_elements,
1233            &mut self.visible_relations,
1234        )
1235    }
1236}
1237
1238/// Computes ordinary directed graph `PageRank` with the supplied outgoing
1239/// distribution policy, allocating temporary scratch.
1240///
1241/// `distribution` selects the per-edge rank-transfer rule:
1242/// [`Uniform`] reproduces the textbook unweighted `PageRank` (every parallel
1243/// outgoing edge carries a unit transition weight), while [`Weighted`]
1244/// reads a caller-supplied edge weight. Any [`OutgoingDistribution`] impl
1245/// is accepted. `elements` defines the visible state iteration order.
1246///
1247/// # Errors
1248///
1249/// Returns [`PageRankError`] for invalid configuration, personalization,
1250/// topology indexes, output length, scratch length, or non-convergence.
1251///
1252/// # Performance
1253///
1254/// Each iteration is `O(n + m · cost(D))` for `n` visible elements, `m`
1255/// outgoing edge entries yielded from those elements, and `cost(D)` the
1256/// per-edge cost of [`OutgoingDistribution::distribute_outgoing`]
1257/// (`O(1)` for the built-in [`Uniform`] and [`Weighted`] impls).
1258/// Scratch allocation is `O(b)` where `b` is `graph.element_bound()`.
1259#[cfg(feature = "alloc")]
1260#[expect(
1261    clippy::too_many_arguments,
1262    reason = "PageRank entry point keeps graph, distribution, elements, config, personalization, and output explicit"
1263)]
1264pub fn pagerank_graph<G, D, I, S>(
1265    graph: &G,
1266    distribution: &D,
1267    elements: I,
1268    config: PageRankConfig<S>,
1269    personalization: Option<&[S]>,
1270    ranks: &mut [S],
1271) -> Result<PageRankReport<S>, PageRankError<S>>
1272where
1273    G: ForwardGraph + DenseElementIndex,
1274    D: OutgoingDistribution<G, S>,
1275    I: Clone + IntoIterator<Item = ElementId<G>>,
1276    S: PageRankScalar,
1277{
1278    let bound = graph.element_bound();
1279    let mut teleport = vec![S::ZERO; bound];
1280    let mut next = vec![S::ZERO; bound];
1281    let mut visible_elements = vec![0; bound];
1282    pagerank_graph_with_scratch(
1283        graph,
1284        distribution,
1285        elements,
1286        config,
1287        personalization,
1288        ranks,
1289        PageRankScratch::new(&mut teleport, &mut next, &mut visible_elements),
1290    )
1291}
1292
1293/// Computes graph `PageRank` under the supplied outgoing distribution
1294/// policy with caller-provided borrowed scratch.
1295///
1296/// See [`pagerank_graph`] for the role of `distribution`.
1297///
1298/// # Errors
1299///
1300/// Returns [`PageRankError`] for invalid configuration, personalization,
1301/// topology indexes, output length, scratch length, or non-convergence.
1302///
1303/// # Performance
1304///
1305/// Performs no heap allocation after caller scratch has been provided. Each
1306/// iteration is `O(n + m · cost(D))`.
1307#[expect(
1308    clippy::too_many_arguments,
1309    clippy::needless_pass_by_value,
1310    reason = "PageRank scratch API consumes a scratch handle and keeps policy inputs explicit"
1311)]
1312pub fn pagerank_graph_with_scratch<G, D, I, S>(
1313    graph: &G,
1314    distribution: &D,
1315    elements: I,
1316    config: PageRankConfig<S>,
1317    personalization: Option<&[S]>,
1318    ranks: &mut [S],
1319    scratch: PageRankScratch<'_, S>,
1320) -> Result<PageRankReport<S>, PageRankError<S>>
1321where
1322    G: ForwardGraph + DenseElementIndex,
1323    D: OutgoingDistribution<G, S>,
1324    I: Clone + IntoIterator<Item = ElementId<G>>,
1325    S: PageRankScalar,
1326{
1327    validate_config(config)?;
1328    let bound = graph.element_bound();
1329    ensure_output_len(ranks.len(), bound)?;
1330    ensure_scratch_len("teleport", scratch.teleport.len(), bound)?;
1331    ensure_scratch_len("next", scratch.next.len(), bound)?;
1332    ensure_scratch_len("visible_elements", scratch.visible_elements.len(), bound)?;
1333    build_personalization_into(
1334        elements.clone(),
1335        bound,
1336        personalization,
1337        |element| graph.element_index(element),
1338        scratch.teleport,
1339        scratch.visible_elements,
1340    )?;
1341    initialize_ranks(elements.clone(), graph, scratch.teleport, ranks)?;
1342    iterate_graph(
1343        graph,
1344        distribution,
1345        elements,
1346        config,
1347        scratch.teleport,
1348        scratch.visible_elements,
1349        ranks,
1350        scratch.next,
1351    )
1352}
1353
1354/// Computes graph `PageRank` under the supplied outgoing distribution
1355/// policy with a reusable owned workspace.
1356///
1357/// See [`pagerank_graph`] for the role of `distribution`.
1358///
1359/// # Errors
1360///
1361/// Returns [`PageRankError`] for invalid configuration, personalization,
1362/// topology indexes, output length, or non-convergence.
1363///
1364/// # Performance
1365///
1366/// Grows workspace storage to `graph.element_bound()` if needed, then performs no
1367/// additional heap allocation. Each iteration is `O(n + m · cost(D))`.
1368#[cfg(feature = "alloc")]
1369#[expect(
1370    clippy::too_many_arguments,
1371    reason = "PageRank workspace API keeps policy and reusable storage inputs explicit"
1372)]
1373pub fn pagerank_graph_with_workspace<G, D, I, S>(
1374    graph: &G,
1375    distribution: &D,
1376    elements: I,
1377    config: PageRankConfig<S>,
1378    personalization: Option<&[S]>,
1379    ranks: &mut [S],
1380    workspace: &mut PageRankWorkspace<G, S>,
1381) -> Result<PageRankReport<S>, PageRankError<S>>
1382where
1383    G: ForwardGraph + DenseElementIndex,
1384    D: OutgoingDistribution<G, S>,
1385    I: Clone + IntoIterator<Item = ElementId<G>>,
1386    S: PageRankScalar,
1387{
1388    workspace.ensure_element_bound(graph.element_bound());
1389    pagerank_graph_with_scratch(
1390        graph,
1391        distribution,
1392        elements,
1393        config,
1394        personalization,
1395        ranks,
1396        workspace.as_scratch(),
1397    )
1398}
1399
1400/// Computes directed hypergraph incidence/bipartite `PageRank` under the
1401/// supplied bipartite outgoing distribution policy, allocating temporary
1402/// scratch.
1403///
1404/// `distribution` selects the per-state rank-transfer rule:
1405/// [`Uniform`] divides each leg uniformly over visible out-degree, while
1406/// [`HyperWeighted`] reads caller-supplied relation and target-incidence
1407/// weights. Any [`HypergraphOutgoingDistribution`] impl is accepted.
1408/// `elements` and `relations` define the visible state iteration order
1409/// across the bipartite element + relation state space.
1410///
1411/// # Errors
1412///
1413/// Returns [`PageRankError`] for invalid configuration, personalization,
1414/// topology indexes, invalid weights, output length, scratch length, or
1415/// non-convergence.
1416///
1417/// # Performance
1418///
1419/// Each iteration is `O(e + r + p · cost(D))` for `e` visible elements, `r`
1420/// visible relations, `p` traversed source/target participant entries, and
1421/// `cost(D)` the per-entry cost of
1422/// [`HypergraphOutgoingDistribution::distribute_from_element`] and
1423/// [`HypergraphOutgoingDistribution::distribute_from_relation`] (`O(1)` for
1424/// the built-in [`Uniform`] and [`HyperWeighted`] impls). Scratch allocation
1425/// is `O(e + r)`.
1426#[cfg(feature = "alloc")]
1427#[expect(
1428    clippy::too_many_arguments,
1429    reason = "hypergraph PageRank entry point keeps hypergraph, distribution, state families, and output explicit"
1430)]
1431pub fn pagerank_hypergraph<H, D, IE, IR, S>(
1432    hypergraph: &H,
1433    distribution: &D,
1434    elements: IE,
1435    relations: IR,
1436    config: PageRankConfig<S>,
1437    personalization: Option<&[S]>,
1438    element_ranks: &mut [S],
1439    relation_ranks: &mut [S],
1440) -> Result<PageRankReport<S>, PageRankError<S>>
1441where
1442    H: PageRankHypergraph,
1443    D: HypergraphOutgoingDistribution<H, S>,
1444    IE: Clone + IntoIterator<Item = ElementId<H>>,
1445    IR: Clone + IntoIterator<Item = RelationId<H>>,
1446    S: PageRankScalar,
1447{
1448    let e_bound = hypergraph.element_bound();
1449    let r_bound = hypergraph.relation_bound();
1450    let state_bound =
1451        checked_state_bound::<S>(e_bound, r_bound, element_ranks.len(), relation_ranks.len())?;
1452    let mut teleport = vec![S::ZERO; state_bound];
1453    let mut next_elements = vec![S::ZERO; e_bound];
1454    let mut next_relations = vec![S::ZERO; r_bound];
1455    let mut visible_elements = vec![0; e_bound];
1456    let mut visible_relations = vec![0; r_bound];
1457    pagerank_hypergraph_with_scratch(
1458        hypergraph,
1459        distribution,
1460        elements,
1461        relations,
1462        config,
1463        personalization,
1464        element_ranks,
1465        relation_ranks,
1466        HypergraphPageRankScratch::new(
1467            &mut teleport,
1468            &mut next_elements,
1469            &mut next_relations,
1470            &mut visible_elements,
1471            &mut visible_relations,
1472        ),
1473    )
1474}
1475
1476/// Computes hypergraph `PageRank` under the supplied outgoing distribution
1477/// policy with caller-provided borrowed scratch.
1478///
1479/// See [`pagerank_hypergraph`] for the role of `distribution`.
1480///
1481/// # Errors
1482///
1483/// Returns [`PageRankError`] for invalid configuration, personalization,
1484/// topology indexes, invalid weights, output length, scratch length, or
1485/// non-convergence.
1486///
1487/// # Performance
1488///
1489/// Performs no heap allocation after caller scratch has been provided. Each
1490/// iteration is `O(e + r + p · cost(D))`.
1491#[expect(
1492    clippy::too_many_arguments,
1493    reason = "hypergraph PageRank scratch entry point keeps policy and storage inputs explicit"
1494)]
1495#[expect(
1496    clippy::needless_pass_by_value,
1497    reason = "hypergraph PageRank scratch API consumes a scratch handle and keeps policy inputs explicit"
1498)]
1499pub fn pagerank_hypergraph_with_scratch<H, D, IE, IR, S>(
1500    hypergraph: &H,
1501    distribution: &D,
1502    elements: IE,
1503    relations: IR,
1504    config: PageRankConfig<S>,
1505    personalization: Option<&[S]>,
1506    element_ranks: &mut [S],
1507    relation_ranks: &mut [S],
1508    scratch: HypergraphPageRankScratch<'_, S>,
1509) -> Result<PageRankReport<S>, PageRankError<S>>
1510where
1511    H: PageRankHypergraph,
1512    D: HypergraphOutgoingDistribution<H, S>,
1513    IE: Clone + IntoIterator<Item = ElementId<H>>,
1514    IR: Clone + IntoIterator<Item = RelationId<H>>,
1515    S: PageRankScalar,
1516{
1517    validate_config(config)?;
1518    let e_bound = hypergraph.element_bound();
1519    let r_bound = hypergraph.relation_bound();
1520    let state_bound =
1521        checked_state_bound::<S>(e_bound, r_bound, element_ranks.len(), relation_ranks.len())?;
1522    ensure_scratch_len("teleport", scratch.teleport.len(), state_bound)?;
1523    ensure_scratch_len("next_elements", scratch.next_elements.len(), e_bound)?;
1524    ensure_scratch_len("next_relations", scratch.next_relations.len(), r_bound)?;
1525    ensure_scratch_len("visible_elements", scratch.visible_elements.len(), e_bound)?;
1526    ensure_scratch_len(
1527        "visible_relations",
1528        scratch.visible_relations.len(),
1529        r_bound,
1530    )?;
1531    build_hyper_personalization_into(
1532        hypergraph,
1533        elements.clone(),
1534        relations.clone(),
1535        state_bound,
1536        personalization,
1537        scratch.teleport,
1538        scratch.visible_elements,
1539        scratch.visible_relations,
1540    )?;
1541    initialize_hyper_ranks(
1542        hypergraph,
1543        elements.clone(),
1544        relations.clone(),
1545        scratch.teleport,
1546        element_ranks,
1547        relation_ranks,
1548    )?;
1549    iterate_hypergraph(
1550        hypergraph,
1551        distribution,
1552        elements,
1553        relations,
1554        config,
1555        scratch.teleport,
1556        scratch.visible_elements,
1557        scratch.visible_relations,
1558        element_ranks,
1559        relation_ranks,
1560        scratch.next_elements,
1561        scratch.next_relations,
1562    )
1563}
1564
1565/// Computes hypergraph `PageRank` under the supplied outgoing distribution
1566/// policy with a reusable owned workspace.
1567///
1568/// See [`pagerank_hypergraph`] for the role of `distribution`.
1569///
1570/// # Errors
1571///
1572/// Returns [`PageRankError`] for invalid configuration, personalization,
1573/// topology indexes, invalid weights, output length, or non-convergence.
1574///
1575/// # Performance
1576///
1577/// Grows workspace storage to the visible bounds if needed, then performs no
1578/// additional heap allocation. Each iteration is `O(e + r + p · cost(D))`.
1579#[cfg(feature = "alloc")]
1580#[expect(
1581    clippy::too_many_arguments,
1582    reason = "hypergraph PageRank workspace entry point keeps policy and storage inputs explicit"
1583)]
1584pub fn pagerank_hypergraph_with_workspace<H, D, IE, IR, S>(
1585    hypergraph: &H,
1586    distribution: &D,
1587    elements: IE,
1588    relations: IR,
1589    config: PageRankConfig<S>,
1590    personalization: Option<&[S]>,
1591    element_ranks: &mut [S],
1592    relation_ranks: &mut [S],
1593    workspace: &mut HypergraphPageRankWorkspace<H, S>,
1594) -> Result<PageRankReport<S>, PageRankError<S>>
1595where
1596    H: PageRankHypergraph,
1597    D: HypergraphOutgoingDistribution<H, S>,
1598    IE: Clone + IntoIterator<Item = ElementId<H>>,
1599    IR: Clone + IntoIterator<Item = RelationId<H>>,
1600    S: PageRankScalar,
1601{
1602    let e_bound = hypergraph.element_bound();
1603    let r_bound = hypergraph.relation_bound();
1604    let state_bound =
1605        checked_state_bound::<S>(e_bound, r_bound, element_ranks.len(), relation_ranks.len())?;
1606    workspace.ensure_bounds(e_bound, r_bound, state_bound);
1607    pagerank_hypergraph_with_scratch(
1608        hypergraph,
1609        distribution,
1610        elements,
1611        relations,
1612        config,
1613        personalization,
1614        element_ranks,
1615        relation_ranks,
1616        workspace.as_scratch(),
1617    )
1618}
1619
1620fn validate_config<S: PageRankScalar>(config: PageRankConfig<S>) -> Result<(), PageRankError<S>> {
1621    if !config.damping.is_finite() || config.damping < S::ZERO || config.damping > S::ONE {
1622        return Err(PageRankError::InvalidDamping {
1623            damping: config.damping,
1624        });
1625    }
1626    if !config.tolerance.is_finite() || config.tolerance < S::ZERO {
1627        return Err(PageRankError::InvalidTolerance {
1628            tolerance: config.tolerance,
1629        });
1630    }
1631    if config.max_iterations == 0 {
1632        return Err(PageRankError::InvalidMaxIterations);
1633    }
1634    Ok(())
1635}
1636
1637const fn ensure_output_len<S>(actual: usize, required: usize) -> Result<(), PageRankError<S>> {
1638    if actual < required {
1639        Err(PageRankError::OutputTooShort { required, actual })
1640    } else {
1641        Ok(())
1642    }
1643}
1644
1645const fn ensure_scratch_len<S>(
1646    name: &'static str,
1647    actual: usize,
1648    required: usize,
1649) -> Result<(), PageRankError<S>> {
1650    if actual < required {
1651        Err(PageRankError::ScratchTooShort {
1652            name,
1653            required,
1654            actual,
1655        })
1656    } else {
1657        Ok(())
1658    }
1659}
1660
1661fn checked_state_bound<S>(
1662    e_bound: usize,
1663    r_bound: usize,
1664    element_output_len: usize,
1665    relation_output_len: usize,
1666) -> Result<usize, PageRankError<S>> {
1667    ensure_output_len(element_output_len, e_bound)?;
1668    ensure_output_len(relation_output_len, r_bound)?;
1669    e_bound
1670        .checked_add(r_bound)
1671        .ok_or_else(|| PageRankError::OutputTooShort {
1672            required: usize::MAX,
1673            actual: element_output_len.saturating_add(relation_output_len),
1674        })
1675}
1676
1677fn clear<S: PageRankScalar>(values: &mut [S], len: usize) {
1678    for value in &mut values[..len] {
1679        *value = S::ZERO;
1680    }
1681}
1682
1683fn clear_u8(values: &mut [u8], len: usize) {
1684    for value in &mut values[..len] {
1685        *value = 0;
1686    }
1687}
1688
1689fn mark_visible_element<S>(visible: &mut [u8], index: usize) -> Result<(), PageRankError<S>> {
1690    if visible[index] != 0 {
1691        return Err(PageRankError::DuplicateElement { index });
1692    }
1693    visible[index] = 1;
1694    Ok(())
1695}
1696
1697fn mark_visible_relation<S>(visible: &mut [u8], index: usize) -> Result<(), PageRankError<S>> {
1698    if visible[index] != 0 {
1699        return Err(PageRankError::DuplicateRelation { index });
1700    }
1701    visible[index] = 1;
1702    Ok(())
1703}
1704
1705// `visible` slices are sized to the view's element/relation bound and every
1706// index is derived from `element_index`/`relation_index`, so direct indexing is
1707// in range — matching `mark_visible_element`/`mark_visible_relation`, which also
1708// index directly. This keeps a single "exactly-sized bitset" contract.
1709fn is_visible(visible: &[u8], index: usize) -> bool {
1710    visible[index] != 0
1711}
1712
1713#[expect(
1714    clippy::too_many_arguments,
1715    reason = "personalization normalization keeps topology family bounds and caller buffers explicit"
1716)]
1717fn build_personalization_into<I, F, S>(
1718    elements: I,
1719    bound: usize,
1720    personalization: Option<&[S]>,
1721    index_of: F,
1722    out: &mut [S],
1723    visible: &mut [u8],
1724) -> Result<(), PageRankError<S>>
1725where
1726    I: IntoIterator,
1727    F: Fn(I::Item) -> usize,
1728    S: PageRankScalar,
1729{
1730    clear(out, bound);
1731    clear_u8(visible, bound);
1732    let mut count = 0_usize;
1733    let mut sum = S::ZERO;
1734    if let Some(input) = personalization {
1735        if input.len() < bound {
1736            return Err(PageRankError::PersonalizationTooShort {
1737                required: bound,
1738                actual: input.len(),
1739            });
1740        }
1741        for element in elements {
1742            let index = index_of(element);
1743            check_index(index, bound)?;
1744            mark_visible_element(visible, index)?;
1745            let value = input[index];
1746            check_personalization_value(index, value)?;
1747            out[index] = value;
1748            sum += value;
1749            count += 1;
1750        }
1751    } else {
1752        for element in elements {
1753            let index = index_of(element);
1754            check_index(index, bound)?;
1755            mark_visible_element(visible, index)?;
1756            out[index] = S::ONE;
1757            sum += S::ONE;
1758            count += 1;
1759        }
1760    }
1761    normalize_personalization(out, count, sum)
1762}
1763
1764/// Personalization source used by [`PageRank`](crate) initialization.
1765///
1766/// Drives the per-visible-state initialization: either copy from a
1767/// caller-supplied vector ([`Self::FromInput`]) or fill every visible state
1768/// with `S::ONE` ([`Self::Uniform`]).
1769///
1770/// # Performance
1771///
1772/// `value_at` is `O(1)`. The fill methods walk visible elements and (for
1773/// hypergraphs) visible relations exactly once.
1774enum PersonalizationSource<'a, S> {
1775    /// Initialize every visible state slot to `S::ONE`.
1776    Uniform,
1777    /// Copy the value at the matching state index from the supplied slice.
1778    /// The slice must already be range-checked against the caller's state
1779    /// bound; [`Self::value_at`] only validates each value.
1780    FromInput(&'a [S]),
1781}
1782
1783impl<S> PersonalizationSource<'_, S>
1784where
1785    S: PageRankScalar,
1786{
1787    /// Returns the value to place at `state_index`.
1788    ///
1789    /// `FromInput` reads `input[state_index]` and rejects non-finite or
1790    /// negative entries via [`PageRankError::InvalidPersonalization`].
1791    /// `Uniform` returns `S::ONE` unconditionally.
1792    ///
1793    /// # Performance
1794    ///
1795    /// `O(1)`.
1796    fn value_at(&self, state_index: usize) -> Result<S, PageRankError<S>> {
1797        match *self {
1798            Self::Uniform => Ok(S::ONE),
1799            Self::FromInput(input) => {
1800                let value = input[state_index];
1801                check_personalization_value(state_index, value)?;
1802                Ok(value)
1803            }
1804        }
1805    }
1806
1807    /// Fills the hypergraph teleport vector at visible-element and
1808    /// visible-relation states.
1809    ///
1810    /// Returns `(count, sum)` for the normalization step. `state_bound` is
1811    /// the total length of `out` (`element_bound + relation_bound`); it is
1812    /// used to bounds-check `FromInput` slices before iteration.
1813    ///
1814    /// # Errors
1815    ///
1816    /// [`PageRankError::PersonalizationTooShort`] when a `FromInput` slice is
1817    /// shorter than `state_bound`;
1818    /// [`PageRankError::ElementIndexOutOfBounds`] /
1819    /// [`PageRankError::RelationIndexOutOfBounds`] when a visible element or
1820    /// relation maps outside its bound; [`PageRankError::DuplicateElement`] /
1821    /// [`PageRankError::DuplicateRelation`] when a state is marked visible
1822    /// twice; and [`PageRankError::InvalidPersonalization`] for a negative
1823    /// personalization entry.
1824    ///
1825    /// # Performance
1826    ///
1827    /// `O(|elements| + |relations|)` plus the index/visibility check cost
1828    /// per state slot.
1829    #[expect(
1830        clippy::too_many_arguments,
1831        reason = "method threads separate element/relation iterables, scratch slices, and the state bound; bundling them obscures the borrow shape that callers explicitly construct"
1832    )]
1833    fn fill_hypergraph<H, IE, IR>(
1834        &self,
1835        hypergraph: &H,
1836        elements: IE,
1837        relations: IR,
1838        state_bound: usize,
1839        out: &mut [S],
1840        visible_elements: &mut [u8],
1841        visible_relations: &mut [u8],
1842    ) -> Result<(usize, S), PageRankError<S>>
1843    where
1844        H: DenseElementIndex + DenseRelationIndex,
1845        IE: IntoIterator<Item = ElementId<H>>,
1846        IR: IntoIterator<Item = RelationId<H>>,
1847    {
1848        if let Self::FromInput(input) = *self
1849            && input.len() < state_bound
1850        {
1851            return Err(PageRankError::PersonalizationTooShort {
1852                required: state_bound,
1853                actual: input.len(),
1854            });
1855        }
1856        let e_bound = hypergraph.element_bound();
1857        let r_bound = hypergraph.relation_bound();
1858        let mut count = 0_usize;
1859        let mut sum = S::ZERO;
1860        for element in elements {
1861            let index = hypergraph.element_index(element);
1862            check_index(index, e_bound)?;
1863            mark_visible_element(visible_elements, index)?;
1864            let value = self.value_at(index)?;
1865            out[index] = value;
1866            sum += value;
1867            count += 1;
1868        }
1869        for relation in relations {
1870            let index = hypergraph.relation_index(relation);
1871            check_relation_index(index, r_bound)?;
1872            mark_visible_relation(visible_relations, index)?;
1873            let state = e_bound + index;
1874            let value = self.value_at(state)?;
1875            out[state] = value;
1876            sum += value;
1877            count += 1;
1878        }
1879        Ok((count, sum))
1880    }
1881}
1882
1883#[expect(
1884    clippy::too_many_arguments,
1885    reason = "helper threads separate element/relation state and caller scratch explicitly"
1886)]
1887fn build_hyper_personalization_into<H, IE, IR, S>(
1888    hypergraph: &H,
1889    elements: IE,
1890    relations: IR,
1891    state_bound: usize,
1892    personalization: Option<&[S]>,
1893    out: &mut [S],
1894    visible_elements: &mut [u8],
1895    visible_relations: &mut [u8],
1896) -> Result<(), PageRankError<S>>
1897where
1898    H: DenseElementIndex + DenseRelationIndex,
1899    IE: IntoIterator<Item = ElementId<H>>,
1900    IR: IntoIterator<Item = RelationId<H>>,
1901    S: PageRankScalar,
1902{
1903    clear(out, state_bound);
1904    clear_u8(visible_elements, hypergraph.element_bound());
1905    clear_u8(visible_relations, hypergraph.relation_bound());
1906    let source = personalization.map_or(
1907        PersonalizationSource::Uniform,
1908        PersonalizationSource::FromInput,
1909    );
1910    let (count, sum) = source.fill_hypergraph(
1911        hypergraph,
1912        elements,
1913        relations,
1914        state_bound,
1915        out,
1916        visible_elements,
1917        visible_relations,
1918    )?;
1919    normalize_personalization(out, count, sum)
1920}
1921
1922fn normalize_personalization<S: PageRankScalar>(
1923    out: &mut [S],
1924    count: usize,
1925    sum: S,
1926) -> Result<(), PageRankError<S>> {
1927    if count == 0 {
1928        return Err(PageRankError::EmptyState);
1929    }
1930    if sum <= S::ZERO {
1931        return Err(PageRankError::ZeroPersonalization);
1932    }
1933    for value in out {
1934        *value = *value / sum;
1935    }
1936    Ok(())
1937}
1938
1939fn check_personalization_value<S: PageRankScalar>(
1940    index: usize,
1941    value: S,
1942) -> Result<(), PageRankError<S>> {
1943    if !value.is_finite() || value < S::ZERO {
1944        Err(PageRankError::InvalidPersonalization { index, value })
1945    } else {
1946        Ok(())
1947    }
1948}
1949
1950fn initialize_ranks<G, I, S>(
1951    elements: I,
1952    graph: &G,
1953    teleport: &[S],
1954    ranks: &mut [S],
1955) -> Result<(), PageRankError<S>>
1956where
1957    G: DenseElementIndex,
1958    I: IntoIterator<Item = ElementId<G>>,
1959    S: PageRankScalar,
1960{
1961    clear(ranks, graph.element_bound());
1962    for element in elements {
1963        let index = graph.element_index(element);
1964        check_index(index, graph.element_bound())?;
1965        ranks[index] = teleport[index];
1966    }
1967    Ok(())
1968}
1969
1970#[expect(
1971    clippy::too_many_arguments,
1972    reason = "initialization writes separate element and relation rank slices"
1973)]
1974fn initialize_hyper_ranks<H, IE, IR, S>(
1975    hypergraph: &H,
1976    elements: IE,
1977    relations: IR,
1978    teleport: &[S],
1979    element_ranks: &mut [S],
1980    relation_ranks: &mut [S],
1981) -> Result<(), PageRankError<S>>
1982where
1983    H: DenseElementIndex + DenseRelationIndex,
1984    IE: IntoIterator<Item = ElementId<H>>,
1985    IR: IntoIterator<Item = RelationId<H>>,
1986    S: PageRankScalar,
1987{
1988    clear(element_ranks, hypergraph.element_bound());
1989    clear(relation_ranks, hypergraph.relation_bound());
1990    for element in elements {
1991        let index = hypergraph.element_index(element);
1992        check_index(index, hypergraph.element_bound())?;
1993        element_ranks[index] = teleport[index];
1994    }
1995    for relation in relations {
1996        let index = hypergraph.relation_index(relation);
1997        check_relation_index(index, hypergraph.relation_bound())?;
1998        relation_ranks[index] = teleport[hypergraph.element_bound() + index];
1999    }
2000    Ok(())
2001}
2002
2003#[expect(
2004    clippy::too_many_arguments,
2005    reason = "graph iteration helper keeps distribution, scratch, and policy inputs explicit"
2006)]
2007#[expect(
2008    clippy::needless_pass_by_value,
2009    reason = "iteration helpers own cloneable iterator values and clone them each power iteration"
2010)]
2011fn iterate_graph<G, D, I, S>(
2012    graph: &G,
2013    distribution: &D,
2014    elements: I,
2015    config: PageRankConfig<S>,
2016    teleport: &[S],
2017    visible: &[u8],
2018    ranks: &mut [S],
2019    next: &mut [S],
2020) -> Result<PageRankReport<S>, PageRankError<S>>
2021where
2022    G: ForwardGraph + DenseElementIndex,
2023    D: OutgoingDistribution<G, S>,
2024    I: Clone + IntoIterator<Item = ElementId<G>>,
2025    S: PageRankScalar,
2026{
2027    let mut last_delta = S::INFINITY;
2028    for iteration in 1..=config.max_iterations {
2029        clear(next, graph.element_bound());
2030        let mut dangling = S::ZERO;
2031        for element in elements.clone() {
2032            let index = checked_element_index(graph, element)?;
2033            let rank = ranks[index];
2034            dangling += distribution.distribute_outgoing(graph, element, rank, next, visible)?;
2035        }
2036        let delta = apply_graph_teleport(
2037            graph,
2038            elements.clone(),
2039            config,
2040            teleport,
2041            dangling,
2042            ranks,
2043            next,
2044        )?;
2045        last_delta = delta;
2046        if delta <= config.tolerance {
2047            return Ok(PageRankReport {
2048                iterations: iteration,
2049                delta,
2050            });
2051        }
2052    }
2053    Err(PageRankError::NonConverged {
2054        iterations: config.max_iterations,
2055        delta: last_delta,
2056    })
2057}
2058
2059#[expect(
2060    clippy::too_many_arguments,
2061    reason = "hypergraph iteration helper keeps distribution, state families, scratch, and policy inputs explicit"
2062)]
2063#[expect(
2064    clippy::needless_pass_by_value,
2065    reason = "iteration helpers own cloneable iterator values and clone them each power iteration"
2066)]
2067fn iterate_hypergraph<H, D, IE, IR, S>(
2068    hypergraph: &H,
2069    distribution: &D,
2070    elements: IE,
2071    relations: IR,
2072    config: PageRankConfig<S>,
2073    teleport: &[S],
2074    visible_elements: &[u8],
2075    visible_relations: &[u8],
2076    element_ranks: &mut [S],
2077    relation_ranks: &mut [S],
2078    next_elements: &mut [S],
2079    next_relations: &mut [S],
2080) -> Result<PageRankReport<S>, PageRankError<S>>
2081where
2082    H: PageRankHypergraph,
2083    D: HypergraphOutgoingDistribution<H, S>,
2084    IE: Clone + IntoIterator<Item = ElementId<H>>,
2085    IR: Clone + IntoIterator<Item = RelationId<H>>,
2086    S: PageRankScalar,
2087{
2088    let mut last_delta = S::INFINITY;
2089    for iteration in 1..=config.max_iterations {
2090        clear(next_elements, hypergraph.element_bound());
2091        clear(next_relations, hypergraph.relation_bound());
2092        let mut dangling = S::ZERO;
2093        for element in elements.clone() {
2094            let index = checked_element_index(hypergraph, element)?;
2095            let rank = element_ranks[index];
2096            dangling += distribution.distribute_from_element(
2097                hypergraph,
2098                element,
2099                rank,
2100                next_relations,
2101                visible_relations,
2102            )?;
2103        }
2104        for relation in relations.clone() {
2105            let index = checked_relation_index_for(hypergraph, relation)?;
2106            let rank = relation_ranks[index];
2107            dangling += distribution.distribute_from_relation(
2108                hypergraph,
2109                relation,
2110                rank,
2111                next_elements,
2112                visible_elements,
2113            )?;
2114        }
2115        let delta = apply_hyper_teleport(
2116            hypergraph,
2117            elements.clone(),
2118            relations.clone(),
2119            config,
2120            teleport,
2121            dangling,
2122            element_ranks,
2123            relation_ranks,
2124            next_elements,
2125            next_relations,
2126        )?;
2127        last_delta = delta;
2128        if delta <= config.tolerance {
2129            return Ok(PageRankReport {
2130                iterations: iteration,
2131                delta,
2132            });
2133        }
2134    }
2135    Err(PageRankError::NonConverged {
2136        iterations: config.max_iterations,
2137        delta: last_delta,
2138    })
2139}
2140
2141fn checked_element_index<G: DenseElementIndex, S>(
2142    graph: &G,
2143    element: ElementId<G>,
2144) -> Result<usize, PageRankError<S>> {
2145    let index = graph.element_index(element);
2146    check_index(index, graph.element_bound())?;
2147    Ok(index)
2148}
2149
2150const fn check_index<S>(index: usize, bound: usize) -> Result<(), PageRankError<S>> {
2151    if index < bound {
2152        Ok(())
2153    } else {
2154        Err(PageRankError::ElementIndexOutOfBounds { index, bound })
2155    }
2156}
2157
2158const fn check_relation_index<S>(index: usize, bound: usize) -> Result<(), PageRankError<S>> {
2159    if index < bound {
2160        Ok(())
2161    } else {
2162        Err(PageRankError::RelationIndexOutOfBounds { index, bound })
2163    }
2164}
2165
2166fn checked_relation_weight<G, W, S>(
2167    graph: &G,
2168    weights: &W,
2169    relation: RelationId<G>,
2170) -> Result<S, PageRankError<S>>
2171where
2172    G: DenseRelationIndex,
2173    W: RelationWeight<ElementId = G::ElementId, RelationId = G::RelationId>,
2174    W::Weight: IntoPageRankScalar<S>,
2175    S: PageRankScalar,
2176{
2177    let index = graph.relation_index(relation);
2178    check_relation_index(index, graph.relation_bound())?;
2179    let value = weights.relation_weight(relation).into_pagerank_scalar();
2180    if !value.is_finite() || value < S::ZERO {
2181        Err(PageRankError::InvalidRelationWeight { index, value })
2182    } else {
2183        Ok(value)
2184    }
2185}
2186
2187fn checked_incidence_weight<H, W, S>(
2188    hypergraph: &H,
2189    weights: &W,
2190    incidence: H::IncidenceId,
2191) -> Result<S, PageRankError<S>>
2192where
2193    H: DenseIncidenceIndex,
2194    W: IncidenceWeight<
2195            ElementId = H::ElementId,
2196            RelationId = H::RelationId,
2197            IncidenceId = H::IncidenceId,
2198        >,
2199    W::Weight: IntoPageRankScalar<S>,
2200    S: PageRankScalar,
2201{
2202    let index = hypergraph.incidence_index(incidence);
2203    let bound = hypergraph.incidence_bound();
2204    if index >= bound {
2205        return Err(PageRankError::IncidenceIndexOutOfBounds { index, bound });
2206    }
2207    let value = weights.incidence_weight(incidence).into_pagerank_scalar();
2208    if !value.is_finite() || value < S::ZERO {
2209        Err(PageRankError::InvalidIncidenceWeight { index, value })
2210    } else {
2211        Ok(value)
2212    }
2213}
2214
2215fn outgoing_weight_total<G, W, S>(
2216    graph: &G,
2217    weights: &W,
2218    element: ElementId<G>,
2219    visible: &[u8],
2220) -> Result<S, PageRankError<S>>
2221where
2222    G: ForwardGraph + DenseElementIndex + DenseRelationIndex,
2223    W: RelationWeight<ElementId = G::ElementId, RelationId = G::RelationId>,
2224    W::Weight: IntoPageRankScalar<S>,
2225    S: PageRankScalar,
2226{
2227    let mut total = S::ZERO;
2228    for edge in graph.outgoing_edges(element) {
2229        let target = graph.target(edge);
2230        let target_index = checked_element_index(graph, target)?;
2231        if is_visible(visible, target_index) {
2232            total += checked_relation_weight(graph, weights, edge)?;
2233        }
2234    }
2235    Ok(total)
2236}
2237
2238/// Computes the teleport-blended rank for one state and its absolute delta.
2239///
2240/// Shared by the graph and both hypergraph state legs so the convergence
2241/// formula `damping * accumulated + teleport_scale * teleport` lives in exactly
2242/// one place. Returns `(new_value, |new_value - previous|)`; the caller writes
2243/// `new_value` into the rank slice (the `next`/accumulator slice is cleared at
2244/// the top of the following iteration, so it is intentionally not written back).
2245///
2246/// # Performance
2247///
2248/// This function is `O(1)`.
2249fn teleport_update<S: PageRankScalar>(
2250    damping: S,
2251    teleport_scale: S,
2252    accumulated: S,
2253    teleport: S,
2254    previous: S,
2255) -> (S, S) {
2256    let value = (damping * accumulated) + (teleport_scale * teleport);
2257    let delta = (value - previous).abs();
2258    (value, delta)
2259}
2260
2261#[expect(
2262    clippy::too_many_arguments,
2263    reason = "teleport helper updates caller-provided rank and scratch slices"
2264)]
2265fn apply_graph_teleport<G, I, S>(
2266    graph: &G,
2267    elements: I,
2268    config: PageRankConfig<S>,
2269    teleport: &[S],
2270    dangling: S,
2271    ranks: &mut [S],
2272    next: &[S],
2273) -> Result<S, PageRankError<S>>
2274where
2275    G: DenseElementIndex,
2276    I: IntoIterator<Item = ElementId<G>>,
2277    S: PageRankScalar,
2278{
2279    let mut delta = S::ZERO;
2280    let teleport_scale = (S::ONE - config.damping) + (config.damping * dangling);
2281    for element in elements {
2282        let index = checked_element_index(graph, element)?;
2283        let (value, state_delta) = teleport_update(
2284            config.damping,
2285            teleport_scale,
2286            next[index],
2287            teleport[index],
2288            ranks[index],
2289        );
2290        delta += state_delta;
2291        ranks[index] = value;
2292    }
2293    Ok(delta)
2294}
2295
2296fn checked_relation_index_for<H: DenseRelationIndex, S>(
2297    hypergraph: &H,
2298    relation: RelationId<H>,
2299) -> Result<usize, PageRankError<S>> {
2300    let index = hypergraph.relation_index(relation);
2301    check_relation_index(index, hypergraph.relation_bound())?;
2302    Ok(index)
2303}
2304
2305fn hyper_outgoing_relation_weight<H, W, S>(
2306    hypergraph: &H,
2307    weights: &W,
2308    element: ElementId<H>,
2309    visible_relations: &[u8],
2310) -> Result<S, PageRankError<S>>
2311where
2312    H: DirectedVertexHyperedges + DenseRelationIndex,
2313    W: RelationWeight<ElementId = H::ElementId, RelationId = H::RelationId>,
2314    W::Weight: IntoPageRankScalar<S>,
2315    S: PageRankScalar,
2316{
2317    let mut total = S::ZERO;
2318    for relation in hypergraph.outgoing_hyperedges(element) {
2319        let relation_index = checked_relation_index_for(hypergraph, relation)?;
2320        if is_visible(visible_relations, relation_index) {
2321            total += checked_relation_weight(hypergraph, weights, relation)?;
2322        }
2323    }
2324    Ok(total)
2325}
2326
2327fn hyper_target_incidence_weight<H, W, S>(
2328    hypergraph: &H,
2329    weights: &W,
2330    relation: RelationId<H>,
2331    visible_elements: &[u8],
2332) -> Result<S, PageRankError<S>>
2333where
2334    H: DirectedHyperedgeIncidences + IncidenceElement + DenseElementIndex + DenseIncidenceIndex,
2335    W: IncidenceWeight<
2336            ElementId = H::ElementId,
2337            RelationId = H::RelationId,
2338            IncidenceId = H::IncidenceId,
2339        >,
2340    W::Weight: IntoPageRankScalar<S>,
2341    S: PageRankScalar,
2342{
2343    let mut total = S::ZERO;
2344    for incidence in hypergraph.target_incidences(relation) {
2345        let target = hypergraph.incidence_element(incidence);
2346        let target_index = checked_element_index(hypergraph, target)?;
2347        if is_visible(visible_elements, target_index) {
2348            total += checked_incidence_weight(hypergraph, weights, incidence)?;
2349        }
2350    }
2351    Ok(total)
2352}
2353
2354#[expect(
2355    clippy::too_many_arguments,
2356    reason = "hypergraph teleport updates separate element and relation slices"
2357)]
2358fn apply_hyper_teleport<H, IE, IR, S>(
2359    hypergraph: &H,
2360    elements: IE,
2361    relations: IR,
2362    config: PageRankConfig<S>,
2363    teleport: &[S],
2364    dangling: S,
2365    element_ranks: &mut [S],
2366    relation_ranks: &mut [S],
2367    next_elements: &[S],
2368    next_relations: &[S],
2369) -> Result<S, PageRankError<S>>
2370where
2371    H: DenseElementIndex + DenseRelationIndex,
2372    IE: IntoIterator<Item = ElementId<H>>,
2373    IR: IntoIterator<Item = RelationId<H>>,
2374    S: PageRankScalar,
2375{
2376    let mut delta = S::ZERO;
2377    let e_bound = hypergraph.element_bound();
2378    let teleport_scale = (S::ONE - config.damping) + (config.damping * dangling);
2379    for element in elements {
2380        let index = checked_element_index(hypergraph, element)?;
2381        let (value, state_delta) = teleport_update(
2382            config.damping,
2383            teleport_scale,
2384            next_elements[index],
2385            teleport[index],
2386            element_ranks[index],
2387        );
2388        delta += state_delta;
2389        element_ranks[index] = value;
2390    }
2391    for relation in relations {
2392        let index = checked_relation_index_for(hypergraph, relation)?;
2393        let state = e_bound + index;
2394        let (value, state_delta) = teleport_update(
2395            config.damping,
2396            teleport_scale,
2397            next_relations[index],
2398            teleport[state],
2399            relation_ranks[index],
2400        );
2401        delta += state_delta;
2402        relation_ranks[index] = value;
2403    }
2404    Ok(delta)
2405}