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
678/// Successful `PageRank` convergence report.
679///
680/// # Performance
681///
682/// Copying and debug-formatting are `O(1)`.
683#[derive(Clone, Copy, Debug, PartialEq)]
684#[non_exhaustive]
685pub struct PageRankReport<S> {
686    /// Number of iterations executed.
687    pub iterations: usize,
688    /// Final L1 rank delta.
689    pub delta: S,
690}
691
692/// `PageRank` input, numeric, scratch, and convergence errors.
693///
694/// # Performance
695///
696/// Formatting is `O(message length)`.
697#[derive(Debug, Clone, PartialEq)]
698#[non_exhaustive]
699pub enum PageRankError<S> {
700    /// `PageRank` is undefined for an empty visible state set.
701    EmptyState,
702    /// Damping must be finite and in `[0, 1]`.
703    InvalidDamping {
704        /// Invalid damping value.
705        damping: S,
706    },
707    /// Tolerance must be finite and non-negative.
708    InvalidTolerance {
709        /// Invalid tolerance value.
710        tolerance: S,
711    },
712    /// At least one iteration is required.
713    InvalidMaxIterations,
714    /// Output rank storage was shorter than the topology index bound.
715    OutputTooShort {
716        /// Required length.
717        required: usize,
718        /// Actual length.
719        actual: usize,
720    },
721    /// Scratch storage was shorter than the required bound.
722    ScratchTooShort {
723        /// Scratch slice name.
724        name: &'static str,
725        /// Required length.
726        required: usize,
727        /// Actual length.
728        actual: usize,
729    },
730    /// Personalization storage was shorter than the topology index bound.
731    PersonalizationTooShort {
732        /// Required length.
733        required: usize,
734        /// Actual length.
735        actual: usize,
736    },
737    /// A personalization entry was negative or non-finite.
738    InvalidPersonalization {
739        /// Invalid index.
740        index: usize,
741        /// Invalid value.
742        value: S,
743    },
744    /// Personalization sum was zero over visible states.
745    ZeroPersonalization,
746    /// A topology element mapped outside the advertised element bound.
747    ElementIndexOutOfBounds {
748        /// Invalid index.
749        index: usize,
750        /// Advertised bound.
751        bound: usize,
752    },
753    /// A visible element was provided more than once.
754    DuplicateElement {
755        /// Duplicate dense element index.
756        index: usize,
757    },
758    /// A visible relation was provided more than once.
759    DuplicateRelation {
760        /// Duplicate dense relation index.
761        index: usize,
762    },
763    /// A topology relation mapped outside the advertised relation bound.
764    RelationIndexOutOfBounds {
765        /// Invalid index.
766        index: usize,
767        /// Advertised bound.
768        bound: usize,
769    },
770    /// A topology incidence mapped outside the advertised incidence bound.
771    IncidenceIndexOutOfBounds {
772        /// Invalid index.
773        index: usize,
774        /// Advertised bound.
775        bound: usize,
776    },
777    /// A relation weight was negative or non-finite.
778    InvalidRelationWeight {
779        /// Dense relation index.
780        index: usize,
781        /// Invalid value.
782        value: S,
783    },
784    /// An incidence weight was negative or non-finite.
785    InvalidIncidenceWeight {
786        /// Dense incidence index.
787        index: usize,
788        /// Invalid value.
789        value: S,
790    },
791    /// Power iteration reached the maximum iteration count before convergence.
792    NonConverged {
793        /// Iterations executed.
794        iterations: usize,
795        /// Final L1 delta.
796        delta: S,
797    },
798}
799
800impl<S: fmt::Debug> fmt::Display for PageRankError<S> {
801    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
802        match self {
803            Self::EmptyState => formatter.write_str("pagerank state set is empty"),
804            Self::InvalidDamping { damping } => {
805                write!(formatter, "invalid pagerank damping {damping:?}")
806            }
807            Self::InvalidTolerance { tolerance } => {
808                write!(formatter, "invalid pagerank tolerance {tolerance:?}")
809            }
810            Self::InvalidMaxIterations => {
811                formatter.write_str("pagerank max_iterations must be positive")
812            }
813            Self::OutputTooShort { required, actual } => write!(
814                formatter,
815                "pagerank output too short: required {required}, got {actual}"
816            ),
817            Self::ScratchTooShort {
818                name,
819                required,
820                actual,
821            } => write!(
822                formatter,
823                "pagerank scratch '{name}' too short: required {required}, got {actual}"
824            ),
825            Self::PersonalizationTooShort { required, actual } => write!(
826                formatter,
827                "pagerank personalization too short: required {required}, got {actual}"
828            ),
829            Self::InvalidPersonalization { index, value } => write!(
830                formatter,
831                "invalid pagerank personalization at {index}: {value:?}"
832            ),
833            Self::ZeroPersonalization => {
834                formatter.write_str("pagerank personalization sum is zero")
835            }
836            Self::ElementIndexOutOfBounds { index, bound } => {
837                write!(formatter, "element index {index} is outside bound {bound}")
838            }
839            Self::DuplicateElement { index } => {
840                write!(formatter, "duplicate pagerank element index {index}")
841            }
842            Self::DuplicateRelation { index } => {
843                write!(formatter, "duplicate pagerank relation index {index}")
844            }
845            Self::RelationIndexOutOfBounds { index, bound } => {
846                write!(formatter, "relation index {index} is outside bound {bound}")
847            }
848            Self::IncidenceIndexOutOfBounds { index, bound } => {
849                write!(
850                    formatter,
851                    "incidence index {index} is outside bound {bound}"
852                )
853            }
854            Self::InvalidRelationWeight { index, value } => {
855                write!(formatter, "invalid relation weight at {index}: {value:?}")
856            }
857            Self::InvalidIncidenceWeight { index, value } => {
858                write!(formatter, "invalid incidence weight at {index}: {value:?}")
859            }
860            Self::NonConverged { iterations, delta } => write!(
861                formatter,
862                "pagerank did not converge after {iterations} iterations; delta {delta:?}"
863            ),
864        }
865    }
866}
867
868impl<S: fmt::Debug> Error for PageRankError<S> {}
869
870/// Borrowed scratch storage for ordinary graph `PageRank`.
871///
872/// # Performance
873///
874/// Construction is `O(1)`. The slices must be at least `graph.element_bound()`
875/// long for the graph passed to a scratch API.
876#[derive(Debug)]
877#[must_use]
878pub struct PageRankScratch<'scratch, S> {
879    /// Teleport/personalization scratch by element index.
880    teleport: &'scratch mut [S],
881    /// Next-rank scratch by element index.
882    next: &'scratch mut [S],
883    /// Visible element bitset by element index.
884    visible_elements: &'scratch mut [u8],
885}
886
887impl<'scratch, S> PageRankScratch<'scratch, S> {
888    /// Constructs borrowed graph `PageRank` scratch from caller-owned slices.
889    ///
890    /// # Performance
891    ///
892    /// This function is `O(1)`.
893    pub const fn new(
894        teleport: &'scratch mut [S],
895        next: &'scratch mut [S],
896        visible_elements: &'scratch mut [u8],
897    ) -> Self {
898        Self {
899            teleport,
900            next,
901            visible_elements,
902        }
903    }
904
905    /// Returns current teleport scratch capacity.
906    ///
907    /// # Performance
908    ///
909    /// This function is `O(1)`.
910    #[must_use]
911    pub const fn teleport_capacity(&self) -> usize {
912        self.teleport.len()
913    }
914
915    /// Returns current next-rank scratch capacity.
916    ///
917    /// # Performance
918    ///
919    /// This function is `O(1)`.
920    #[must_use]
921    pub const fn next_capacity(&self) -> usize {
922        self.next.len()
923    }
924
925    /// Returns current visible-element scratch capacity.
926    ///
927    /// # Performance
928    ///
929    /// This function is `O(1)`.
930    #[must_use]
931    pub const fn visible_element_capacity(&self) -> usize {
932        self.visible_elements.len()
933    }
934}
935
936/// Borrowed scratch storage for incidence/bipartite hypergraph `PageRank`.
937///
938/// # Performance
939///
940/// Construction is `O(1)`. `teleport` must cover `element_bound + relation_bound`,
941/// while `next_elements` and `next_relations` cover their respective bounds.
942#[derive(Debug)]
943#[must_use]
944pub struct HypergraphPageRankScratch<'scratch, S> {
945    /// Teleport/personalization scratch by combined element+relation state index.
946    teleport: &'scratch mut [S],
947    /// Next element ranks by element index.
948    next_elements: &'scratch mut [S],
949    /// Next relation ranks by relation index.
950    next_relations: &'scratch mut [S],
951    /// Visible element bitset by element index.
952    visible_elements: &'scratch mut [u8],
953    /// Visible relation bitset by relation index.
954    visible_relations: &'scratch mut [u8],
955}
956
957impl<'scratch, S> HypergraphPageRankScratch<'scratch, S> {
958    /// Constructs borrowed hypergraph `PageRank` scratch from caller-owned slices.
959    ///
960    /// # Performance
961    ///
962    /// This function is `O(1)`.
963    pub const fn new(
964        teleport: &'scratch mut [S],
965        next_elements: &'scratch mut [S],
966        next_relations: &'scratch mut [S],
967        visible_elements: &'scratch mut [u8],
968        visible_relations: &'scratch mut [u8],
969    ) -> Self {
970        Self {
971            teleport,
972            next_elements,
973            next_relations,
974            visible_elements,
975            visible_relations,
976        }
977    }
978
979    /// Returns current teleport scratch capacity.
980    ///
981    /// # Performance
982    ///
983    /// This function is `O(1)`.
984    #[must_use]
985    pub const fn teleport_capacity(&self) -> usize {
986        self.teleport.len()
987    }
988}
989
990/// Owned reusable workspace for ordinary graph `PageRank`.
991///
992/// The `G` parameter brands the workspace to a view type, mirroring
993/// [`crate::BfsWorkspace`]. The scalar `S` fixes the rank/storage scalar.
994///
995/// # Performance
996///
997/// Memory usage is `O(b)` for the largest element bound used with the workspace.
998#[cfg(feature = "alloc")]
999#[derive(Clone, Debug)]
1000pub struct PageRankWorkspace<G, S> {
1001    /// Teleport/personalization scratch.
1002    teleport: Vec<S>,
1003    /// Next-rank scratch.
1004    next: Vec<S>,
1005    /// Visible element bitset.
1006    visible_elements: Vec<u8>,
1007    /// Brands workspace storage to a topology view type without owning the view.
1008    _graph: PhantomData<fn() -> G>,
1009}
1010
1011#[cfg(feature = "alloc")]
1012impl<G, S: PageRankScalar> Default for PageRankWorkspace<G, S> {
1013    fn default() -> Self {
1014        Self::new()
1015    }
1016}
1017
1018#[cfg(feature = "alloc")]
1019impl<G, S: PageRankScalar> PageRankWorkspace<G, S> {
1020    /// Creates an empty reusable `PageRank` workspace.
1021    ///
1022    /// # Performance
1023    ///
1024    /// This function is `O(1)` and performs no allocation.
1025    #[must_use]
1026    pub const fn new() -> Self {
1027        Self {
1028            teleport: Vec::new(),
1029            next: Vec::new(),
1030            visible_elements: Vec::new(),
1031            _graph: PhantomData,
1032        }
1033    }
1034
1035    /// Creates a workspace sized for `graph.element_bound()`.
1036    ///
1037    /// # Performance
1038    ///
1039    /// Allocates and initializes `O(graph.element_bound())` storage.
1040    #[must_use]
1041    pub fn for_graph(graph: &G) -> Self
1042    where
1043        G: DenseElementIndex,
1044    {
1045        Self::with_element_bound(graph.element_bound())
1046    }
1047
1048    /// Creates a workspace with capacity for `element_bound` element states.
1049    ///
1050    /// # Performance
1051    ///
1052    /// Allocates and initializes `O(element_bound)` storage.
1053    #[must_use]
1054    pub fn with_element_bound(element_bound: usize) -> Self {
1055        Self {
1056            teleport: vec![S::ZERO; element_bound],
1057            next: vec![S::ZERO; element_bound],
1058            visible_elements: vec![0; element_bound],
1059            _graph: PhantomData,
1060        }
1061    }
1062
1063    /// Returns the element-bound capacity currently available without growth.
1064    ///
1065    /// # Performance
1066    ///
1067    /// This function is `O(1)`.
1068    #[must_use]
1069    pub const fn element_bound_capacity(&self) -> usize {
1070        self.teleport.len()
1071    }
1072
1073    /// Ensures workspace storage covers `element_bound`.
1074    fn ensure_element_bound(&mut self, element_bound: usize) {
1075        if self.teleport.len() < element_bound {
1076            self.teleport.resize(element_bound, S::ZERO);
1077        }
1078        if self.next.len() < element_bound {
1079            self.next.resize(element_bound, S::ZERO);
1080        }
1081        if self.visible_elements.len() < element_bound {
1082            self.visible_elements.resize(element_bound, 0);
1083        }
1084    }
1085
1086    /// Borrows this workspace as scratch.
1087    fn as_scratch(&mut self) -> PageRankScratch<'_, S> {
1088        PageRankScratch::new(
1089            &mut self.teleport,
1090            &mut self.next,
1091            &mut self.visible_elements,
1092        )
1093    }
1094}
1095
1096/// Owned reusable workspace for incidence/bipartite hypergraph `PageRank`.
1097///
1098/// # Performance
1099///
1100/// Memory usage is `O(e + r)` for the largest element and relation bounds used.
1101#[cfg(feature = "alloc")]
1102#[derive(Clone, Debug)]
1103pub struct HypergraphPageRankWorkspace<H, S> {
1104    /// Combined element+relation teleport/personalization scratch.
1105    teleport: Vec<S>,
1106    /// Next element ranks.
1107    next_elements: Vec<S>,
1108    /// Next relation ranks.
1109    next_relations: Vec<S>,
1110    /// Visible element bitset.
1111    visible_elements: Vec<u8>,
1112    /// Visible relation bitset.
1113    visible_relations: Vec<u8>,
1114    /// Brands workspace storage to a hypergraph view type without owning the view.
1115    _hypergraph: PhantomData<fn() -> H>,
1116}
1117
1118#[cfg(feature = "alloc")]
1119impl<H, S: PageRankScalar> Default for HypergraphPageRankWorkspace<H, S> {
1120    fn default() -> Self {
1121        Self::new()
1122    }
1123}
1124
1125#[cfg(feature = "alloc")]
1126impl<H, S: PageRankScalar> HypergraphPageRankWorkspace<H, S> {
1127    /// Creates an empty reusable hypergraph `PageRank` workspace.
1128    ///
1129    /// # Performance
1130    ///
1131    /// This function is `O(1)` and performs no allocation.
1132    #[must_use]
1133    pub const fn new() -> Self {
1134        Self {
1135            teleport: Vec::new(),
1136            next_elements: Vec::new(),
1137            next_relations: Vec::new(),
1138            visible_elements: Vec::new(),
1139            visible_relations: Vec::new(),
1140            _hypergraph: PhantomData,
1141        }
1142    }
1143
1144    /// Creates a workspace sized for a hypergraph's element/relation bounds.
1145    ///
1146    /// # Performance
1147    ///
1148    /// Allocates and initializes `O(element_bound + relation_bound)` storage.
1149    #[must_use]
1150    pub fn for_hypergraph(hypergraph: &H) -> Self
1151    where
1152        H: DenseElementIndex + DenseRelationIndex,
1153    {
1154        Self::with_bounds(hypergraph.element_bound(), hypergraph.relation_bound())
1155    }
1156
1157    /// Creates a workspace with capacity for element and relation bounds.
1158    ///
1159    /// # Performance
1160    ///
1161    /// Allocates and initializes `O(element_bound + relation_bound)` storage.
1162    #[must_use]
1163    pub fn with_bounds(element_bound: usize, relation_bound: usize) -> Self {
1164        let state_bound = element_bound.saturating_add(relation_bound);
1165        Self {
1166            teleport: vec![S::ZERO; state_bound],
1167            next_elements: vec![S::ZERO; element_bound],
1168            next_relations: vec![S::ZERO; relation_bound],
1169            visible_elements: vec![0; element_bound],
1170            visible_relations: vec![0; relation_bound],
1171            _hypergraph: PhantomData,
1172        }
1173    }
1174
1175    /// Returns current element-rank capacity.
1176    ///
1177    /// # Performance
1178    ///
1179    /// This function is `O(1)`.
1180    #[must_use]
1181    pub const fn element_bound_capacity(&self) -> usize {
1182        self.next_elements.len()
1183    }
1184
1185    /// Returns current relation-rank capacity.
1186    ///
1187    /// # Performance
1188    ///
1189    /// This function is `O(1)`.
1190    #[must_use]
1191    pub const fn relation_bound_capacity(&self) -> usize {
1192        self.next_relations.len()
1193    }
1194
1195    /// Ensures workspace storage covers the requested bounds.
1196    fn ensure_bounds(&mut self, element_bound: usize, relation_bound: usize, state_bound: usize) {
1197        if self.teleport.len() < state_bound {
1198            self.teleport.resize(state_bound, S::ZERO);
1199        }
1200        if self.next_elements.len() < element_bound {
1201            self.next_elements.resize(element_bound, S::ZERO);
1202        }
1203        if self.next_relations.len() < relation_bound {
1204            self.next_relations.resize(relation_bound, S::ZERO);
1205        }
1206        if self.visible_elements.len() < element_bound {
1207            self.visible_elements.resize(element_bound, 0);
1208        }
1209        if self.visible_relations.len() < relation_bound {
1210            self.visible_relations.resize(relation_bound, 0);
1211        }
1212    }
1213
1214    /// Borrows this workspace as hypergraph scratch.
1215    fn as_scratch(&mut self) -> HypergraphPageRankScratch<'_, S> {
1216        HypergraphPageRankScratch::new(
1217            &mut self.teleport,
1218            &mut self.next_elements,
1219            &mut self.next_relations,
1220            &mut self.visible_elements,
1221            &mut self.visible_relations,
1222        )
1223    }
1224}
1225
1226/// Computes ordinary directed graph `PageRank` with the supplied outgoing
1227/// distribution policy, allocating temporary scratch.
1228///
1229/// `distribution` selects the per-edge rank-transfer rule:
1230/// [`Uniform`] reproduces the textbook unweighted `PageRank` (every parallel
1231/// outgoing edge carries a unit transition weight), while [`Weighted`]
1232/// reads a caller-supplied edge weight. Any [`OutgoingDistribution`] impl
1233/// is accepted. `elements` defines the visible state iteration order.
1234///
1235/// # Errors
1236///
1237/// Returns [`PageRankError`] for invalid configuration, personalization,
1238/// topology indexes, output length, scratch length, or non-convergence.
1239///
1240/// # Performance
1241///
1242/// Each iteration is `O(n + m · cost(D))` for `n` visible elements, `m`
1243/// outgoing edge entries yielded from those elements, and `cost(D)` the
1244/// per-edge cost of [`OutgoingDistribution::distribute_outgoing`]
1245/// (`O(1)` for the built-in [`Uniform`] and [`Weighted`] impls).
1246/// Scratch allocation is `O(b)` where `b` is `graph.element_bound()`.
1247#[cfg(feature = "alloc")]
1248#[expect(
1249    clippy::too_many_arguments,
1250    reason = "PageRank entry point keeps graph, distribution, elements, config, personalization, and output explicit"
1251)]
1252pub fn pagerank_graph<G, D, I, S>(
1253    graph: &G,
1254    distribution: &D,
1255    elements: I,
1256    config: PageRankConfig<S>,
1257    personalization: Option<&[S]>,
1258    ranks: &mut [S],
1259) -> Result<PageRankReport<S>, PageRankError<S>>
1260where
1261    G: ForwardGraph + DenseElementIndex,
1262    D: OutgoingDistribution<G, S>,
1263    I: Clone + IntoIterator<Item = ElementId<G>>,
1264    S: PageRankScalar,
1265{
1266    let bound = graph.element_bound();
1267    let mut teleport = vec![S::ZERO; bound];
1268    let mut next = vec![S::ZERO; bound];
1269    let mut visible_elements = vec![0; bound];
1270    pagerank_graph_with_scratch(
1271        graph,
1272        distribution,
1273        elements,
1274        config,
1275        personalization,
1276        ranks,
1277        PageRankScratch::new(&mut teleport, &mut next, &mut visible_elements),
1278    )
1279}
1280
1281/// Computes graph `PageRank` under the supplied outgoing distribution
1282/// policy with caller-provided borrowed scratch.
1283///
1284/// See [`pagerank_graph`] for the role of `distribution`.
1285///
1286/// # Errors
1287///
1288/// Returns [`PageRankError`] for invalid configuration, personalization,
1289/// topology indexes, output length, scratch length, or non-convergence.
1290///
1291/// # Performance
1292///
1293/// Performs no heap allocation after caller scratch has been provided. Each
1294/// iteration is `O(n + m · cost(D))`.
1295#[expect(
1296    clippy::too_many_arguments,
1297    clippy::needless_pass_by_value,
1298    reason = "PageRank scratch API consumes a scratch handle and keeps policy inputs explicit"
1299)]
1300pub fn pagerank_graph_with_scratch<G, D, I, S>(
1301    graph: &G,
1302    distribution: &D,
1303    elements: I,
1304    config: PageRankConfig<S>,
1305    personalization: Option<&[S]>,
1306    ranks: &mut [S],
1307    scratch: PageRankScratch<'_, S>,
1308) -> Result<PageRankReport<S>, PageRankError<S>>
1309where
1310    G: ForwardGraph + DenseElementIndex,
1311    D: OutgoingDistribution<G, S>,
1312    I: Clone + IntoIterator<Item = ElementId<G>>,
1313    S: PageRankScalar,
1314{
1315    validate_config(config)?;
1316    let bound = graph.element_bound();
1317    ensure_output_len(ranks.len(), bound)?;
1318    ensure_scratch_len("teleport", scratch.teleport.len(), bound)?;
1319    ensure_scratch_len("next", scratch.next.len(), bound)?;
1320    ensure_scratch_len("visible_elements", scratch.visible_elements.len(), bound)?;
1321    build_personalization_into(
1322        elements.clone(),
1323        bound,
1324        personalization,
1325        |element| graph.element_index(element),
1326        scratch.teleport,
1327        scratch.visible_elements,
1328    )?;
1329    initialize_ranks(elements.clone(), graph, scratch.teleport, ranks)?;
1330    iterate_graph(
1331        graph,
1332        distribution,
1333        elements,
1334        config,
1335        scratch.teleport,
1336        scratch.visible_elements,
1337        ranks,
1338        scratch.next,
1339    )
1340}
1341
1342/// Computes graph `PageRank` under the supplied outgoing distribution
1343/// policy with a reusable owned workspace.
1344///
1345/// See [`pagerank_graph`] for the role of `distribution`.
1346///
1347/// # Errors
1348///
1349/// Returns [`PageRankError`] for invalid configuration, personalization,
1350/// topology indexes, output length, or non-convergence.
1351///
1352/// # Performance
1353///
1354/// Grows workspace storage to `graph.element_bound()` if needed, then performs no
1355/// additional heap allocation. Each iteration is `O(n + m · cost(D))`.
1356#[cfg(feature = "alloc")]
1357#[expect(
1358    clippy::too_many_arguments,
1359    reason = "PageRank workspace API keeps policy and reusable storage inputs explicit"
1360)]
1361pub fn pagerank_graph_with_workspace<G, D, I, S>(
1362    graph: &G,
1363    distribution: &D,
1364    elements: I,
1365    config: PageRankConfig<S>,
1366    personalization: Option<&[S]>,
1367    ranks: &mut [S],
1368    workspace: &mut PageRankWorkspace<G, S>,
1369) -> Result<PageRankReport<S>, PageRankError<S>>
1370where
1371    G: ForwardGraph + DenseElementIndex,
1372    D: OutgoingDistribution<G, S>,
1373    I: Clone + IntoIterator<Item = ElementId<G>>,
1374    S: PageRankScalar,
1375{
1376    workspace.ensure_element_bound(graph.element_bound());
1377    pagerank_graph_with_scratch(
1378        graph,
1379        distribution,
1380        elements,
1381        config,
1382        personalization,
1383        ranks,
1384        workspace.as_scratch(),
1385    )
1386}
1387
1388/// Computes directed hypergraph incidence/bipartite `PageRank` under the
1389/// supplied bipartite outgoing distribution policy, allocating temporary
1390/// scratch.
1391///
1392/// `distribution` selects the per-state rank-transfer rule:
1393/// [`Uniform`] divides each leg uniformly over visible out-degree, while
1394/// [`HyperWeighted`] reads caller-supplied relation and target-incidence
1395/// weights. Any [`HypergraphOutgoingDistribution`] impl is accepted.
1396/// `elements` and `relations` define the visible state iteration order
1397/// across the bipartite element + relation state space.
1398///
1399/// # Errors
1400///
1401/// Returns [`PageRankError`] for invalid configuration, personalization,
1402/// topology indexes, invalid weights, output length, scratch length, or
1403/// non-convergence.
1404///
1405/// # Performance
1406///
1407/// Each iteration is `O(e + r + p · cost(D))` for `e` visible elements, `r`
1408/// visible relations, `p` traversed source/target participant entries, and
1409/// `cost(D)` the per-entry cost of
1410/// [`HypergraphOutgoingDistribution::distribute_from_element`] and
1411/// [`HypergraphOutgoingDistribution::distribute_from_relation`] (`O(1)` for
1412/// the built-in [`Uniform`] and [`HyperWeighted`] impls). Scratch allocation
1413/// is `O(e + r)`.
1414#[cfg(feature = "alloc")]
1415#[expect(
1416    clippy::too_many_arguments,
1417    reason = "hypergraph PageRank entry point keeps hypergraph, distribution, state families, and output explicit"
1418)]
1419pub fn pagerank_hypergraph<H, D, IE, IR, S>(
1420    hypergraph: &H,
1421    distribution: &D,
1422    elements: IE,
1423    relations: IR,
1424    config: PageRankConfig<S>,
1425    personalization: Option<&[S]>,
1426    element_ranks: &mut [S],
1427    relation_ranks: &mut [S],
1428) -> Result<PageRankReport<S>, PageRankError<S>>
1429where
1430    H: PageRankHypergraph,
1431    D: HypergraphOutgoingDistribution<H, S>,
1432    IE: Clone + IntoIterator<Item = ElementId<H>>,
1433    IR: Clone + IntoIterator<Item = RelationId<H>>,
1434    S: PageRankScalar,
1435{
1436    let e_bound = hypergraph.element_bound();
1437    let r_bound = hypergraph.relation_bound();
1438    let state_bound =
1439        checked_state_bound::<S>(e_bound, r_bound, element_ranks.len(), relation_ranks.len())?;
1440    let mut teleport = vec![S::ZERO; state_bound];
1441    let mut next_elements = vec![S::ZERO; e_bound];
1442    let mut next_relations = vec![S::ZERO; r_bound];
1443    let mut visible_elements = vec![0; e_bound];
1444    let mut visible_relations = vec![0; r_bound];
1445    pagerank_hypergraph_with_scratch(
1446        hypergraph,
1447        distribution,
1448        elements,
1449        relations,
1450        config,
1451        personalization,
1452        element_ranks,
1453        relation_ranks,
1454        HypergraphPageRankScratch::new(
1455            &mut teleport,
1456            &mut next_elements,
1457            &mut next_relations,
1458            &mut visible_elements,
1459            &mut visible_relations,
1460        ),
1461    )
1462}
1463
1464/// Computes hypergraph `PageRank` under the supplied outgoing distribution
1465/// policy with caller-provided borrowed scratch.
1466///
1467/// See [`pagerank_hypergraph`] for the role of `distribution`.
1468///
1469/// # Errors
1470///
1471/// Returns [`PageRankError`] for invalid configuration, personalization,
1472/// topology indexes, invalid weights, output length, scratch length, or
1473/// non-convergence.
1474///
1475/// # Performance
1476///
1477/// Performs no heap allocation after caller scratch has been provided. Each
1478/// iteration is `O(e + r + p · cost(D))`.
1479#[expect(
1480    clippy::too_many_arguments,
1481    reason = "hypergraph PageRank scratch entry point keeps policy and storage inputs explicit"
1482)]
1483#[expect(
1484    clippy::needless_pass_by_value,
1485    reason = "hypergraph PageRank scratch API consumes a scratch handle and keeps policy inputs explicit"
1486)]
1487pub fn pagerank_hypergraph_with_scratch<H, D, IE, IR, S>(
1488    hypergraph: &H,
1489    distribution: &D,
1490    elements: IE,
1491    relations: IR,
1492    config: PageRankConfig<S>,
1493    personalization: Option<&[S]>,
1494    element_ranks: &mut [S],
1495    relation_ranks: &mut [S],
1496    scratch: HypergraphPageRankScratch<'_, S>,
1497) -> Result<PageRankReport<S>, PageRankError<S>>
1498where
1499    H: PageRankHypergraph,
1500    D: HypergraphOutgoingDistribution<H, S>,
1501    IE: Clone + IntoIterator<Item = ElementId<H>>,
1502    IR: Clone + IntoIterator<Item = RelationId<H>>,
1503    S: PageRankScalar,
1504{
1505    validate_config(config)?;
1506    let e_bound = hypergraph.element_bound();
1507    let r_bound = hypergraph.relation_bound();
1508    let state_bound =
1509        checked_state_bound::<S>(e_bound, r_bound, element_ranks.len(), relation_ranks.len())?;
1510    ensure_scratch_len("teleport", scratch.teleport.len(), state_bound)?;
1511    ensure_scratch_len("next_elements", scratch.next_elements.len(), e_bound)?;
1512    ensure_scratch_len("next_relations", scratch.next_relations.len(), r_bound)?;
1513    ensure_scratch_len("visible_elements", scratch.visible_elements.len(), e_bound)?;
1514    ensure_scratch_len(
1515        "visible_relations",
1516        scratch.visible_relations.len(),
1517        r_bound,
1518    )?;
1519    build_hyper_personalization_into(
1520        hypergraph,
1521        elements.clone(),
1522        relations.clone(),
1523        state_bound,
1524        personalization,
1525        scratch.teleport,
1526        scratch.visible_elements,
1527        scratch.visible_relations,
1528    )?;
1529    initialize_hyper_ranks(
1530        hypergraph,
1531        elements.clone(),
1532        relations.clone(),
1533        scratch.teleport,
1534        element_ranks,
1535        relation_ranks,
1536    )?;
1537    iterate_hypergraph(
1538        hypergraph,
1539        distribution,
1540        elements,
1541        relations,
1542        config,
1543        scratch.teleport,
1544        scratch.visible_elements,
1545        scratch.visible_relations,
1546        element_ranks,
1547        relation_ranks,
1548        scratch.next_elements,
1549        scratch.next_relations,
1550    )
1551}
1552
1553/// Computes hypergraph `PageRank` under the supplied outgoing distribution
1554/// policy with a reusable owned workspace.
1555///
1556/// See [`pagerank_hypergraph`] for the role of `distribution`.
1557///
1558/// # Errors
1559///
1560/// Returns [`PageRankError`] for invalid configuration, personalization,
1561/// topology indexes, invalid weights, output length, or non-convergence.
1562///
1563/// # Performance
1564///
1565/// Grows workspace storage to the visible bounds if needed, then performs no
1566/// additional heap allocation. Each iteration is `O(e + r + p · cost(D))`.
1567#[cfg(feature = "alloc")]
1568#[expect(
1569    clippy::too_many_arguments,
1570    reason = "hypergraph PageRank workspace entry point keeps policy and storage inputs explicit"
1571)]
1572pub fn pagerank_hypergraph_with_workspace<H, D, IE, IR, S>(
1573    hypergraph: &H,
1574    distribution: &D,
1575    elements: IE,
1576    relations: IR,
1577    config: PageRankConfig<S>,
1578    personalization: Option<&[S]>,
1579    element_ranks: &mut [S],
1580    relation_ranks: &mut [S],
1581    workspace: &mut HypergraphPageRankWorkspace<H, S>,
1582) -> Result<PageRankReport<S>, PageRankError<S>>
1583where
1584    H: PageRankHypergraph,
1585    D: HypergraphOutgoingDistribution<H, S>,
1586    IE: Clone + IntoIterator<Item = ElementId<H>>,
1587    IR: Clone + IntoIterator<Item = RelationId<H>>,
1588    S: PageRankScalar,
1589{
1590    let e_bound = hypergraph.element_bound();
1591    let r_bound = hypergraph.relation_bound();
1592    let state_bound =
1593        checked_state_bound::<S>(e_bound, r_bound, element_ranks.len(), relation_ranks.len())?;
1594    workspace.ensure_bounds(e_bound, r_bound, state_bound);
1595    pagerank_hypergraph_with_scratch(
1596        hypergraph,
1597        distribution,
1598        elements,
1599        relations,
1600        config,
1601        personalization,
1602        element_ranks,
1603        relation_ranks,
1604        workspace.as_scratch(),
1605    )
1606}
1607
1608fn validate_config<S: PageRankScalar>(config: PageRankConfig<S>) -> Result<(), PageRankError<S>> {
1609    if !config.damping.is_finite() || config.damping < S::ZERO || config.damping > S::ONE {
1610        return Err(PageRankError::InvalidDamping {
1611            damping: config.damping,
1612        });
1613    }
1614    if !config.tolerance.is_finite() || config.tolerance < S::ZERO {
1615        return Err(PageRankError::InvalidTolerance {
1616            tolerance: config.tolerance,
1617        });
1618    }
1619    if config.max_iterations == 0 {
1620        return Err(PageRankError::InvalidMaxIterations);
1621    }
1622    Ok(())
1623}
1624
1625const fn ensure_output_len<S>(actual: usize, required: usize) -> Result<(), PageRankError<S>> {
1626    if actual < required {
1627        Err(PageRankError::OutputTooShort { required, actual })
1628    } else {
1629        Ok(())
1630    }
1631}
1632
1633const fn ensure_scratch_len<S>(
1634    name: &'static str,
1635    actual: usize,
1636    required: usize,
1637) -> Result<(), PageRankError<S>> {
1638    if actual < required {
1639        Err(PageRankError::ScratchTooShort {
1640            name,
1641            required,
1642            actual,
1643        })
1644    } else {
1645        Ok(())
1646    }
1647}
1648
1649fn checked_state_bound<S>(
1650    e_bound: usize,
1651    r_bound: usize,
1652    element_output_len: usize,
1653    relation_output_len: usize,
1654) -> Result<usize, PageRankError<S>> {
1655    ensure_output_len(element_output_len, e_bound)?;
1656    ensure_output_len(relation_output_len, r_bound)?;
1657    e_bound
1658        .checked_add(r_bound)
1659        .ok_or_else(|| PageRankError::OutputTooShort {
1660            required: usize::MAX,
1661            actual: element_output_len.saturating_add(relation_output_len),
1662        })
1663}
1664
1665fn clear<S: PageRankScalar>(values: &mut [S], len: usize) {
1666    for value in &mut values[..len] {
1667        *value = S::ZERO;
1668    }
1669}
1670
1671fn clear_u8(values: &mut [u8], len: usize) {
1672    for value in &mut values[..len] {
1673        *value = 0;
1674    }
1675}
1676
1677fn mark_visible_element<S>(visible: &mut [u8], index: usize) -> Result<(), PageRankError<S>> {
1678    if visible[index] != 0 {
1679        return Err(PageRankError::DuplicateElement { index });
1680    }
1681    visible[index] = 1;
1682    Ok(())
1683}
1684
1685fn mark_visible_relation<S>(visible: &mut [u8], index: usize) -> Result<(), PageRankError<S>> {
1686    if visible[index] != 0 {
1687        return Err(PageRankError::DuplicateRelation { index });
1688    }
1689    visible[index] = 1;
1690    Ok(())
1691}
1692
1693// `visible` slices are sized to the view's element/relation bound and every
1694// index is derived from `element_index`/`relation_index`, so direct indexing is
1695// in range — matching `mark_visible_element`/`mark_visible_relation`, which also
1696// index directly. This keeps a single "exactly-sized bitset" contract.
1697fn is_visible(visible: &[u8], index: usize) -> bool {
1698    visible[index] != 0
1699}
1700
1701#[expect(
1702    clippy::too_many_arguments,
1703    reason = "personalization normalization keeps topology family bounds and caller buffers explicit"
1704)]
1705fn build_personalization_into<I, F, S>(
1706    elements: I,
1707    bound: usize,
1708    personalization: Option<&[S]>,
1709    index_of: F,
1710    out: &mut [S],
1711    visible: &mut [u8],
1712) -> Result<(), PageRankError<S>>
1713where
1714    I: IntoIterator,
1715    F: Fn(I::Item) -> usize,
1716    S: PageRankScalar,
1717{
1718    clear(out, bound);
1719    clear_u8(visible, bound);
1720    let mut count = 0_usize;
1721    let mut sum = S::ZERO;
1722    if let Some(input) = personalization {
1723        if input.len() < bound {
1724            return Err(PageRankError::PersonalizationTooShort {
1725                required: bound,
1726                actual: input.len(),
1727            });
1728        }
1729        for element in elements {
1730            let index = index_of(element);
1731            check_index(index, bound)?;
1732            mark_visible_element(visible, index)?;
1733            let value = input[index];
1734            check_personalization_value(index, value)?;
1735            out[index] = value;
1736            sum += value;
1737            count += 1;
1738        }
1739    } else {
1740        for element in elements {
1741            let index = index_of(element);
1742            check_index(index, bound)?;
1743            mark_visible_element(visible, index)?;
1744            out[index] = S::ONE;
1745            sum += S::ONE;
1746            count += 1;
1747        }
1748    }
1749    normalize_personalization(out, count, sum)
1750}
1751
1752/// Personalization source used by [`PageRank`](crate) initialization.
1753///
1754/// Drives the per-visible-state initialization: either copy from a
1755/// caller-supplied vector ([`Self::FromInput`]) or fill every visible state
1756/// with `S::ONE` ([`Self::Uniform`]).
1757///
1758/// # Performance
1759///
1760/// `value_at` is `O(1)`. The fill methods walk visible elements and (for
1761/// hypergraphs) visible relations exactly once.
1762enum PersonalizationSource<'a, S> {
1763    /// Initialize every visible state slot to `S::ONE`.
1764    Uniform,
1765    /// Copy the value at the matching state index from the supplied slice.
1766    /// The slice must already be range-checked against the caller's state
1767    /// bound; [`Self::value_at`] only validates each value.
1768    FromInput(&'a [S]),
1769}
1770
1771impl<S> PersonalizationSource<'_, S>
1772where
1773    S: PageRankScalar,
1774{
1775    /// Returns the value to place at `state_index`.
1776    ///
1777    /// `FromInput` reads `input[state_index]` and rejects non-finite or
1778    /// negative entries via [`PageRankError::InvalidPersonalization`].
1779    /// `Uniform` returns `S::ONE` unconditionally.
1780    ///
1781    /// # Performance
1782    ///
1783    /// `O(1)`.
1784    fn value_at(&self, state_index: usize) -> Result<S, PageRankError<S>> {
1785        match *self {
1786            Self::Uniform => Ok(S::ONE),
1787            Self::FromInput(input) => {
1788                let value = input[state_index];
1789                check_personalization_value(state_index, value)?;
1790                Ok(value)
1791            }
1792        }
1793    }
1794
1795    /// Fills the hypergraph teleport vector at visible-element and
1796    /// visible-relation states.
1797    ///
1798    /// Returns `(count, sum)` for the normalization step. `state_bound` is
1799    /// the total length of `out` (`element_bound + relation_bound`); it is
1800    /// used to bounds-check `FromInput` slices before iteration.
1801    ///
1802    /// # Errors
1803    ///
1804    /// [`PageRankError::PersonalizationTooShort`] when a `FromInput` slice is
1805    /// shorter than `state_bound`;
1806    /// [`PageRankError::ElementIndexOutOfBounds`] /
1807    /// [`PageRankError::RelationIndexOutOfBounds`] when a visible element or
1808    /// relation maps outside its bound; [`PageRankError::DuplicateElement`] /
1809    /// [`PageRankError::DuplicateRelation`] when a state is marked visible
1810    /// twice; and [`PageRankError::InvalidPersonalization`] for a negative
1811    /// personalization entry.
1812    ///
1813    /// # Performance
1814    ///
1815    /// `O(|elements| + |relations|)` plus the index/visibility check cost
1816    /// per state slot.
1817    #[expect(
1818        clippy::too_many_arguments,
1819        reason = "method threads separate element/relation iterables, scratch slices, and the state bound; bundling them obscures the borrow shape that callers explicitly construct"
1820    )]
1821    fn fill_hypergraph<H, IE, IR>(
1822        &self,
1823        hypergraph: &H,
1824        elements: IE,
1825        relations: IR,
1826        state_bound: usize,
1827        out: &mut [S],
1828        visible_elements: &mut [u8],
1829        visible_relations: &mut [u8],
1830    ) -> Result<(usize, S), PageRankError<S>>
1831    where
1832        H: DenseElementIndex + DenseRelationIndex,
1833        IE: IntoIterator<Item = ElementId<H>>,
1834        IR: IntoIterator<Item = RelationId<H>>,
1835    {
1836        if let Self::FromInput(input) = *self
1837            && input.len() < state_bound
1838        {
1839            return Err(PageRankError::PersonalizationTooShort {
1840                required: state_bound,
1841                actual: input.len(),
1842            });
1843        }
1844        let e_bound = hypergraph.element_bound();
1845        let r_bound = hypergraph.relation_bound();
1846        let mut count = 0_usize;
1847        let mut sum = S::ZERO;
1848        for element in elements {
1849            let index = hypergraph.element_index(element);
1850            check_index(index, e_bound)?;
1851            mark_visible_element(visible_elements, index)?;
1852            let value = self.value_at(index)?;
1853            out[index] = value;
1854            sum += value;
1855            count += 1;
1856        }
1857        for relation in relations {
1858            let index = hypergraph.relation_index(relation);
1859            check_relation_index(index, r_bound)?;
1860            mark_visible_relation(visible_relations, index)?;
1861            let state = e_bound + index;
1862            let value = self.value_at(state)?;
1863            out[state] = value;
1864            sum += value;
1865            count += 1;
1866        }
1867        Ok((count, sum))
1868    }
1869}
1870
1871#[expect(
1872    clippy::too_many_arguments,
1873    reason = "helper threads separate element/relation state and caller scratch explicitly"
1874)]
1875fn build_hyper_personalization_into<H, IE, IR, S>(
1876    hypergraph: &H,
1877    elements: IE,
1878    relations: IR,
1879    state_bound: usize,
1880    personalization: Option<&[S]>,
1881    out: &mut [S],
1882    visible_elements: &mut [u8],
1883    visible_relations: &mut [u8],
1884) -> Result<(), PageRankError<S>>
1885where
1886    H: DenseElementIndex + DenseRelationIndex,
1887    IE: IntoIterator<Item = ElementId<H>>,
1888    IR: IntoIterator<Item = RelationId<H>>,
1889    S: PageRankScalar,
1890{
1891    clear(out, state_bound);
1892    clear_u8(visible_elements, hypergraph.element_bound());
1893    clear_u8(visible_relations, hypergraph.relation_bound());
1894    let source = personalization.map_or(
1895        PersonalizationSource::Uniform,
1896        PersonalizationSource::FromInput,
1897    );
1898    let (count, sum) = source.fill_hypergraph(
1899        hypergraph,
1900        elements,
1901        relations,
1902        state_bound,
1903        out,
1904        visible_elements,
1905        visible_relations,
1906    )?;
1907    normalize_personalization(out, count, sum)
1908}
1909
1910fn normalize_personalization<S: PageRankScalar>(
1911    out: &mut [S],
1912    count: usize,
1913    sum: S,
1914) -> Result<(), PageRankError<S>> {
1915    if count == 0 {
1916        return Err(PageRankError::EmptyState);
1917    }
1918    if sum <= S::ZERO {
1919        return Err(PageRankError::ZeroPersonalization);
1920    }
1921    for value in out {
1922        *value = *value / sum;
1923    }
1924    Ok(())
1925}
1926
1927fn check_personalization_value<S: PageRankScalar>(
1928    index: usize,
1929    value: S,
1930) -> Result<(), PageRankError<S>> {
1931    if !value.is_finite() || value < S::ZERO {
1932        Err(PageRankError::InvalidPersonalization { index, value })
1933    } else {
1934        Ok(())
1935    }
1936}
1937
1938fn initialize_ranks<G, I, S>(
1939    elements: I,
1940    graph: &G,
1941    teleport: &[S],
1942    ranks: &mut [S],
1943) -> Result<(), PageRankError<S>>
1944where
1945    G: DenseElementIndex,
1946    I: IntoIterator<Item = ElementId<G>>,
1947    S: PageRankScalar,
1948{
1949    clear(ranks, graph.element_bound());
1950    for element in elements {
1951        let index = graph.element_index(element);
1952        check_index(index, graph.element_bound())?;
1953        ranks[index] = teleport[index];
1954    }
1955    Ok(())
1956}
1957
1958#[expect(
1959    clippy::too_many_arguments,
1960    reason = "initialization writes separate element and relation rank slices"
1961)]
1962fn initialize_hyper_ranks<H, IE, IR, S>(
1963    hypergraph: &H,
1964    elements: IE,
1965    relations: IR,
1966    teleport: &[S],
1967    element_ranks: &mut [S],
1968    relation_ranks: &mut [S],
1969) -> Result<(), PageRankError<S>>
1970where
1971    H: DenseElementIndex + DenseRelationIndex,
1972    IE: IntoIterator<Item = ElementId<H>>,
1973    IR: IntoIterator<Item = RelationId<H>>,
1974    S: PageRankScalar,
1975{
1976    clear(element_ranks, hypergraph.element_bound());
1977    clear(relation_ranks, hypergraph.relation_bound());
1978    for element in elements {
1979        let index = hypergraph.element_index(element);
1980        check_index(index, hypergraph.element_bound())?;
1981        element_ranks[index] = teleport[index];
1982    }
1983    for relation in relations {
1984        let index = hypergraph.relation_index(relation);
1985        check_relation_index(index, hypergraph.relation_bound())?;
1986        relation_ranks[index] = teleport[hypergraph.element_bound() + index];
1987    }
1988    Ok(())
1989}
1990
1991#[expect(
1992    clippy::too_many_arguments,
1993    reason = "graph iteration helper keeps distribution, scratch, and policy inputs explicit"
1994)]
1995#[expect(
1996    clippy::needless_pass_by_value,
1997    reason = "iteration helpers own cloneable iterator values and clone them each power iteration"
1998)]
1999fn iterate_graph<G, D, I, S>(
2000    graph: &G,
2001    distribution: &D,
2002    elements: I,
2003    config: PageRankConfig<S>,
2004    teleport: &[S],
2005    visible: &[u8],
2006    ranks: &mut [S],
2007    next: &mut [S],
2008) -> Result<PageRankReport<S>, PageRankError<S>>
2009where
2010    G: ForwardGraph + DenseElementIndex,
2011    D: OutgoingDistribution<G, S>,
2012    I: Clone + IntoIterator<Item = ElementId<G>>,
2013    S: PageRankScalar,
2014{
2015    let mut last_delta = S::INFINITY;
2016    for iteration in 1..=config.max_iterations {
2017        clear(next, graph.element_bound());
2018        let mut dangling = S::ZERO;
2019        for element in elements.clone() {
2020            let index = checked_element_index(graph, element)?;
2021            let rank = ranks[index];
2022            dangling += distribution.distribute_outgoing(graph, element, rank, next, visible)?;
2023        }
2024        let delta = apply_graph_teleport(
2025            graph,
2026            elements.clone(),
2027            config,
2028            teleport,
2029            dangling,
2030            ranks,
2031            next,
2032        )?;
2033        last_delta = delta;
2034        if delta <= config.tolerance {
2035            return Ok(PageRankReport {
2036                iterations: iteration,
2037                delta,
2038            });
2039        }
2040    }
2041    Err(PageRankError::NonConverged {
2042        iterations: config.max_iterations,
2043        delta: last_delta,
2044    })
2045}
2046
2047#[expect(
2048    clippy::too_many_arguments,
2049    reason = "hypergraph iteration helper keeps distribution, state families, scratch, and policy inputs explicit"
2050)]
2051#[expect(
2052    clippy::needless_pass_by_value,
2053    reason = "iteration helpers own cloneable iterator values and clone them each power iteration"
2054)]
2055fn iterate_hypergraph<H, D, IE, IR, S>(
2056    hypergraph: &H,
2057    distribution: &D,
2058    elements: IE,
2059    relations: IR,
2060    config: PageRankConfig<S>,
2061    teleport: &[S],
2062    visible_elements: &[u8],
2063    visible_relations: &[u8],
2064    element_ranks: &mut [S],
2065    relation_ranks: &mut [S],
2066    next_elements: &mut [S],
2067    next_relations: &mut [S],
2068) -> Result<PageRankReport<S>, PageRankError<S>>
2069where
2070    H: PageRankHypergraph,
2071    D: HypergraphOutgoingDistribution<H, S>,
2072    IE: Clone + IntoIterator<Item = ElementId<H>>,
2073    IR: Clone + IntoIterator<Item = RelationId<H>>,
2074    S: PageRankScalar,
2075{
2076    let mut last_delta = S::INFINITY;
2077    for iteration in 1..=config.max_iterations {
2078        clear(next_elements, hypergraph.element_bound());
2079        clear(next_relations, hypergraph.relation_bound());
2080        let mut dangling = S::ZERO;
2081        for element in elements.clone() {
2082            let index = checked_element_index(hypergraph, element)?;
2083            let rank = element_ranks[index];
2084            dangling += distribution.distribute_from_element(
2085                hypergraph,
2086                element,
2087                rank,
2088                next_relations,
2089                visible_relations,
2090            )?;
2091        }
2092        for relation in relations.clone() {
2093            let index = checked_relation_index_for(hypergraph, relation)?;
2094            let rank = relation_ranks[index];
2095            dangling += distribution.distribute_from_relation(
2096                hypergraph,
2097                relation,
2098                rank,
2099                next_elements,
2100                visible_elements,
2101            )?;
2102        }
2103        let delta = apply_hyper_teleport(
2104            hypergraph,
2105            elements.clone(),
2106            relations.clone(),
2107            config,
2108            teleport,
2109            dangling,
2110            element_ranks,
2111            relation_ranks,
2112            next_elements,
2113            next_relations,
2114        )?;
2115        last_delta = delta;
2116        if delta <= config.tolerance {
2117            return Ok(PageRankReport {
2118                iterations: iteration,
2119                delta,
2120            });
2121        }
2122    }
2123    Err(PageRankError::NonConverged {
2124        iterations: config.max_iterations,
2125        delta: last_delta,
2126    })
2127}
2128
2129fn checked_element_index<G: DenseElementIndex, S>(
2130    graph: &G,
2131    element: ElementId<G>,
2132) -> Result<usize, PageRankError<S>> {
2133    let index = graph.element_index(element);
2134    check_index(index, graph.element_bound())?;
2135    Ok(index)
2136}
2137
2138const fn check_index<S>(index: usize, bound: usize) -> Result<(), PageRankError<S>> {
2139    if index < bound {
2140        Ok(())
2141    } else {
2142        Err(PageRankError::ElementIndexOutOfBounds { index, bound })
2143    }
2144}
2145
2146const fn check_relation_index<S>(index: usize, bound: usize) -> Result<(), PageRankError<S>> {
2147    if index < bound {
2148        Ok(())
2149    } else {
2150        Err(PageRankError::RelationIndexOutOfBounds { index, bound })
2151    }
2152}
2153
2154fn checked_relation_weight<G, W, S>(
2155    graph: &G,
2156    weights: &W,
2157    relation: RelationId<G>,
2158) -> Result<S, PageRankError<S>>
2159where
2160    G: DenseRelationIndex,
2161    W: RelationWeight<ElementId = G::ElementId, RelationId = G::RelationId>,
2162    W::Weight: IntoPageRankScalar<S>,
2163    S: PageRankScalar,
2164{
2165    let index = graph.relation_index(relation);
2166    check_relation_index(index, graph.relation_bound())?;
2167    let value = weights.relation_weight(relation).into_pagerank_scalar();
2168    if !value.is_finite() || value < S::ZERO {
2169        Err(PageRankError::InvalidRelationWeight { index, value })
2170    } else {
2171        Ok(value)
2172    }
2173}
2174
2175fn checked_incidence_weight<H, W, S>(
2176    hypergraph: &H,
2177    weights: &W,
2178    incidence: H::IncidenceId,
2179) -> Result<S, PageRankError<S>>
2180where
2181    H: DenseIncidenceIndex,
2182    W: IncidenceWeight<
2183            ElementId = H::ElementId,
2184            RelationId = H::RelationId,
2185            IncidenceId = H::IncidenceId,
2186        >,
2187    W::Weight: IntoPageRankScalar<S>,
2188    S: PageRankScalar,
2189{
2190    let index = hypergraph.incidence_index(incidence);
2191    let bound = hypergraph.incidence_bound();
2192    if index >= bound {
2193        return Err(PageRankError::IncidenceIndexOutOfBounds { index, bound });
2194    }
2195    let value = weights.incidence_weight(incidence).into_pagerank_scalar();
2196    if !value.is_finite() || value < S::ZERO {
2197        Err(PageRankError::InvalidIncidenceWeight { index, value })
2198    } else {
2199        Ok(value)
2200    }
2201}
2202
2203fn outgoing_weight_total<G, W, S>(
2204    graph: &G,
2205    weights: &W,
2206    element: ElementId<G>,
2207    visible: &[u8],
2208) -> Result<S, PageRankError<S>>
2209where
2210    G: ForwardGraph + DenseElementIndex + DenseRelationIndex,
2211    W: RelationWeight<ElementId = G::ElementId, RelationId = G::RelationId>,
2212    W::Weight: IntoPageRankScalar<S>,
2213    S: PageRankScalar,
2214{
2215    let mut total = S::ZERO;
2216    for edge in graph.outgoing_edges(element) {
2217        let target = graph.target(edge);
2218        let target_index = checked_element_index(graph, target)?;
2219        if is_visible(visible, target_index) {
2220            total += checked_relation_weight(graph, weights, edge)?;
2221        }
2222    }
2223    Ok(total)
2224}
2225
2226/// Computes the teleport-blended rank for one state and its absolute delta.
2227///
2228/// Shared by the graph and both hypergraph state legs so the convergence
2229/// formula `damping * accumulated + teleport_scale * teleport` lives in exactly
2230/// one place. Returns `(new_value, |new_value - previous|)`; the caller writes
2231/// `new_value` into the rank slice (the `next`/accumulator slice is cleared at
2232/// the top of the following iteration, so it is intentionally not written back).
2233///
2234/// # Performance
2235///
2236/// This function is `O(1)`.
2237fn teleport_update<S: PageRankScalar>(
2238    damping: S,
2239    teleport_scale: S,
2240    accumulated: S,
2241    teleport: S,
2242    previous: S,
2243) -> (S, S) {
2244    let value = (damping * accumulated) + (teleport_scale * teleport);
2245    let delta = (value - previous).abs();
2246    (value, delta)
2247}
2248
2249#[expect(
2250    clippy::too_many_arguments,
2251    reason = "teleport helper updates caller-provided rank and scratch slices"
2252)]
2253fn apply_graph_teleport<G, I, S>(
2254    graph: &G,
2255    elements: I,
2256    config: PageRankConfig<S>,
2257    teleport: &[S],
2258    dangling: S,
2259    ranks: &mut [S],
2260    next: &[S],
2261) -> Result<S, PageRankError<S>>
2262where
2263    G: DenseElementIndex,
2264    I: IntoIterator<Item = ElementId<G>>,
2265    S: PageRankScalar,
2266{
2267    let mut delta = S::ZERO;
2268    let teleport_scale = (S::ONE - config.damping) + (config.damping * dangling);
2269    for element in elements {
2270        let index = checked_element_index(graph, element)?;
2271        let (value, state_delta) = teleport_update(
2272            config.damping,
2273            teleport_scale,
2274            next[index],
2275            teleport[index],
2276            ranks[index],
2277        );
2278        delta += state_delta;
2279        ranks[index] = value;
2280    }
2281    Ok(delta)
2282}
2283
2284fn checked_relation_index_for<H: DenseRelationIndex, S>(
2285    hypergraph: &H,
2286    relation: RelationId<H>,
2287) -> Result<usize, PageRankError<S>> {
2288    let index = hypergraph.relation_index(relation);
2289    check_relation_index(index, hypergraph.relation_bound())?;
2290    Ok(index)
2291}
2292
2293fn hyper_outgoing_relation_weight<H, W, S>(
2294    hypergraph: &H,
2295    weights: &W,
2296    element: ElementId<H>,
2297    visible_relations: &[u8],
2298) -> Result<S, PageRankError<S>>
2299where
2300    H: DirectedVertexHyperedges + DenseRelationIndex,
2301    W: RelationWeight<ElementId = H::ElementId, RelationId = H::RelationId>,
2302    W::Weight: IntoPageRankScalar<S>,
2303    S: PageRankScalar,
2304{
2305    let mut total = S::ZERO;
2306    for relation in hypergraph.outgoing_hyperedges(element) {
2307        let relation_index = checked_relation_index_for(hypergraph, relation)?;
2308        if is_visible(visible_relations, relation_index) {
2309            total += checked_relation_weight(hypergraph, weights, relation)?;
2310        }
2311    }
2312    Ok(total)
2313}
2314
2315fn hyper_target_incidence_weight<H, W, S>(
2316    hypergraph: &H,
2317    weights: &W,
2318    relation: RelationId<H>,
2319    visible_elements: &[u8],
2320) -> Result<S, PageRankError<S>>
2321where
2322    H: DirectedHyperedgeIncidences + IncidenceElement + DenseElementIndex + DenseIncidenceIndex,
2323    W: IncidenceWeight<
2324            ElementId = H::ElementId,
2325            RelationId = H::RelationId,
2326            IncidenceId = H::IncidenceId,
2327        >,
2328    W::Weight: IntoPageRankScalar<S>,
2329    S: PageRankScalar,
2330{
2331    let mut total = S::ZERO;
2332    for incidence in hypergraph.target_incidences(relation) {
2333        let target = hypergraph.incidence_element(incidence);
2334        let target_index = checked_element_index(hypergraph, target)?;
2335        if is_visible(visible_elements, target_index) {
2336            total += checked_incidence_weight(hypergraph, weights, incidence)?;
2337        }
2338    }
2339    Ok(total)
2340}
2341
2342#[expect(
2343    clippy::too_many_arguments,
2344    reason = "hypergraph teleport updates separate element and relation slices"
2345)]
2346fn apply_hyper_teleport<H, IE, IR, S>(
2347    hypergraph: &H,
2348    elements: IE,
2349    relations: IR,
2350    config: PageRankConfig<S>,
2351    teleport: &[S],
2352    dangling: S,
2353    element_ranks: &mut [S],
2354    relation_ranks: &mut [S],
2355    next_elements: &[S],
2356    next_relations: &[S],
2357) -> Result<S, PageRankError<S>>
2358where
2359    H: DenseElementIndex + DenseRelationIndex,
2360    IE: IntoIterator<Item = ElementId<H>>,
2361    IR: IntoIterator<Item = RelationId<H>>,
2362    S: PageRankScalar,
2363{
2364    let mut delta = S::ZERO;
2365    let e_bound = hypergraph.element_bound();
2366    let teleport_scale = (S::ONE - config.damping) + (config.damping * dangling);
2367    for element in elements {
2368        let index = checked_element_index(hypergraph, element)?;
2369        let (value, state_delta) = teleport_update(
2370            config.damping,
2371            teleport_scale,
2372            next_elements[index],
2373            teleport[index],
2374            element_ranks[index],
2375        );
2376        delta += state_delta;
2377        element_ranks[index] = value;
2378    }
2379    for relation in relations {
2380        let index = checked_relation_index_for(hypergraph, relation)?;
2381        let state = e_bound + index;
2382        let (value, state_delta) = teleport_update(
2383            config.damping,
2384            teleport_scale,
2385            next_relations[index],
2386            teleport[state],
2387            relation_ranks[index],
2388        );
2389        delta += state_delta;
2390        relation_ranks[index] = value;
2391    }
2392    Ok(delta)
2393}