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