Repository: Collektive/collektive
Pull request #1001: feat(stdlib)!: improve the memory usage and the usability of `gradientCast`
Author: login: DanySK
Labels: name: auto-update-rebase
description: Mergify will try to update with rebase
name: released
Created: 2025-05-23T16:28:43Z | Merged: 2025-05-25T21:34:24Z

The algorithm now selects a single path per node across the multiple paths that are locally available.
There is also no need to be informed if the environment is a Riemannian manifold, the metric is used to deduce it.
The new implementation uses exchange to share different paths with different neighbors.


Diff:
diff --git a/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/DistanceTo.kt b/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/DistanceTo.kt
index 2ce606c9c..a38d32b20 100644
--- a/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/DistanceTo.kt
+++ b/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/DistanceTo.kt
@@ -16,32 +16,46 @@ import it.unibo.collektive.stdlib.util.hops
 import kotlin.jvm.JvmOverloads
 
 /**
- * Compute the distance from the closest [source], using [Double]s.
+ * Computes the distance from the nearest source node as a `Double`.
  *
- * The distance between neighboring devices is computed using the [metric] function,
- * and defaults to the hop distance.
+ * The distance between neighboring devices is computed using the `metric` function
+ * and defaults to the hop-count metric.
+ *
+ * @param ID The type used for neighbor identifiers.
+ * @param source True if this device is a source node; otherwise false.
+ * @param metric A `Field<ID, Double>` providing edge weights to each neighbor. Defaults to `hops().toDouble()`.
+ * @param maxDiameter Maximum allowed hops before discarding paths. Defaults to `Int.MAX_VALUE`.
+ * @return The computed `Double` distance to the closest source.
  */
 @JvmOverloads
 inline fun <reified ID : Any> Aggregate<ID>.distanceTo(
     source: Boolean,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
     metric: Field<ID, Double> = hops().toDouble(),
-): Double = distanceTo(source, 0.0, Double.POSITIVE_INFINITY, metric, maxPaths, isRiemannianManifold, Double::plus)
+    maxDiameter: Int = Int.MAX_VALUE,
+): Double = distanceTo(source, 0.0, Double.POSITIVE_INFINITY, metric, maxDiameter, Double::plus)
 
 /**
- * Compute the [Distance] from the closest [source], starting from [bottom] and up to [top].
+ * Computes the distance from the nearest source node within a specified range.
+ *
+ * Starting from `bottom` at a source and up to `top`, distances between neighboring devices
+ * are computed using the `metric` function and accumulated via `accumulateDistance`.
  *
- * the [Distance] between neighboring devices is computed using the [metric] function,
- * the distance summation is governed by the [accumulateDistance] function.
+ * @param ID The type used for neighbor identifiers.
+ * @param Distance The comparable type representing path lengths.
+ * @param source True if this device is a source node; otherwise false.
+ * @param bottom The zero-distance value at a source.
+ * @param top The maximum allowed distance; incoming distances are clamped to this value.
+ * @param metric A `Field<ID, Distance>` providing edge weights to each neighbor.
+ * @param maxDiameter Maximum allowed hops before discarding paths. Defaults to `Int.MAX_VALUE`.
+ * @param accumulateDistance Reducer function to combine two distances.
+ * @return The computed `Distance` to the closest source.
  */
 inline fun <reified ID : Any, reified Distance : Comparable<Distance>> Aggregate<ID>.distanceTo(
     source: Boolean,
     bottom: Distance,
     top: Distance,
     metric: Field<ID, Distance>,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateDistance: Reducer<Distance>,
 ): Distance = gradientCast(
     source = source,
@@ -49,8 +63,7 @@ inline fun <reified ID : Any, reified Distance : Comparable<Distance>> Aggregate
     bottom = bottom,
     top = top,
     metric = metric,
-    maxPaths = maxPaths,
-    isRiemannianManifold = isRiemannianManifold,
+    maxDiameter = maxDiameter,
     accumulateData = { neighborToSource, hereToNeighbor, _ ->
         accumulateDistance(neighborToSource, hereToNeighbor)
     },
@@ -58,10 +71,15 @@ inline fun <reified ID : Any, reified Distance : Comparable<Distance>> Aggregate
 )
 
 /**
- * Computes the hop distance from the closest [source].
+ * Computes the hop-count distance from the nearest source node as an `Int`.
+ *
+ * @param ID The type used for neighbor identifiers.
+ * @param source True if this device is a source node; otherwise false.
+ * @param maxDiameter Maximum allowed hops before discarding paths. Defaults to `Int.MAX_VALUE`.
+ * @return The computed hop-count `Int` distance to the closest source.
  */
 @JvmOverloads
-inline fun <reified ID : Any> Aggregate<ID>.hopDistanceTo(source: Boolean, maxPaths: Int = Int.MAX_VALUE): Int =
-    hopGradientCast(source = source, local = 0, maxPaths = maxPaths) { neighborToSource, hereToNeighbor, _ ->
+inline fun <reified ID : Any> Aggregate<ID>.hopDistanceTo(source: Boolean, maxDiameter: Int = Int.MAX_VALUE): Int =
+    hopGradientCast(source = source, local = 0, maxDiameter = maxDiameter) { neighborToSource, hereToNeighbor, _ ->
         neighborToSource + hereToNeighbor
     }
diff --git a/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/GradientCast.kt b/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/GradientCast.kt
index a58467f15..882a068c9 100644
--- a/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/GradientCast.kt
+++ b/collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/GradientCast.kt
@@ -11,27 +11,43 @@ package it.unibo.collektive.stdlib.spreading
 import it.unibo.collektive.aggregate.Field
 import it.unibo.collektive.aggregate.api.Aggregate
 import it.unibo.collektive.aggregate.api.DelicateCollektiveApi
+import it.unibo.collektive.aggregate.api.exchange
+import it.unibo.collektive.aggregate.api.mapNeighborhood
 import it.unibo.collektive.aggregate.api.share
-import it.unibo.collektive.aggregate.api.sharing
-import it.unibo.collektive.stdlib.fields.foldValues
 import it.unibo.collektive.stdlib.fields.minValueBy
 import it.unibo.collektive.stdlib.util.Reducer
 import it.unibo.collektive.stdlib.util.coerceIn
 import it.unibo.collektive.stdlib.util.hops
 import it.unibo.collektive.stdlib.util.nonOverflowingPlus
 import kotlinx.serialization.Serializable
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
 import kotlin.jvm.JvmOverloads
 
 /**
- * Propagate [local] values across a spanning tree starting from the closest [source].
+ * Computes a gradient-based broadcast of local values over the network using a Bellman–Ford–style algorithm.
  *
- * If there are no sources and no neighbors, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Distance]s.
- * [Distance]s must be in the [[bottom], [top]] range, [accumulateDistance] is used to sum distances.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * Starting from the nearest `source` node(s), this function propagates values of type `Value` along a
+ * spanning tree defined by a distance metric. At each device:
+ * - If `source` is `true`, returns the `local` value at minimal distance `bottom`.
+ * - Otherwise, selects the neighbor path yielding the minimal total distance (accumulated via `accumulateDistance`)
+ *   and applies `accumulateData` to combine neighbor data.
+ * - In the absence of any source or neighbors, returns the `local` value.
  *
- * This function features *incremental repair*, and it is subject to the *rising value problem*,
- * see [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163).
+ * @param source               `true` if this device is a source; `false` otherwise.
+ * @param local                The local value to propagate from a source or to use as a default.
+ * @param bottom               The minimum distance (distance at a source). Must be ≤ `top`.
+ * @param top                  The maximum distance threshold; values outside `[bottom, top]` are clamped.
+ * @param accumulateData       A function to combine data when forwarding from a neighbor.
+ *                              Receives the neighbor's distance from source, the edge distance to this device,
+ *                              and the neighbor's data. Defaults to the identity function.
+ * @param accumulateDistance   A reducer that sums two distances; used to accumulate path lengths.
+ * @param metric               A `Field<ID, Distance>` providing the distance to each neighbor.
+ *
+ * @return The aggregated `Value` at this device, chosen from the minimal-distance path.
+ *
+ * This function uses *incremental repair* to self-heal the gradient and may suffer from the *rising value problem*.
+ * See [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163) for details.
  */
 @JvmOverloads
 inline fun <reified ID, reified Value, reified Distance> Aggregate<ID>.bellmanFordGradientCast(
@@ -48,11 +64,7 @@ inline fun <reified ID, reified Value, reified Distance> Aggregate<ID>.bellmanFo
     val distances = metric.coerceIn(bottom, top)
     return share(topValue) { neighborData ->
         val pathsThroughNeighbors = neighborData.alignedMapValues(distances) { (fromSource, data), toNeighbor ->
-            val totalDistance = accumulateDistance(fromSource, toNeighbor).coerceIn(bottom, top)
-            check(totalDistance >= fromSource && totalDistance >= toNeighbor) {
-                "The provided distance accumulation function violates the triangle inequality: " +
-                    "accumulating $fromSource and $toNeighbor produced $totalDistance"
-            }
+            val totalDistance = accumulate(bottom, top, fromSource, toNeighbor, accumulateDistance)
             val newData = accumulateData(fromSource, toNeighbor, data)
             totalDistance to newData
         }
@@ -65,15 +77,33 @@ inline fun <reified ID, reified Value, reified Distance> Aggregate<ID>.bellmanFo
 }
 
 /**
- * Propagate [local] values across a spanning tree starting from the closest [source].
+ * Propagates local values across the network using a Bellman–Ford gradient.
+ *
+ * Starting from the nearest `source` node(s), this function builds a spanning tree
+ * and distributes data of type `Value` along minimal-distance paths. At each device:
+ * - If `source` is `true`, returns the `local` value at distance `0.0`.
+ * - Otherwise, examines each neighbor’s propagated pair `(distance, data)`, adds the
+ *   edge weight via `accumulateDistance`, applies `accumulateData` to the data, and
+ *   selects the path with the smallest total distance.
+ * - If there are no sources and no neighbors, returns the `local` value.
  *
- * If there are no sources and no neighbors, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Double]s,
- * [accumulateDistance] is used to sum distances.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * This overload uses a default distance range of `[0.0, +∞)` and the standard
+ * addition operator for distance accumulation.
  *
- * This function features *incremental repair*, and it is subject to the *rising value problem*,
- * see [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163).
+ * @param source             `true` if this device is a source node; `false` otherwise.
+ * @param local              The local value to propagate or to use as a default.
+ * @param accumulateData     Function to combine neighbor data:
+ *                           receives the neighbor’s distance from source,
+ *                           the edge distance to this device, and the neighbor’s data.
+ *                           Defaults to the identity function.
+ * @param accumulateDistance Function to sum two distances; defaults to `Double::plus`.
+ * @param metric             A `Field<ID, Double>` providing the edge weight to each neighbor.
+ *
+ * @return The propagated `Value` at this device, chosen along the shortest-distance path.
+ *
+ * This function uses *incremental repair* to self-heal the gradient and may suffer
+ * from the *rising value problem*. See
+ * [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163) for details.
  */
 @JvmOverloads
 inline fun <reified ID, reified Value> Aggregate<ID>.bellmanFordGradientCast(
@@ -87,19 +117,42 @@ inline fun <reified ID, reified Value> Aggregate<ID>.bellmanFordGradientCast(
     bellmanFordGradientCast(source, local, 0.0, Double.POSITIVE_INFINITY, accumulateData, accumulateDistance, metric)
 
 /**
- * Propagate [local] values across multiple spanning trees starting from all the devices in which [source] holds,
- * retaining the value of the closest source.
+ * Computes a fast, self-healing gradient broadcast of local values from all source nodes,
+ * always retaining the data from the nearest source.
+ *
+ * Starting from every device where `source` is `true`, this function propagates data of type `Value`
+ * along multiple overlapping spanning trees. At each device:
+ * - If `source` is `true`, initializes its own `local` value at distance `bottom`.
+ * - Otherwise, gathers neighbor-propagated `GradientPath<ID, Value, Distance>?` values,
+ *   filters out nulls, loops (via `maxDiameter` and hop sets), and paths invalidated by
+ *   inconsistent neighbor distances, then selects the minimal-distance, path-coherent route.
+ * - Uses `accumulateDistance` to sum edge distances and `accumulateData` to update the data payload.
+ * - Falls back to `local` if no valid source path exists.
+ *
+ * This algorithm employs *fast repair* and is **not** subject to the rising-value problem,
+ * but it incurs larger message sizes and more computation than [bellmanFordGradientCast].
+ * To limit work and prevent infinite loops, supply an estimate of the network diameter via `maxDiameter`.
  *
- * If there are no sources, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Distance]s.
- * [Distance]s must be in the [[bottom], [top]] range, [accumulateDistance] is used to sum distances.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * @param source             `true` if this device is a source node; otherwise `false`.
+ * @param local              The local data value for this device, or the default if no source path exists.
+ * @param bottom             The zero-distance value at a source. Must be ≤ `top`.
+ * @param top                The maximum allowed distance; incoming distances are clamped into `[bottom, top]`.
+ * @param metric             A `Field<ID, Distance>` providing edge weights to each neighbor.
+ * @param maxDiameter        Upper bound on path hop count; paths longer than this are discarded.
+ *                          Defaults to `Int.MAX_VALUE`.
+ * @param accumulateData     Function to update neighbor data when traversing an edge.
+ *                          Receives the neighbor’s distance from source, the edge distance,
+ *                          and the neighbor’s data, returning a new `Value`. Defaults to identity.
+ * @param accumulateDistance A reducer to sum two `Distance` values; used for accumulating path lengths.
+ * @param ID       The type used for neighbor identifiers.
+ * @param Value    The type of data being propagated.
+ * @param Distance The type representing path lengths; must be comparable.
  *
- * This function features *fast repair*, and it is **not** subject to the *rising value problem*,
- * see [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163).
+ * @return The `Value` associated with the nearest source node, following a minimal-distance, loop-free path,
+ *         or `local` if no source paths are found.
  *
- * On the other hand, it requires larger messages and more processing than the classic
- * [bellmanFordGradientCast].
+ * @see bellmanFordGradientCast
+ * @see <a href="https://doi.org/10.1145/1363686.1364163">Fast self-healing gradients</a>
  */
 @OptIn(DelicateCollektiveApi::class)
 @JvmOverloads
@@ -109,86 +162,87 @@ inline fun <reified ID : Any, reified Value, reified Distance : Comparable<Dista
     bottom: Distance,
     top: Distance,
     metric: Field<ID, Distance>,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Distance, toNeighbor: Distance, neighborData: Value) -> Value =
         { _, _, data -> data },
     crossinline accumulateDistance: Reducer<Distance>,
 ): Value {
-    require(maxPaths > 0) {
-        "Computing the gradient requires at least one-path memory"
-    }
     val coercedMetric = metric.coerceIn(bottom, top)
-    val fromLocalSource = if (source) listOf(GradientPath(bottom, localId, localId, local)) else emptyList()
-    return sharing(fromLocalSource) { neighborData: Field<ID, List<GradientPath<ID, Value, Distance>>> ->
-        val distanceUpdatedPaths = neighborData.alignedMap(coercedMetric) { neighbor, paths, distanceToNeighbor ->
-            paths.mapNotNull { path ->
-                when {
-                    // Previous data, discarded anyway when reducing, or loopback to self
-                    neighbor == localId || path.through == localId -> null
+    val fromLocalSource = if (source) GradientPath(bottom, emptyList<ID>(), local) else null
+    return exchange(fromLocalSource) { neighborData: Field<ID, GradientPath<ID, Value, Distance>?> ->
+        val neighbors = neighborData.neighbors
+        // Accumulated distances with neighbors, to be used to exclude invalid paths
+        val accDistances = neighborData.alignedMapValues(coercedMetric) { path, distance ->
+            path?.distance?.let { accumulateDistance(it, distance) }
+        }
+        val neighborAccumulatedDistances = accDistances.excludeSelf()
+        val nonLoopingPaths = neighborData.alignedMap(accDistances, coercedMetric) { id, path, accDist, distance ->
+            path?.takeUnless { id == localId }
+                ?.takeUnless { path.length > maxDiameter }
+                ?.takeUnless { localId in path.hops }
+                ?.takeUnless { path.isInvalidViaShortcut(accDist, neighbors, neighborAccumulatedDistances) }
+                ?.run { accDist to lazy { update(id, distance, bottom, top, accumulateDistance, accumulateData) } }
+        }.excludeSelf().values.asSequence().filterNotNull().sortedBy { it.first }.map { it.second.value }
+        val best = when {
+            fromLocalSource != null -> sequenceOf(fromLocalSource)
+            else -> {
+                val pathsHopSets by lazy { nonLoopingPaths.associate { it.nextHop to it.hops.toSet() } }
+                nonLoopingPaths.filter { reference ->
                     /*
-                     * In Riemannian manifolds, the distance is always positive and the triangle inequality holds.
-                     * Thus, we can safely discard paths that pass through a direct neighbor
-                     * (except for neighbors that are sources),
-                     * as the distance will be always larger than getting to the neighbor directly.
+                     * Path-coherence: paths that contain inconsistent information must be removed.
+                     * In particular, if some path passes through A and then B, and another reaches the source
+                     * through B and then A, we must keep only the shortest
+                     * (unless they have the same path-length, namely the network is symmetric).
                      */
-                    isRiemannianManifold && !path.comesFromSource && path.through in neighborData.neighbors -> null
-                    // Keep the path
-                    else -> path.updateDistance(neighbor, distanceToNeighbor, bottom, top, accumulateDistance)
-                }
-            }
-        }
-        /*
-         * Take one path per source and neighbor (the one with the shortest distance).
-         */
-        val candidatePaths = distanceUpdatedPaths.foldValues(
-            mutableMapOf<ID, UpdatedGradientPath<ID, Value, Distance>>(),
-        ) { accumulator, paths ->
-            paths.forEach { path ->
-                val key = path.source
-                val previous = accumulator.getOrPut(key) { path }
-                if (previous.totalDistance > path.totalDistance) {
-                    accumulator[key] = path
+                    val refSize = reference.length
+                    refSize <= 1 ||
+                        nonLoopingPaths.all { other ->
+                            other.length > refSize ||
+                                // the current reference is shorter
+                                // same hop count, same distance (symmetric network or same path)
+                                (other.length == refSize && other.distance == reference.distance) ||
+                                // all common hops appear in the same order
+                                reference.allCommonHopsAppearInTheSameOrderOf(other, pathsHopSets)
+                        }
                 }
             }
-            accumulator
-        }.values.sorted()
-        /*
-         * Keep at most maxPaths paths, including the local source.
-         */
-        val topCandidates = candidatePaths.asSequence()
-            .take(maxPaths - fromLocalSource.size)
-            .map { it.toLocalPath(accumulateData) }
-        val shared = fromLocalSource + topCandidates
-        check(shared.size <= maxPaths) {
-            "Bug in gradientCast: the number of paths exceeds the maximum allowed: ${shared.size} > $maxPaths."
         }
-        shared.yielding { firstOrNull()?.data ?: local }
-    }
+        val bestLazyList = best.map { lazy { it } }.toList()
+        mapNeighborhood { neighbor -> bestLazyList.firstOrNull { it.value.hops.lastOrNull() != neighbor }?.value }
+    }.local.value?.data ?: local
 }
 
 /**
- * Propagate [local] values across multiple spanning trees starting from all the devices in which [source] holds,
- * retaining the value of the closest source.
+ * Broadcasts a fast-repair gradient of local values from all source nodes,
+ * always retaining the data from the nearest source.
  *
- * If there are no sources, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Double]s,
- * [accumulateDistance] is used to accumulate distances, defaulting to a plain sum.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * This overload delegates to the full `gradientCast(...)` implementation using
+ * a default distance range of `[0.0, +∞)` and the standard addition reducer.
  *
- * This function features *fast repair*, and it is **not** subject to the *rising value problem*,
- * see [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163).
+ * @param source `true` if this device is a source node; otherwise `false`.
+ * @param local The local data value or the fallback if no source path exists.
+ * @param metric A `Field<ID, Double>` that provides the edge weight (distance) to each neighbor.
+ * @param maxDiameter Upper limit on path hop count; any path exceeding this is discarded.
+ *    Defaults to `Int.MAX_VALUE`.
+ * @param accumulateData Function to update neighbor data when traversing an edge.
+ *    Receives the neighbor’s distance from source, the edge distance,
+ *    and the neighbor’s data, returning a new data value.
+ *    Defaults to the identity function.
+ * @param accumulateDistance Function to combine two distances; defaults to `Double::plus`.
  *
- * On the other hand, it requires larger messages and more processing than the classic
- * [bellmanFordGradientCast].
+ * @return The data associated with the nearest source node, following a minimal-distance,
+ *         loop-free path, or `local` if no source is reachable.
+ *
+ * @see gradientCast
+ * @see bellmanFordGradientCast
+ * @see <a href="https://doi.org/10.1145/1363686.1364163">Fast self-healing gradients</a>
  */
 @JvmOverloads
 inline fun <reified ID : Any, reified Type> Aggregate<ID>.gradientCast(
     source: Boolean,
     local: Type,
     metric: Field<ID, Double>,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Double, toNeighbor: Double, data: Type) -> Type = { _, _, data -> data },
     crossinline accumulateDistance: Reducer<Double> = Double::plus,
 ): Type = gradientCast(
@@ -197,34 +251,50 @@ inline fun <reified ID : Any, reified Type> Aggregate<ID>.gradientCast(
     0.0,
     Double.POSITIVE_INFINITY,
     metric,
-    maxPaths,
-    isRiemannianManifold,
+    maxDiameter,
     accumulateData,
     accumulateDistance,
 )
 
 /**
- * Propagate [local] values across multiple spanning trees starting from all the devices in which [source] holds,
- * retaining the value of the closest source.
+ * Broadcasts a fast-repair integer gradient of local values from all source nodes,
+ * always retaining the data from the nearest source.
+ *
+ * This overload applies when distances are `Int`, using a default range of `[0, Int.MAX_VALUE]`
+ * and the standard non-overflowing addition reducer.
  *
- * If there are no sources, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Int]s,
- * [accumulateDistance] is used to accumulate distances, defaulting to a plain sum.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * At each device:
+ * - If `source` is `true`, propagates its own `local` value with distance `0`.
+ * - Otherwise, collects neighbor paths, accumulates distances via `accumulateDistance`,
+ *   applies `accumulateData` to the neighbor’s data, and selects the path with minimal total distance.
+ * - If no source path exists, returns `local`.
  *
- * This function features *fast repair*, and it is **not** subject to the *rising value problem*,
- * see [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163).
+ * @param ID The type used for neighbor identifiers.
+ * @param Type The type of data being propagated.
+ * @param source `true` if this device is a source node; otherwise `false`.
+ * @param local The local data value or fallback if no source is reachable.
+ * @param metric A `Field<ID, Int>` providing edge weights to each neighbor.
+ * @param maxDiameter Maximum allowed number of hops; paths longer than this are discarded. Defaults to `Int.MAX_VALUE`.
+ * @param accumulateData Function to update neighbor data on each hop: receives the neighbor’s distance from source,
+ * the edge distance, and the neighbor’s data. Defaults to the identity function.
+ * @param accumulateDistance Reducer to combine two distances; defaults to `Int::nonOverflowingPlus`.
  *
- * On the other hand, it requires larger messages and more processing than the classic
- * [bellmanFordGradientCast].
+ * @return The data associated with the nearest source node along a minimal-distance,
+ * loop-free path, or `local` if no source is reachable.
+ *
+ * This algorithm employs *fast repair* and is **not** subject to the *rising value problem*.
+ * It requires larger messages and more computation than [bellmanFordGradientCast].
+ * To improve performance, provide an upper bound on the network diameter via `maxDiameter`.
+ *
+ * @see bellmanFordGradientCast
+ * @see <a href="https://doi.org/10.1145/1363686.1364163">Fast self-healing gradients</a>
  */
 @JvmOverloads
 inline fun <reified ID : Any, reified Type> Aggregate<ID>.intGradientCast(
     source: Boolean,
     local: Type,
     metric: Field<ID, Int>,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Int, toNeighbor: Int, data: Type) -> Type = { _, _, data -> data },
     crossinline accumulateDistance: Reducer<Int> = Int::nonOverflowingPlus,
 ): Type = gradientCast(
@@ -233,39 +303,71 @@ inline fun <reified ID : Any, reified Type> Aggregate<ID>.intGradientCast(
     0,
     Int.MAX_VALUE,
     metric,
-    maxPaths,
-    isRiemannianManifold,
+    maxDiameter,
     accumulateData,
     accumulateDistance,
 )
 
 /**
- * Propagate [local] values across multiple spanning trees starting from all the devices in which [source] holds,
- * retaining the value of the closest source, using the hop count as distance metric.
+ * Broadcasts a fast-repair gradient of local values using hop count as the distance metric.
  *
- * If there are no sources, default to [local] value.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * Propagates values from every device where `source` is true, retaining the data from
+ * the nearest source (fewest hops). Internally delegates to [intGradientCast] with
+ * `hops()` as the metric and `Int::plus` as the distance reducer.
  *
- * This function features *fast repair*, and it is **not** subject to the *rising value problem*,
- * see [Fast self-healing gradients](https://doi.org/10.1145/1363686.1364163).
+ * @param ID The type representing neighbor identifiers.
+ * @param Type The type of data being propagated.
+ * @param source True if this device is a source node; false otherwise.
+ * @param local The local data value or fallback if no source is reachable.
+ * @param maxDiameter Upper bound on path hop count; paths longer than this are discarded. Defaults to Int.MAX_VALUE.
+ * @param accumulateData Function to update neighbor data on each hop. Receives the neighbor’s
+ *                       distance from source (hop count), the hop increment (always 1),
+ *                       and the neighbor’s data. Defaults to the identity function.
+ * @return The data associated with the nearest source node along a minimal-hop,
+ *         loop-free path, or `local` if no source is reachable.
  *
- * On the other hand, it requires larger messages and more processing than the classic
- * [bellmanFordGradientCast].
+ * @see intGradientCast
+ * @see bellmanFordGradientCast
+ * @see <a href="https://doi.org/10.1145/1363686.1364163">Fast self-healing gradients</a>
  */
 @JvmOverloads
 inline fun <reified ID : Any, reified Type> Aggregate<ID>.hopGradientCast(
     source: Boolean,
     local: Type,
-    maxPaths: Int = Int.MAX_VALUE,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Int, toNeighbor: Int, data: Type) -> Type = { _, _, data -> data },
-): Type = intGradientCast(source, local, hops(), maxPaths, true, accumulateData, Int::plus)
+): Type = intGradientCast(
+    source,
+    local,
+    hops(),
+    maxDiameter,
+    accumulateData,
+    Int::plus,
+)
 
 /**
- * Provided a list of [sources], propagates information from each, collecting it in a map.
+ * Computes, for each ID in `sources`, a fast-repair gradient propagation of `local` values,
+ * returning a map from each source ID to its propagated value at this device.
+ *
+ * For each `source` in `sources`, this function invokes [gradientCast] with that ID as the sole source.
+ * If no valid path exists from a given source, the `local` value is used as a fallback.
+ *
+ * @param ID The type used for neighbor identifiers.
+ * @param Value The type of data being propagated.
+ * @param Distance The comparable type representing distances.
+ * @param sources The collection of device IDs to treat as sources.
+ * @param local The local data value or default if a source is unreachable.
+ * @param bottom The zero-distance value at a source. Must be ≤ `top`.
+ * @param top The maximum distance threshold; incoming distances are clamped into `[bottom, top]`.
+ * @param metric A `Field<ID, Distance>` providing edge weights to each neighbor.
+ * @param maxDiameter Maximum allowed number of hops; paths longer than this are discarded. Defaults to `Int.MAX_VALUE`.
+ * @param accumulateData Function to combine neighbor data on each hop: receives the neighbor’s distance from source,
+ * the edge distance, and the neighbor’s data. Defaults to the identity function.
+ * @param accumulateDistance Reducer to accumulate two `Distance` values; used for path-length accumulation.
  *
- * If there are no sources and no neighbors, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Float]s.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * @return A `Map<ID, Value>` mapping each source ID to the propagated data value at this device.
+ *
+ * @see gradientCast
  */
 @JvmOverloads
 inline fun <reified ID : Any, reified Value, reified Distance : Comparable<Distance>> Aggregate<ID>.multiGradientCast(
@@ -274,8 +376,7 @@ inline fun <reified ID : Any, reified Value, reified Distance : Comparable<Dista
     bottom: Distance,
     top: Distance,
     metric: Field<ID, Distance>,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Distance, toNeighbor: Distance, data: Value) -> Value =
         { _, _, data -> data },
     crossinline accumulateDistance: Reducer<Distance>,
@@ -287,8 +388,7 @@ inline fun <reified ID : Any, reified Value, reified Distance : Comparable<Dista
             bottom,
             top,
             metric,
-            maxPaths,
-            isRiemannianManifold,
+            maxDiameter,
             accumulateData,
             accumulateDistance,
         )
@@ -296,19 +396,33 @@ inline fun <reified ID : Any, reified Value, reified Distance : Comparable<Dista
 }
 
 /**
- * Provided a list of [sources], propagates information from each, collecting it in a map.
+ * Computes a fast-repair gradient propagation from multiple sources,
+ * returning a map of the propagated values for each source ID.
+ *
+ * For each ID in `sources`, this function invokes [gradientCast] treating only that ID
+ * as the source node. It uses `metric` to compute edge distances, applies `accumulateData`
+ * at each hop, and accumulates distances by plain addition (`Double::plus`). If a source
+ * is unreachable or there are no neighbors, it falls back to `local`.
+ *
+ * @param ID The type used for device identifiers.
+ * @param Value The type of data being propagated.
+ * @param sources The collection of device IDs to use as sources.
+ * @param local The local data value or default if a source path is not found.
+ * @param metric A `Field<ID, Double>` providing edge weights to each neighbor.
+ * @param maxDiameter Maximum allowed hops before discarding a path. Defaults to `Int.MAX_VALUE`.
+ * @param accumulateData Function to update data on each hop. Receives the neighbor’s distance from the source,
+ * the edge distance to this device, and the neighbor’s data. Defaults to the identity function.
+ *
+ * @return A `Map<ID, Value>` mapping each source ID to its propagated value at this device.
  *
- * If there are no sources and no neighbors, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Float]s.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * @see gradientCast
  */
 @JvmOverloads
 inline fun <reified ID : Any, reified Value> Aggregate<ID>.multiGradientCast(
     sources: Iterable<ID>,
     local: Value,
     metric: Field<ID, Double>,
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Double, toNeighbor: Double, data: Value) -> Value = { _, _, data -> data },
 ): Map<ID, Value> = sources.associateWith { source ->
     alignedOn(source) {
@@ -316,8 +430,7 @@ inline fun <reified ID : Any, reified Value> Aggregate<ID>.multiGradientCast(
             source = source == localId,
             local = local,
             metric = metric,
-            maxPaths = maxPaths,
-            isRiemannianManifold = isRiemannianManifold,
+            maxDiameter = maxDiameter,
             accumulateData,
             Double::plus,
         )
@@ -325,19 +438,33 @@ inline fun <reified ID : Any, reified Value> Aggregate<ID>.multiGradientCast(
 }
 
 /**
- * Provided a list of [sources], propagates information from each, collecting it in a map.
+ * For each ID in `sources`, propagates data from that source using a fast-repair integer gradient,
+ * and collects the results in a map from source ID to propagated value.
  *
- * If there are no sources and no neighbors, default to [local] value.
- * The [metric] function is used to compute the distance between devices in form of a field of [Float]s.
- * [accumulateData] is used to modify data from neighbors on the fly, and defaults to the identity function.
+ * Delegates to [intGradientCast], which uses a default distance of `0` to `Int.MAX_VALUE`,
+ * the hop-count metric (`hops()`), and non-overflowing addition for distance accumulation.
+ * If a source is unreachable or there are no neighbors, the `local` value is used.
+ *
+ * @param ID The type used for device identifiers.
+ * @param Value The type of data being propagated.
+ * @param sources Collection of device IDs to use as sources.
+ * @param local The local data value or default if a source path is not found.
+ * @param metric A `Field<ID, Int>` providing the hop-count metric to each neighbor. Defaults to `hops()`.
+ * @param maxDiameter Maximum allowed hops before discarding a path. Defaults to `Int.MAX_VALUE`.
+ * @param accumulateData Function to update neighbor data on each hop. Receives the neighbor’s
+ * distance from the source, the hop increment (always 1),
+ * and the neighbor’s data. Defaults to the identity function.
+ *
+ * @return A `Map<ID, Value>` mapping each source ID to its propagated value at this device.
+ *
+ * @see intGradientCast
  */
 @JvmOverloads
 inline fun <reified ID : Any, reified Value> Aggregate<ID>.multiIntGradientCast(
     sources: Iterable<ID>,
     local: Value,
     metric: Field<ID, Int> = hops(),
-    maxPaths: Int = Int.MAX_VALUE,
-    isRiemannianManifold: Boolean = true,
+    maxDiameter: Int = Int.MAX_VALUE,
     noinline accumulateData: (fromSource: Int, toNeighbor: Int, data: Value) -> Value = { _, _, data -> data },
 ): Map<ID, Value> = sources.associateWith { source ->
     alignedOn(source) {
@@ -345,8 +472,7 @@ inline fun <reified ID : Any, reified Value> Aggregate<ID>.multiIntGradientCast(
             source = source == localId,
             local = local,
             metric = metric,
-            maxPaths = maxPaths,
-            isRiemannianManifold = isRiemannianManifold,
+            maxDiameter = maxDiameter,
             accumulateData = accumulateData,
             accumulateDistance = Int::nonOverflowingPlus,
         )
@@ -354,82 +480,153 @@ inline fun <reified ID : Any, reified Value> Aggregate<ID>.multiIntGradientCast(
 }
 
 /**
- * A path segment along a potential field that reaches the current device,
- * after [distance], starting from [source],
- * passing [through] an intermediate direct neighbor,
- * carrying [data].
+ * A path segment representing a route from a source device to the current device.
+ *
+ * Each segment holds the total distance traveled, the list of hops (device IDs) along the path,
+ * and the data carried from the source at each step.
  *
- * This data class is designed to be shared within [gradientCast] and derivative functions.
+ * This data class is used internally by [gradientCast] and related functions to track
+ * path metadata and propagated values.
+ *
+ * @param ID The type used for device identifiers in the path.
+ * @param Value The type of data being propagated along the path.
+ * @param Distance The comparable type representing path lengths.
+ * @param distance The total accumulated distance from the source to this device.
+ * @param hops The sequence of device IDs, starting with the source and ending with the current device.
+ * @param data The value propagated along this path.
  */
 @Serializable
-data class GradientPath<ID : Any, Value, Distance : Comparable<Distance>>(
+@PublishedApi
+internal data class GradientPath<ID : Any, Value, Distance : Comparable<Distance>>(
     val distance: Distance,
-    val through: ID,
-    val source: ID,
+    val hops: List<ID>,
     val data: Value,
-) {
+) : Comparable<GradientPath<ID, Value, Distance>> {
+
+    /**
+     * The ID of the original source node where this path begins.
+     */
+    val source: ID get() = hops.first()
+
+    /**
+     * The ID of the neighbor device on the next hop toward this device.
+     */
+    val nextHop: ID get() = hops.last()
+
+    /**
+     * The number of hops (segments) in this path.
+     */
+    val length: Int get() = hops.size
 
     /**
-     * Returns `true` if this path has been directly provided by a source
-     * (namely, [source] == [through]).
+     * Returns true if this path includes the specified device ID.
+     *
+     * @param id The device identifier to check.
+     * @return True if the path hops contain the given ID.
      */
-    val comesFromSource get() = source == through
+    operator fun contains(id: ID): Boolean = hops.contains(id)
 
     /**
-     * Updates this path adding information about the local device.
+     * Creates a new path by extending this one with a neighbor hop.
+     *
+     * @param neighbor The ID of the next hop (neighbor device).
+     * @param distanceToNeighbor The edge distance to the neighbor.
+     * @param bottom The minimum allowed distance (source base).
+     * @param top The maximum allowed distance (distance clamp).
+     * @param accumulateDistance Reducer to combine two distances and enforce bounds.
+     * @param accumulateData Function to update the carried data when crossing the edge.
+     * @return A new [GradientPath] including the neighbor hop, updated distance, and data.
      */
     @OptIn(DelicateCollektiveApi::class)
-    inline fun updateDistance(
+    inline fun update(
         neighbor: ID,
         distanceToNeighbor: Distance,
         bottom: Distance,
         top: Distance,
         crossinline accumulateDistance: Reducer<Distance>,
-    ): UpdatedGradientPath<ID, Value, Distance> {
-        val totalDistance = accumulateDistance(distance, distanceToNeighbor).coerceIn(bottom, top)
-        check(totalDistance >= distance && totalDistance >= distanceToNeighbor) {
-            "The provided distance accumulation function violates the triangle inequality: " +
-                "accumulating $distance and $distanceToNeighbor produced $totalDistance"
+        crossinline accumulateData: (fromSource: Distance, toNeighbor: Distance, data: Value) -> Value,
+    ): GradientPath<ID, Value, Distance> {
+        val totalDistance = accumulate(bottom, top, distance, distanceToNeighbor, accumulateDistance)
+        val updatedData = accumulateData(distance, distanceToNeighbor, data)
+        return GradientPath(totalDistance, hops + neighbor, updatedData)
+    }
+
+    override fun compareTo(other: GradientPath<ID, Value, Distance>) =
+        compareBy<GradientPath<ID, Value, Distance>> { it.distance }.compare(this, other)
+
+    // Check if the path is invalid because there is a shortcut through an intermediate neighboring hop
+    @PublishedApi
+    internal fun isInvalidViaShortcut(
+        accDist: Distance?,
+        neighbors: Set<ID>,
+        neighborAccumulatedDistances: Map<ID, Distance?>,
+    ): Boolean = accDist != null &&
+        hops.asSequence()
+            .filter { it in neighbors }
+            .map { neighborAccumulatedDistances[it] }
+            .any { it == null || it < accDist }
+
+    // Check if all hops that appear in both paths have the same order
+    @PublishedApi
+    internal fun allCommonHopsAppearInTheSameOrderOf(
+        other: GradientPath<ID, Value, Distance>,
+        pathsHopSets: Map<ID, Set<ID>>,
+    ): Boolean {
+        val otherHops = pathsHopSets[other.nextHop].orEmpty()
+        val commonHops = hops.filter { it in otherHops }
+        return when (commonHops.size) {
+            0, 1 -> true
+            else -> {
+                // otherHops and commonHops must have the same order for all elements
+                val commonIterator = commonHops.iterator()
+                val otherIterator = otherHops.iterator()
+                var matches = 0
+                while (commonIterator.hasNext() && otherIterator.hasNext()) {
+                    val common = commonIterator.next()
+                    val matchesSoFar = matches
+                    while (otherIterator.hasNext() && matchesSoFar == matches) {
+                        if (common == otherIterator.next()) {
+                            matches++
+                        }
+                    }
+                }
+                matches == commonHops.size
+            }
         }
-        return UpdatedGradientPath(neighbor, totalDistance, distanceToNeighbor, this)
     }
 }
 
 /**
- * A two-segment path along a potential field that reaches the current device,
- * after [totalDistance], starting from [source],
- * passing through an intermediate direct [neighbor]
- * distant [distanceToNeighbor] from the current device,
- * to which it arrives through [path].
- *
- * This class is meant to be used internally by the [gradientCast] function,
- * and it is not intended to be used outside it.
+ * Combines two distances with bounds checking and triangle-inequality enforcement.
+ *
+ * Uses the provided accumulator to sum `distance` and `distanceToNeighbor`, clamps the result
+ * between `bottom` and `top`, and verifies the triangle inequality.
+ *
+ * @param D The comparable type used for distances.
+ * @param bottom The minimum allowed distance.
+ * @param top The maximum allowed distance.
+ * @param distance The current accumulated distance.
+ * @param distanceToNeighbor The edge distance to add.
+ * @param accumulator Reducer function to combine two distances.
+ * @return The new clamped distance result.
+ * @throws IllegalStateException if the accumulator violates the triangle inequality.
  */
-@DelicateCollektiveApi
-data class UpdatedGradientPath<ID : Any, Value, Distance : Comparable<Distance>>(
-    val neighbor: ID,
-    val totalDistance: Distance,
-    val distanceToNeighbor: Distance,
-    val path: GradientPath<ID, Value, Distance>,
-) : Comparable<UpdatedGradientPath<ID, Value, Distance>> {
-
-    val source get() = path.source
-
-    /**
-     * The neighbor's neighbor from which this gradient information is coming.
-     */
-    val neighborsNeighbor get() = path.through
-
-    override fun compareTo(other: UpdatedGradientPath<ID, Value, Distance>): Int =
-        totalDistance.compareTo(other.totalDistance)
-
-    /**
-     * Convert this path to a [GradientPath] by accumulating the data from the source to the neighbor.
-     *
-     * @param accumulateData function to accumulate data from the source to the neighbor.
-     */
-    fun toLocalPath(
-        accumulateData: (fromSource: Distance, toNeighbor: Distance, data: Value) -> Value,
-    ): GradientPath<ID, Value, Distance> =
-        GradientPath(totalDistance, neighbor, source, accumulateData(path.distance, distanceToNeighbor, path.data))
+@OptIn(ExperimentalContracts::class)
+@PublishedApi
+internal inline fun <D : Comparable<D>> accumulate(
+    bottom: D,
+    top: D,
+    distance: D,
+    distanceToNeighbor: D,
+    accumulator: Reducer<D>,
+): D {
+    contract {
+        callsInPlace(accumulator, kotlin.contracts.InvocationKind.EXACTLY_ONCE)
+    }
+    val totalDistance = accumulator(distance, distanceToNeighbor).coerceIn(bottom, top)
+    check(totalDistance >= distance && totalDistance >= distanceToNeighbor) {
+        "The provided distance accumulation function violates the triangle inequality: " +
+            "accumulating $distance and $distanceToNeighbor produced $totalDistance"
+    }
+    return totalDistance
 }
diff --git a/collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt b/collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt
index 6b487a3aa..e09460d1b 100644
--- a/collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt
+++ b/collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt
@@ -35,8 +35,8 @@ class GradientCastTest {
     fun `GradientPath is serializable`() {
         val network = SerializingMailbox()
         aggregate(0, network) {
-            neighboring(GradientPath(0, 0, 0, 0))
-            neighboring(GradientPath(0, 0, 0, listOf(1, 2, 3)))
+            neighboring(GradientPath(0, emptyList<String>(), 0))
+            neighboring(GradientPath(0, listOf(1, 2, 3), 0))
         }
         assertNotNull(network.serializeToString(1))
     }
@@ -116,24 +116,10 @@ class GradientCastTest {
         }
     }
 
-    @Test
-    fun `gradientCast keeps working when paths are limited`() {
-        for (maxPaths in 1..5) {
-            testManhattanDistance {
-                hopDistanceTo(localId == 0, maxPaths = maxPaths)
-            }
-        }
-    }
-
     @Test
     fun `gradientCast supports multiple sources`() {
-        val maxPaths = listOf(1, 2, 3, 5, Int.MAX_VALUE)
-        vonNeumannGrid(10, 10, generateSequence { Int.MAX_VALUE }.take(maxPaths.size).toList()) {
-            maxPaths.map {
-                alignedOn(it) {
-                    hopDistanceTo(localId == 0 || localId == 99, maxPaths = it)
-                }
-            }
+        vonNeumannGrid(10, 10, Int.MAX_VALUE) {
+            hopDistanceTo(localId == 0 || localId == 99)
         }.apply {
             repeat(10) {
                 // In a diameter cycles all information should have been propagated
@@ -142,9 +128,8 @@ class GradientCastTest {
             nodes.forEach { node ->
                 val (x, y) = positionOf(node.id)
                 val manhattanDistance = min(x + y, abs(9 - x) + abs(9 - y))
-                val distances = node.value.toSet()
-                assertEquals(1, distances.size)
-                assertEquals(manhattanDistance.toInt(), distances.single(), "Node $node has not the expected distance")
+                val distance = node.value
+                assertEquals(manhattanDistance.toInt(), distance, "Node $node has not the expected distance")
             }
         }
     }
diff --git a/collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt b/collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt
index ea776a7bc..6aae22bfd 100644
--- a/collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt
+++ b/collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt
@@ -101,10 +101,8 @@ class Node<R>(
             @Suppress("UNCHECKED_CAST")
             override fun <Value> dataAt(path: Path, dataSharingMethod: DataSharingMethod<Value>): Map<Int, Value> =
                 messageBuffer
-                    .mapValues { it.value.sharedData.getOrElse(path) { NoValue } as Value }
-                    .filterValues { it != NoValue }
+                    .filterValues { message -> message.sharedData.containsKey(path) }
+                    .mapValues { (_, message) -> message.sharedData[path] as Value }
         }
     }
-
-    private object NoValue
 }


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors and enhances the `gradientCast` API by:
- Simplifying `Node.dataAt` to reduce memory allocations and remove sentinel values
- Renaming and re-documenting `maxPaths` to `maxDiameter` in `distanceTo` and `hopDistanceTo`
- Updating tests in `GradientCastTest` to match the new API signature

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

| File                                                                                     | Description                                                     |
|------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt       | Simplified `dataAt` implementation and removed `NoValue` object |
| collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt | Updated `GradientPath` constructor usage and removed path‐limit tests |
| collektive-stdlib/src/commonMain/kotlin/it/unibo/collektive/stdlib/spreading/DistanceTo.kt | Renamed `maxPaths` parameters to `maxDiameter` and updated docs |


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt:118**
* Tests for finite `maxDiameter` values were removed. To ensure the new `maxDiameter` parameter behaves correctly at different limits, add back parameterized tests covering small and large diameters.
```
fun `gradientCast supports multiple sources`() {
```
**collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt:121**
* Switching to only `Int.MAX_VALUE` for `maxDiameter` skips edge‐case tests for smaller diameters. Consider adding multiple grid runs with varying `maxDiameter` to validate correct path pruning.
```
vonNeumannGrid(10, 10, Int.MAX_VALUE) {
```
**collektive-stdlib/src/commonTest/kotlin/it/unibo/collektive/stdlib/test/GradientCastTest.kt:38**
* The type parameter for `GradientPath` should match the network node identifier type (likely `Int`). Using `emptyList<String>()` may cause a type mismatch; consider `emptyList<Int>()` instead.
```
neighboring(GradientPath(0, emptyList<String>(), 0))
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T16:30:51Z

Review comments:
diff_hunk: @@ -101,10 +101,8 @@ class Node<R>(
             @Suppress("UNCHECKED_CAST")
             override fun <Value> dataAt(path: Path, dataSharingMethod: DataSharingMethod<Value>): Map<Int, Value> =
                 messageBuffer
-                    .mapValues { it.value.sharedData.getOrElse(path) { NoValue } as Value }
-                    .filterValues { it != NoValue }
+                    .filterValues { message -> message.sharedData.containsKey(path) }
+                    .mapValues { (_, message) -> message.sharedData[path] as Value }
path: collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt
user: login: Copilot
body: Chaining `filterValues` and `mapValues` performs two passes over `messageBuffer` and two map allocations. Consider using a single iteration with `mapNotNull` then `toMap()` to avoid redundant lookups and intermediate maps:
```kotlin
messageBuffer
  .mapNotNull { (id, message) ->
    message.sharedData[path]?.let { id to it as Value }
  }
  .toMap()
```
```suggestion
                    .mapNotNull { (id, message) ->
                        message.sharedData[path]?.let { id to it as Value }
                    }
                    .toMap()
```
created_at: 2025-05-23T16:30:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,10 +101,8 @@ class Node<R>(
             @Suppress("UNCHECKED_CAST")
             override fun <Value> dataAt(path: Path, dataSharingMethod: DataSharingMethod<Value>): Map<Int, Value> =
                 messageBuffer
-                    .mapValues { it.value.sharedData.getOrElse(path) { NoValue } as Value }
-                    .filterValues { it != NoValue }
+                    .filterValues { message -> message.sharedData.containsKey(path) }
+                    .mapValues { (_, message) -> message.sharedData[path] as Value }
path: collektive-test-tooling/src/commonMain/kotlin/it/unibo/collektive/testing/Node.kt
user: login: Copilot
body: Using `message.sharedData[path] as Value` can potentially cast a null to `Value` and crash. If presence is guaranteed, prefer `message.sharedData.getValue(path) as Value` to make non-null intent explicit.
```suggestion
                    .mapValues { (_, message) -> message.sharedData.getValue(path) as Value }
```
created_at: 2025-05-23T16:30:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-23T16:51:16Z
body: ## [Codecov](https://app.codecov.io/gh/Collektive/collektive/pull/1001?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Collektive) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 68.30%. Comparing base [(`ac00251`)](https://app.codecov.io/gh/Collektive/collektive/commit/ac002515ddc33292cbdee2d67aa23f0f743ca394?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Collektive) to head [(`d2c5db6`)](https://app.codecov.io/gh/Collektive/collektive/commit/d2c5db6e8dd5e8116693de5ce4bb55a90ece48a8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Collektive).
> Report is 4 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1001      +/-   ##
==========================================
+ Coverage   68.01%   68.30%   +0.28%     
==========================================
  Files          26       26              
  Lines         691      691              
  Branches      122      122              
==========================================
+ Hits          470      472       +2     
+ Misses        151      149       -2     
  Partials       70       70              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Collektive/collektive/pull/1001?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Collektive).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Collektive).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-26T00:42:50Z
body: :tada: This PR is included in version 25.0.0 :tada:

The release is available on:
- `25.0.0`
- [GitHub release](https://github.com/Collektive/collektive/releases/tag/25.0.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE