From 0cb7be5f4727a81bb511c20a2dae9abe85a2829b Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Mon, 22 Jun 2026 17:46:26 +0200 Subject: [PATCH 01/12] feat(sensors): add TimeSensor, LagSensor, and RandomGenerator capabilities Add three minimal sensor capability traits mirroring DistanceSensor: TimeSensor (deltaTime/timestamp), LagSensor[Lag] (senseLag), and RandomGenerator (nextRandom), each with a static using-based facade and unit tests covering facade delegation, SharedData iteration, and random range/advancement. Implements migration task 01. Co-Authored-By: Claude Sonnet 4.6 --- .../it/unibo/scafi/sensors/LagSensor.scala | 31 +++++++++++++ .../unibo/scafi/sensors/RandomGenerator.scala | 26 +++++++++++ .../it/unibo/scafi/sensors/TimeSensor.scala | 44 +++++++++++++++++++ .../language/sensors/LagSensorTests.scala | 25 +++++++++++ .../sensors/RandomGeneratorTests.scala | 32 ++++++++++++++ .../language/sensors/TimeSensorTests.scala | 30 +++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/sensors/LagSensor.scala create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/sensors/RandomGenerator.scala create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/sensors/TimeSensor.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/sensors/LagSensor.scala b/scafi3-core/src/main/scala/it/unibo/scafi/sensors/LagSensor.scala new file mode 100644 index 00000000..5f421760 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/sensors/LagSensor.scala @@ -0,0 +1,31 @@ +package it.unibo.scafi.sensors + +import it.unibo.scafi.language.AggregateFoundation + +/** + * If an aggregate foundation implements this trait, it provides the communication lag perceived from each neighbour. + * @tparam Lag + * the type used to represent lag (e.g. [[scala.concurrent.duration.FiniteDuration]]) + */ +trait LagSensor[Lag]: + this: AggregateFoundation => + + /** + * @return + * an aggregate value holding the lag from each neighbour (and self). + */ + def senseLag: SharedData[Lag] + +object LagSensor: + + /** + * A static facade for [[LagSensor.senseLag]]. + * @param language + * the aggregate foundation that provides the lag sensor + * @tparam Lag + * the type used to represent lag + * @return + * an aggregate value holding the lag from each neighbour (and self) + */ + def senseLag[Lag](using language: AggregateFoundation & LagSensor[Lag]): language.SharedData[Lag] = + language.senseLag diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/sensors/RandomGenerator.scala b/scafi3-core/src/main/scala/it/unibo/scafi/sensors/RandomGenerator.scala new file mode 100644 index 00000000..8ce0cad4 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/sensors/RandomGenerator.scala @@ -0,0 +1,26 @@ +package it.unibo.scafi.sensors + +import it.unibo.scafi.language.AggregateFoundation + +/** + * If an aggregate foundation implements this trait, it provides a stream of pseudo-random numbers, one draw per round. + */ +trait RandomGenerator: + this: AggregateFoundation => + + /** + * @return + * a fresh pseudo-random number, uniformly distributed in `[0, 1)`. + */ + def nextRandom: Double + +object RandomGenerator: + + /** + * A static facade for [[RandomGenerator.nextRandom]]. + * @param language + * the aggregate foundation that provides the random generator + * @return + * a fresh pseudo-random number, uniformly distributed in `[0, 1)` + */ + def nextRandom(using language: AggregateFoundation & RandomGenerator): Double = language.nextRandom diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/sensors/TimeSensor.scala b/scafi3-core/src/main/scala/it/unibo/scafi/sensors/TimeSensor.scala new file mode 100644 index 00000000..e72275d5 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/sensors/TimeSensor.scala @@ -0,0 +1,44 @@ +package it.unibo.scafi.sensors + +import scala.concurrent.duration.FiniteDuration + +import it.unibo.scafi.language.AggregateFoundation + +/** + * If an aggregate foundation implements this trait, it provides the local notion of time: the wall-clock instant and + * the time elapsed since the previous round. + */ +trait TimeSensor: + this: AggregateFoundation => + + /** + * @return + * the time elapsed between the previous round and the current one. + */ + def deltaTime: FiniteDuration + + /** + * @return + * the wall-clock instant at which the current round is evaluated. + */ + def timestamp: Long + +object TimeSensor: + + /** + * A static facade for [[TimeSensor.deltaTime]]. + * @param language + * the aggregate foundation that provides the time sensor + * @return + * the time elapsed between the previous round and the current one + */ + def deltaTime(using language: AggregateFoundation & TimeSensor): FiniteDuration = language.deltaTime + + /** + * A static facade for [[TimeSensor.timestamp]]. + * @param language + * the aggregate foundation that provides the time sensor + * @return + * the wall-clock instant at which the current round is evaluated + */ + def timestamp(using language: AggregateFoundation & TimeSensor): Long = language.timestamp diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala new file mode 100644 index 00000000..d5a196bd --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala @@ -0,0 +1,25 @@ +package it.unibo.scafi.language.sensors + +import scala.concurrent.duration.* + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.foundation.AggregateFoundationMock +import it.unibo.scafi.sensors.LagSensor + +class LagSensorTests extends UnitTest: + + given CanEqual[FiniteDuration, FiniteDuration] = CanEqual.derived + + type Language = AggregateFoundation & LagSensor[FiniteDuration] + + val mockedLags: Seq[FiniteDuration] = Seq(0.millis, 10.millis, 20.millis, 30.millis) + + given lang: Language = new AggregateFoundationMock with LagSensor[FiniteDuration]: + override def senseLag: MockAggregate[FiniteDuration] = MockAggregate(mockedLags) + + "LagSensor.senseLag" should "include all neighbours and self when iterated" in: + LagSensor.senseLag.toList shouldBe mockedLags.toList + + it should "exclude self when withoutSelf is used" in: + LagSensor.senseLag.withoutSelf.toList shouldBe mockedLags.tail.toList diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala new file mode 100644 index 00000000..7cd4883b --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala @@ -0,0 +1,32 @@ +package it.unibo.scafi.language.sensors + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.foundation.AggregateFoundationMock +import it.unibo.scafi.sensors.RandomGenerator + +class RandomGeneratorTests extends UnitTest: + + type Language = AggregateFoundation & RandomGenerator + + val rng: scala.util.Random = new scala.util.Random(seed = 12345L) + + given lang: Language = new AggregateFoundationMock with RandomGenerator: + override def nextRandom: Double = rng.nextDouble() + + "RandomGenerator.nextRandom" should "delegate to the foundation" in: + val r1 = lang.nextRandom + val r2 = RandomGenerator.nextRandom + // both calls advance the same rng; they should be different draws but both in [0,1) + r1 should (be >= 0.0 and be < 1.0) + r2 should (be >= 0.0 and be < 1.0) + + it should "return values in [0, 1)" in: + for _ <- 1 to 100 do + val v = RandomGenerator.nextRandom + v should (be >= 0.0 and be < 1.0) + + it should "advance the stream across rounds (successive draws differ)" in: + val a = RandomGenerator.nextRandom + val b = RandomGenerator.nextRandom + a should not equal b diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala new file mode 100644 index 00000000..c94eebb7 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala @@ -0,0 +1,30 @@ +package it.unibo.scafi.language.sensors + +import scala.concurrent.duration.* + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.foundation.AggregateFoundationMock +import it.unibo.scafi.sensors.TimeSensor + +class TimeSensorTests extends UnitTest: + + given CanEqual[FiniteDuration, FiniteDuration] = CanEqual.derived + + type Language = AggregateFoundation & TimeSensor + + given lang: Language = new AggregateFoundationMock with TimeSensor: + override def deltaTime: FiniteDuration = 100.millis + override def timestamp: Long = 42L + + "TimeSensor.deltaTime" should "delegate to the foundation" in: + TimeSensor.deltaTime shouldBe lang.deltaTime + + "TimeSensor.timestamp" should "delegate to the foundation" in: + TimeSensor.timestamp shouldBe lang.timestamp + + "TimeSensor.deltaTime" should "return the expected FiniteDuration" in: + TimeSensor.deltaTime shouldBe 100.millis + + "TimeSensor.timestamp" should "return the expected Long" in: + TimeSensor.timestamp shouldBe 42L From da4dce93ede39524bd44defe512927a452f65c44 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Mon, 22 Jun 2026 18:04:12 +0200 Subject: [PATCH 02/12] feat(libraries): add FieldUtilsLibrary with minHoodSelector, maxHoodSelector, mergeHood Port the genuinely-missing FieldUtils primitives: argmin/argmax neighbour selectors (excluding self, with tie-breaking by device id) and key-wise map merging. Document idiomatic scafi3 replacements for legacy sumHood, anyHood, everyHood, reifyField, etc. Exported from All.scala. Implements migration task 02. Co-Authored-By: Claude Sonnet 4.6 --- .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../scafi/libraries/FieldUtilsLibrary.scala | 113 ++++++++++++++++++ .../libraries/FieldUtilsLibraryTests.scala | 89 ++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index 73e3b97b..b75011f8 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -11,6 +11,7 @@ object All: export CommonLibrary.{ *, given } export ExchangeCalculusLibrary.{ *, given } export FieldCalculusLibrary.{ *, given } + export FieldUtilsLibrary.{ *, given } export FoldingLibrary.{ *, given } export GradientLibrary.{ *, given } export MathLibrary.{ *, given } diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala new file mode 100644 index 00000000..285eface --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala @@ -0,0 +1,113 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.language.AggregateFoundation + +import cats.syntax.all.catsSyntaxTuple3Semigroupal + +/** + * Field reduction utilities providing argmin/argmax selectors and map-merging over neighbour fields. + * + * Several legacy `FieldUtils` operations are already expressible with the existing APIs: + * {{{ + * // sumHood(e) → neighborValues(e).fold(zero)(_ + _) + * // anyHood(e) → neighborValues(e).fold(false)(_ || _) + * // everyHood(e) → neighborValues(e).fold(true)(_ && _) + * // minHoodLoc(d)(e) → neighborValues(e).withoutSelf.fold(d)(_.min(_)) + * // includingSelf/excludingSelf → pass `field` vs `field.withoutSelf` + * // reifyField(e) → (device, neighborValues(e)).mapN(_ -> _).toList.toMap + * }}} + * + * This object adds only the genuinely-missing primitives: argmin/argmax selectors and map merging. + */ +object FieldUtilsLibrary: + + /** + * Returns the `data` value associated with the neighbour minimising `key`, excluding the local device. Total: + * returns `default` for an empty neighbourhood. Ties broken by the smaller device identifier. + * + * To include the local device in the comparison, pass `key` and `data` with self already present and use the + * `includingSelf` variant by calling this method on the unfiltered `SharedData`. + * + * @param key + * the shared field of keys used to rank neighbours + * @param data + * the shared field of data values to select from + * @param default + * the value returned when no neighbours are present after excluding self + * @tparam K + * the key type; must have an [[Ordering]] + * @tparam V + * the data type to return + * @return + * the data value of the neighbour with the minimum key, or `default` + * @see [[maxHoodSelector]] for the dual operation + */ + def minHoodSelector[K: Ordering, V](using language: AggregateFoundation)(using Ordering[language.DeviceId])( + key: language.SharedData[K], + data: language.SharedData[V], + default: V, + ): V = + (key, data, language.device) + .mapN { (k, v, id) => (k, id, v) } + .withoutSelf + .minByOption { (k, id, _) => (k, id) } + .map(_._3) + .getOrElse(default) + + /** + * Returns the `data` value associated with the neighbour maximising `key`, excluding the local device. Total: + * returns `default` for an empty neighbourhood. Ties broken by the larger device identifier. + * + * @param key + * the shared field of keys used to rank neighbours + * @param data + * the shared field of data values to select from + * @param default + * the value returned when no neighbours are present after excluding self + * @tparam K + * the key type; must have an [[Ordering]] + * @tparam V + * the data type to return + * @return + * the data value of the neighbour with the maximum key, or `default` + * @see [[minHoodSelector]] for the dual operation + */ + def maxHoodSelector[K: Ordering, V](using language: AggregateFoundation)(using Ordering[language.DeviceId])( + key: language.SharedData[K], + data: language.SharedData[V], + default: V, + ): V = + (key, data, language.device) + .mapN { (k, v, id) => (k, id, v) } + .withoutSelf + .maxByOption { (k, id, _) => (k, id) } + .map(_._3) + .getOrElse(default) + + /** + * Key-wise merge of map-valued neighbour contributions. On key collision the `overwrite` policy decides the + * surviving value: `overwrite(existing, incoming)`. + * + * @param maps + * the shared field of maps to merge (may include self) + * @param overwrite + * collision policy: first argument is the value accumulated so far, second is the incoming value + * @tparam K + * the map key type + * @tparam V + * the map value type + * @return + * a single [[Map]] with all keys from all neighbours, resolved via `overwrite` + * @see [[FoldingLibrary]] for simple `fold`/`foldWithoutSelf` patterns + */ + def mergeHood[K, V](using language: AggregateFoundation)( + maps: language.SharedData[Map[K, V]], + )(overwrite: (V, V) => V): Map[K, V] = + maps.foldLeft(Map.empty[K, V]) { (acc, m) => + m.foldLeft(acc) { case (a, (k, v)) => + a.updatedWith(k): + case None => Some(v) + case Some(existing) => Some(overwrite(existing, v)) + } + } +end FieldUtilsLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala new file mode 100644 index 00000000..3bd9cae7 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala @@ -0,0 +1,89 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.language.foundation.AggregateFoundationMock +import it.unibo.scafi.libraries.All.{ localId, neighborValues } +import it.unibo.scafi.libraries.FieldUtilsLibrary.{ maxHoodSelector, mergeHood, minHoodSelector } +import it.unibo.scafi.message.{ Codable, Codables } +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.environment.IntNetworkManager + +import org.scalatest.Inspectors + +class FieldUtilsLibraryTests extends UnitTest, Inspectors: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + // ── unit tests (pure single-device, no network) ────────────────────────── + + given lang: AggregateFoundationMock = AggregateFoundationMock() + // lang.DeviceId = Int; mock device field = Seq(0,1,2,...,9) with self at index 0 + + "minHoodSelector" should "return data of the neighbour with minimum key (self excluded)" in: + // withoutSelf leaves: (key=1, dev=1, data="b"), (key=2, dev=2, data="c") + val key = lang.mockField(Seq(3, 1, 2)) + val data = lang.mockField(Seq("a", "b", "c")) + minHoodSelector(key, data, "z") shouldBe "b" + + "maxHoodSelector" should "return data of the neighbour with maximum key (self excluded)" in: + // withoutSelf leaves: (key=1, dev=1, data="b"), (key=2, dev=2, data="c") → max key=2 → "c" + val key = lang.mockField(Seq(3, 1, 2)) + val data = lang.mockField(Seq("a", "b", "c")) + maxHoodSelector(key, data, "z") shouldBe "c" + + "minHoodSelector" should "return default when no neighbours remain after excluding self" in: + val key = lang.mockField(Seq(42)) + val data = lang.mockField(Seq("only-self")) + minHoodSelector(key, data, "default") shouldBe "default" + + "maxHoodSelector" should "return default when no neighbours remain after excluding self" in: + val key = lang.mockField(Seq(42)) + val data = lang.mockField(Seq("only-self")) + maxHoodSelector(key, data, "default") shouldBe "default" + + "minHoodSelector" should "break ties by the smaller device id" in: + // withoutSelf: (key=1, dev=1, data="b"), (key=1, dev=2, data="c") → tie → dev=1 wins + val key = lang.mockField(Seq(99, 1, 1)) + val data = lang.mockField(Seq("self", "b", "c")) + minHoodSelector(key, data, "z") shouldBe "b" + + "maxHoodSelector" should "break ties by the larger device id" in: + // withoutSelf: (key=5, dev=1, data="b"), (key=5, dev=2, data="c") → tie → dev=2 wins + val key = lang.mockField(Seq(0, 5, 5)) + val data = lang.mockField(Seq("self", "b", "c")) + maxHoodSelector(key, data, "z") shouldBe "c" + + "mergeHood" should "merge maps summing values on key collision" in: + val maps = lang.mockField(Seq(Map("a" -> 1), Map("a" -> 2, "b" -> 3))) + mergeHood(maps)(_ + _) shouldBe Map("a" -> 3, "b" -> 3) + + it should "keep the first value on key collision when overwrite is (x,_)=>x" in: + val maps = lang.mockField(Seq(Map("a" -> 1), Map("a" -> 2, "b" -> 3))) + mergeHood(maps)((x, _) => x) shouldBe Map("a" -> 1, "b" -> 3) + + it should "return empty Map for an empty field" in: + val maps = lang.mockField(Seq.empty[Map[String, Int]]) + mergeHood(maps)(_ + _) shouldBe Map.empty[String, Int] + + // ── network test ───────────────────────────────────────────────────────── + + "minHoodSelector" should "select the minimum-key neighbour over a 2×2 Moore grid" in: + type TestCtx = ExchangeAggregateContext[Int] + // Explicit type args so the compiler knows TestCtx.DeviceId = Int + // and can resolve Ordering[Int] from the standard library. + val env = mooreGrid[Int, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val ids = neighborValues(localId) + minHoodSelector(ids, ids, Int.MaxValue) + + (0 until 2).foreach(_ => env.cycleInOrder()) + + // 2×2 Moore grid (all-connected): nodes 0,1,2,3 + // node 0 → minimum neighbour id = 1 (excludes self 0) + // nodes 1,2,3 → minimum neighbour id = 0 + val expected = Map(0 -> 1, 1 -> 0, 2 -> 0, 3 -> 0) + forAll(expected.toSeq): (id, minNbr) => + env.status.get(id) shouldBe Some(minNbr) +end FieldUtilsLibraryTests From d255c3d7b40b9c66e800af796c3a4e34799b8b87 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Tue, 23 Jun 2026 09:01:29 +0200 Subject: [PATCH 03/12] feat(libraries): add StateLibrary with round-to-round state management blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements migration/03: roundCounter, remember, constant, keep, keepTrue, captureChange, countChanges, goesUp, goesDown, delay, once — all built on evolve with no captured vars and no default arguments (overloads instead). Also fixes wildcard import violations in sensor tests. Co-Authored-By: Claude Sonnet 4.6 --- .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../unibo/scafi/libraries/StateLibrary.scala | 85 ++++++++++ .../language/sensors/LagSensorTests.scala | 2 +- .../language/sensors/TimeSensorTests.scala | 3 +- .../scafi/libraries/StateLibraryTests.scala | 145 ++++++++++++++++++ 5 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/StateLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index b75011f8..e9751c98 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -15,5 +15,6 @@ object All: export FoldingLibrary.{ *, given } export GradientLibrary.{ *, given } export MathLibrary.{ *, given } + export StateLibrary.{ *, given } export CommonBoundaries.{ *, given } export cats.syntax.all.* diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala new file mode 100644 index 00000000..3b80b53d --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala @@ -0,0 +1,85 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax + +/** Round-to-round state helpers built purely on `evolve`. No communication. */ +object StateLibrary: + + /** Counts how many rounds this expression has been evaluated, starting at 1. */ + def roundCounter(using language: AggregateFoundation & FieldCalculusSyntax): Long = + language.evolve(0L)(_ + 1) + + /** Latches the first value seen and keeps returning it forever. */ + def remember[T](value: => T)(using language: AggregateFoundation & FieldCalculusSyntax): T = + language.evolve(value)(identity) + + /** Alias of [[remember]]. */ + def constant[T](value: => T)(using language: AggregateFoundation & FieldCalculusSyntax): T = + remember(value) + + /** Keeps the last non-empty optional ever produced; `None` until the first `Some`. */ + def keep[T](expr: => Option[T])(using language: AggregateFoundation & FieldCalculusSyntax): Option[T] = + language.evolve(Option.empty[T])(old => expr.orElse(old)) + + /** Latches a boolean event to `true` forever once it first holds. */ + def keepTrue(expr: => Boolean)(using language: AggregateFoundation & FieldCalculusSyntax): Boolean = + language.evolve(false)(old => old || expr) + + /** True exactly on rounds where `x` differs from the previous round; defaults `initially` to `true`. */ + def captureChange[T](x: T)(using language: AggregateFoundation & FieldCalculusSyntax)(using + CanEqual[T, T], + ): Boolean = + captureChange(x, true) + + /** + * True exactly on rounds where `x` differs from the previous round. + * @param initially + * value to return on the very first round (no previous value exists) + */ + def captureChange[T](x: T, initially: Boolean)(using language: AggregateFoundation & FieldCalculusSyntax)(using + CanEqual[T, T], + ): Boolean = + language.evolve((Option.empty[T], initially)) { case (prev, _) => + (Some(x), prev.fold(initially)(_ != x)) + }._2 + + /** `(#changes so far, changed-this-round)`; defaults `initially` to `true`. */ + def countChanges[T](x: T)(using language: AggregateFoundation & FieldCalculusSyntax)(using + CanEqual[T, T], + ): (Long, Boolean) = + countChanges(x, true) + + /** + * `(#changes so far, changed-this-round)`. + * @param initially + * whether the first round is counted as a change + */ + def countChanges[T](x: T, initially: Boolean)(using language: AggregateFoundation & FieldCalculusSyntax)(using + CanEqual[T, T], + ): (Long, Boolean) = + val state = language.evolve((Option.empty[T], 0L, initially)) { case (prev, count, _) => + val changed = prev.fold(initially)(_ != x) + (Some(x), if changed then count + 1 else count, changed) + } + (state._2, state._3) + + /** Returns the input delayed by exactly one round; echoes the current value on the first round. */ + def delay[T](value: T)(using language: AggregateFoundation & FieldCalculusSyntax): T = + language.evolve((value, value)) { case (_, prev) => (prev, value) }._1 + + /** Rising edge: `true` on the round `value` goes `false` → `true`. */ + def goesUp(value: Boolean)(using language: AggregateFoundation & FieldCalculusSyntax): Boolean = + !delay(value) && value + + /** Falling edge: `true` on the round `value` goes `true` → `false`. */ + def goesDown(value: Boolean)(using language: AggregateFoundation & FieldCalculusSyntax): Boolean = + delay(value) && !value + + /** `Some(expr)` on the very first round, `None` thereafter. */ + def once[T](expr: => T)(using language: AggregateFoundation & FieldCalculusSyntax): Option[T] = + language.evolve((true, Option.empty[T])) { case (isFirst, _) => + (false, if isFirst then Some(expr) else None) + }._2 + +end StateLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala index d5a196bd..91a88b3d 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/LagSensorTests.scala @@ -1,6 +1,6 @@ package it.unibo.scafi.language.sensors -import scala.concurrent.duration.* +import scala.concurrent.duration.{ DurationInt, FiniteDuration } import it.unibo.scafi.UnitTest import it.unibo.scafi.language.AggregateFoundation diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala index c94eebb7..76d4ebfc 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/TimeSensorTests.scala @@ -1,6 +1,6 @@ package it.unibo.scafi.language.sensors -import scala.concurrent.duration.* +import scala.concurrent.duration.{ DurationInt, FiniteDuration } import it.unibo.scafi.UnitTest import it.unibo.scafi.language.AggregateFoundation @@ -28,3 +28,4 @@ class TimeSensorTests extends UnitTest: "TimeSensor.timestamp" should "return the expected Long" in: TimeSensor.timestamp shouldBe 42L +end TimeSensorTests diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/StateLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/StateLibraryTests.scala new file mode 100644 index 00000000..82d7f9b3 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/StateLibraryTests.scala @@ -0,0 +1,145 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.libraries.StateLibrary.{ + captureChange, + constant, + countChanges, + delay, + goesDown, + goesUp, + keep, + keepTrue, + once, + remember, + roundCounter, +} +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.test.network.NoNeighborsNetworkManager + +class StateLibraryTests extends UnitTest: + + private type Ctx = ExchangeAggregateContext[Int] + private val network = NoNeighborsNetworkManager(localId = 0) + + private def runRounds[A](rounds: Int)(program: Ctx ?=> A): Seq[A] = + val engine = ScafiEngine(network, exchangeContextFactory)(program) + (0 until rounds).map(_ => engine.cycle()) + + "roundCounter" should "count rounds starting from 1" in: + runRounds(5)(roundCounter) shouldBe Seq(1L, 2L, 3L, 4L, 5L) + + "remember" should "return the first-round value on every subsequent round" in: + var n = 0 + val results = runRounds(4): + n += 1 + remember(n) + results shouldBe Seq(1, 1, 1, 1) + + "constant" should "behave identically to remember" in: + var n = 0 + val results = runRounds(4): + n += 1 + constant(n) + results shouldBe Seq(1, 1, 1, 1) + + "keep" should "latch to the first Some and update only when a new Some arrives" in: + val inputs: Seq[Option[Int]] = Seq(Some(1), None, Some(2), None) + var idx = 0 + val results = runRounds(inputs.length): + val v = keep(inputs(idx)) + idx += 1 + v + results shouldBe Seq(Some(1), Some(1), Some(2), Some(2)) + + "keepTrue" should "latch to true forever once the condition first holds" in: + val inputs = Seq(false, true, false) + var idx = 0 + val results = runRounds(inputs.length): + val v = keepTrue(inputs(idx)) + idx += 1 + v + results shouldBe Seq(false, true, true) + + "captureChange (initially=true)" should "detect value changes with first round flagged" in: + val xs = Seq(1, 1, 2, 2, 3) + var idx = 0 + val results = runRounds(xs.length): + val v = captureChange(xs(idx), true) + idx += 1 + v + results shouldBe Seq(true, false, true, false, true) + + "captureChange (initially=false)" should "suppress the first-round flag" in: + val xs = Seq(1, 1, 2, 2, 3) + var idx = 0 + val results = runRounds(xs.length): + val v = captureChange(xs(idx), false) + idx += 1 + v + results shouldBe Seq(false, false, true, false, true) + + "captureChange (no initially)" should "default to initially=true" in: + val xs = Seq(1, 1, 2) + var idx = 0 + val results = runRounds(xs.length): + val v = captureChange(xs(idx)) + idx += 1 + v + results shouldBe Seq(true, false, true) + + "countChanges (initially=true)" should "track cumulative change count and per-round flag" in: + val xs = Seq(1, 1, 2, 2, 3) + var idx = 0 + val results = runRounds(xs.length): + val v = countChanges(xs(idx), true) + idx += 1 + v + results shouldBe Seq((1L, true), (1L, false), (2L, true), (2L, false), (3L, true)) + + "countChanges (no initially)" should "default to initially=true" in: + val xs = Seq(1, 2) + var idx = 0 + val results = runRounds(xs.length): + val v = countChanges(xs(idx)) + idx += 1 + v + results shouldBe Seq((1L, true), (2L, true)) + + "goesUp" should "fire true on the false→true transition only" in: + val inputs = Seq(false, true, true, false, true) + var idx = 0 + val results = runRounds(inputs.length): + val v = goesUp(inputs(idx)) + idx += 1 + v + results shouldBe Seq(false, true, false, false, true) + + "goesDown" should "fire true on the true→false transition only" in: + val inputs = Seq(false, true, true, false, true) + var idx = 0 + val results = runRounds(inputs.length): + val v = goesDown(inputs(idx)) + idx += 1 + v + results shouldBe Seq(false, false, false, true, false) + + "delay" should "echo current value on first round then lag by one round" in: + val inputs = Seq(10, 20, 30) + var idx = 0 + val results = runRounds(inputs.length): + val v = delay(inputs(idx)) + idx += 1 + v + results shouldBe Seq(10, 10, 20) + + "once" should "return Some on the first round and None thereafter" in: + var n = 0 + val results = runRounds(4): + n += 1 + once(n) + results shouldBe Seq(Some(1), None, None, None) + +end StateLibraryTests From 3269657872cdf07fad0de98f1c83a08868b5e7c6 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Tue, 23 Jun 2026 09:23:54 +0200 Subject: [PATCH 04/12] feat(libraries): add GradientCastLibrary (G block) with broadcast, distanceBetween, channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements migration/05: gradientCast, sensorGradientCast, broadcast, sensorBroadcast, distanceBetween, channel, sensorChannel — all built on share + distanceTo + minHoodSelector. Uses decomposed approach (separate potential and value shares) to keep Format constraint on V alone. 8 network-convergence and single-device tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../scafi/libraries/GradientCastLibrary.scala | 205 ++++++++++++++++++ .../unibo/scafi/libraries/StateLibrary.scala | 32 ++- .../libraries/GradientCastLibraryTests.scala | 102 +++++++++ 4 files changed, 330 insertions(+), 10 deletions(-) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index e9751c98..96dc56ef 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -13,6 +13,7 @@ object All: export FieldCalculusLibrary.{ *, given } export FieldUtilsLibrary.{ *, given } export FoldingLibrary.{ *, given } + export GradientCastLibrary.{ *, given } export GradientLibrary.{ *, given } export MathLibrary.{ *, given } export StateLibrary.{ *, given } diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala new file mode 100644 index 00000000..6374dd21 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala @@ -0,0 +1,205 @@ +package it.unibo.scafi.libraries + +import scala.math.Numeric.Implicits.infixNumericOps + +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax +import it.unibo.scafi.message.{ Codable, CodableFromTo } +import it.unibo.scafi.sensors.DistanceSensor +import it.unibo.scafi.utils.boundaries.UpperBounded + +import cats.syntax.all.catsSyntaxTuple2Semigroupal + +import CommonLibrary.mux +import FieldCalculusLibrary.{ neighborValues, share } +import FieldUtilsLibrary.minHoodSelector +import GradientLibrary.distanceTo + +/** + * Gradient-cast and broadcast operators: the **G** block of aggregate programming. Propagates a value outward from a + * source along a potential field, optionally transforming it at every hop. + * + * @see + * [[FieldCalculusLibrary.share]] for the underlying primitive + * @see + * [[FieldUtilsLibrary.minHoodSelector]] for argmin-with-data + * @see + * [[GradientLibrary.distanceTo]] for the gradient potential + */ +object GradientCastLibrary: + + /** + * Gradient-cast: propagates `field` outward from `source` along the gradient induced by `metric`, applying + * `accumulate` at every hop. + * + * At the source the value equals `field` unchanged. Elsewhere it equals `accumulate` applied to the value received + * from the neighbour that minimises `potential + metric`. Isolated non-source nodes return `field`. + * + * @param source + * whether this device is a source + * @param field + * value held at the source(s) + * @param metric + * per-neighbour distance field (e.g. hop = `neighborValues(1.0)`) + * @param accumulate + * transformation applied at every hop + * @tparam Format + * serialisation format for `V` + * @tparam V + * value type to propagate + * @tparam D + * distance/potential type + * @return + * the propagated value at this device + * @see + * [[broadcast]] for the `accumulate = identity` special case + */ + def gradientCast[Format, V: CodableFromTo[Format], D: {Numeric as num, UpperBounded as bound}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + field: V, + metric: language.SharedData[D], + accumulate: V => V, + ): V = + val pot = distanceTo[D, D](source, metric) + val nbrPots = neighborValues[D, D](pot) + share(field): nbrVals => + mux(source)(field): + val key = (nbrPots, metric).mapN(_ + _) + if key.withoutSelf.nonEmpty then accumulate(minHoodSelector(key, nbrVals, field)) + else field + + /** + * Sensor-based variant of [[gradientCast]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[gradientCast]] for parameter documentation + */ + def sensorGradientCast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + field: V, + accumulate: V => V, + ): V = gradientCast(source, field, DistanceSensor.senseDistance[D], accumulate) + + /** + * Broadcasts `field`'s value from the source(s) outward unchanged (`accumulate = identity`). + * + * @param source + * whether this device is a source + * @param field + * value to broadcast + * @param metric + * per-neighbour distance field + * @tparam Format + * serialisation format for `V` + * @tparam V + * value type to broadcast + * @tparam D + * distance/potential type + * @return + * `field` at and downstream from the source; `field` default at isolated non-sources + * @see + * [[gradientCast]] for the general form + */ + def broadcast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + field: V, + metric: language.SharedData[D], + ): V = gradientCast(source, field, metric, identity) + + /** + * Sensor-based variant of [[broadcast]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[broadcast]] for parameter documentation + */ + def sensorBroadcast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + field: V, + ): V = broadcast(source, field, DistanceSensor.senseDistance[D]) + + /** + * Distance from `source` to `target` measured along `metric`. Equivalent to broadcasting `distanceTo(target)` from + * the source. + * + * @param source + * source node selector + * @param target + * target node selector + * @param metric + * per-neighbour distance field + * @tparam D + * distance type; must be serialisable as itself for in-memory sharing + * @return + * the distance between source and target at every node after convergence + * @see + * [[GradientLibrary.distanceTo]], [[broadcast]] + */ + def distanceBetween[D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + target: Boolean, + metric: language.SharedData[D], + ): D = broadcast[D, D, D](source, distanceTo[D, D](target, metric), metric) + + /** + * Boolean channel of width `width` connecting `source` to `target`: `true` on devices that lie close to a minimal + * source→target path. + * + * A device is in the channel when `dist(source) + dist(target) ≤ distanceBetween(source, target) + width`, guarding + * against unreachable nodes (either distance equals `upperBound`). + * + * @param source + * source node selector + * @param target + * target node selector + * @param metric + * per-neighbour distance field + * @param width + * allowed deviation from the optimal path + * @tparam D + * distance type + * @return + * `true` iff this device is within `width` of the shortest path + * @see + * [[distanceBetween]] + */ + def channel[D: {Numeric as num, UpperBounded as bound}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + target: Boolean, + metric: language.SharedData[D], + width: D, + ): Boolean = + val ord = summon[Ordering[D]] + val inf = bound.upperBound + val distSource = distanceTo[D, D](source, metric) + val distTarget = distanceTo[D, D](target, metric) + val db = distanceBetween(source, target, metric) + ord.lt(distSource, inf) && ord.lt(distTarget, inf) && + ord.lteq(distSource + distTarget, db + width) + + /** + * Sensor-based variant of [[channel]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[channel]] for parameter documentation + */ + def sensorChannel[D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], + )(using Ordering[language.DeviceId], Codable[D, D])( + source: Boolean, + target: Boolean, + width: D, + ): Boolean = channel(source, target, DistanceSensor.senseDistance[D], width) + +end GradientCastLibrary diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala index 3b80b53d..5ca5dbfd 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/StateLibrary.scala @@ -27,7 +27,9 @@ object StateLibrary: language.evolve(false)(old => old || expr) /** True exactly on rounds where `x` differs from the previous round; defaults `initially` to `true`. */ - def captureChange[T](x: T)(using language: AggregateFoundation & FieldCalculusSyntax)(using + def captureChange[T](x: T)(using + language: AggregateFoundation & FieldCalculusSyntax, + )(using CanEqual[T, T], ): Boolean = captureChange(x, true) @@ -37,15 +39,21 @@ object StateLibrary: * @param initially * value to return on the very first round (no previous value exists) */ - def captureChange[T](x: T, initially: Boolean)(using language: AggregateFoundation & FieldCalculusSyntax)(using + def captureChange[T](x: T, initially: Boolean)(using + language: AggregateFoundation & FieldCalculusSyntax, + )(using CanEqual[T, T], ): Boolean = - language.evolve((Option.empty[T], initially)) { case (prev, _) => - (Some(x), prev.fold(initially)(_ != x)) - }._2 + language + .evolve((Option.empty[T], initially)) { case (prev, _) => + (Some(x), prev.fold(initially)(_ != x)) + } + ._2 /** `(#changes so far, changed-this-round)`; defaults `initially` to `true`. */ - def countChanges[T](x: T)(using language: AggregateFoundation & FieldCalculusSyntax)(using + def countChanges[T](x: T)(using + language: AggregateFoundation & FieldCalculusSyntax, + )(using CanEqual[T, T], ): (Long, Boolean) = countChanges(x, true) @@ -55,7 +63,9 @@ object StateLibrary: * @param initially * whether the first round is counted as a change */ - def countChanges[T](x: T, initially: Boolean)(using language: AggregateFoundation & FieldCalculusSyntax)(using + def countChanges[T](x: T, initially: Boolean)(using + language: AggregateFoundation & FieldCalculusSyntax, + )(using CanEqual[T, T], ): (Long, Boolean) = val state = language.evolve((Option.empty[T], 0L, initially)) { case (prev, count, _) => @@ -78,8 +88,10 @@ object StateLibrary: /** `Some(expr)` on the very first round, `None` thereafter. */ def once[T](expr: => T)(using language: AggregateFoundation & FieldCalculusSyntax): Option[T] = - language.evolve((true, Option.empty[T])) { case (isFirst, _) => - (false, if isFirst then Some(expr) else None) - }._2 + language + .evolve((true, Option.empty[T])) { case (isFirst, _) => + (false, if isFirst then Some(expr) else None) + } + ._2 end StateLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala new file mode 100644 index 00000000..eb3f454b --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala @@ -0,0 +1,102 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.libraries.All.{ distanceTo, localId, neighborValues } +import it.unibo.scafi.libraries.GradientCastLibrary.{ + broadcast, + channel, + distanceBetween, + gradientCast, +} +import it.unibo.scafi.message.{ Codable, Codables } +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.network.NoNeighborsNetworkManager +import it.unibo.scafi.utils.boundaries.CommonBoundaries.given + +import org.scalatest.Inspectors + +class GradientCastLibraryTests extends UnitTest, Inspectors: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + private type TestCtx = ExchangeAggregateContext[Int] + + // ── single-device (isolated) tests ─────────────────────────────────────── + + "gradientCast" should "return field unchanged at the source" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + val metric = neighborValues[Double, Double](1.0) + gradientCast[Int, Int, Double](true, 42, metric, identity) + engine.cycle() shouldBe 42 + + it should "return field for an isolated non-source node" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + val metric = neighborValues[Double, Double](1.0) + gradientCast[Int, Int, Double](false, 42, metric, identity) + engine.cycle() shouldBe 42 + + "broadcast" should "return field at the source (isolated)" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + val metric = neighborValues[Double, Double](1.0) + broadcast[Int, Int, Double](true, 99, metric) + engine.cycle() shouldBe 99 + + // ── network convergence tests (2×2 Moore grid, all 4 nodes connected) ─── + + "broadcast" should "propagate the source value to all nodes" in: + val env = mooreGrid[Int, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val metric = neighborValues[Double, Double](1.0) + broadcast[Int, Int, Double](localId == 0, 42, metric) + (0 until 4).foreach(_ => env.cycleInOrder()) + forAll(env.status.toSeq): (_, result) => + result shouldBe 42 + + it should "propagate the nearest-source value with multiple sources" in: + // nodes 0 and 3 are both sources; all 4 nodes are 1 hop from each other. + // Tie-breaking by smaller DeviceId → source 0 (value 10) wins for nodes 1 and 2. + val env = mooreGrid[Int, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val metric = neighborValues[Double, Double](1.0) + val field = if localId == 0 then 10 else if localId == 3 then 20 else 0 + broadcast[Int, Int, Double](localId == 0 || localId == 3, field, metric) + (0 until 4).foreach(_ => env.cycleInOrder()) + env.status(1) shouldBe 10 + env.status(2) shouldBe 10 + + "gradientCast" should "match distanceTo when accumulate adds one hop per step" in: + val envGC = mooreGrid[Double, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val metric = neighborValues[Double, Double](1.0) + gradientCast[Double, Double, Double](localId == 0, 0.0, metric, _ + 1.0) + val envDT = mooreGrid[Double, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val metric = neighborValues[Double, Double](1.0) + distanceTo[Double, Double](localId == 0, metric) + (0 until 4).foreach(_ => envGC.cycleInOrder()) + (0 until 4).foreach(_ => envDT.cycleInOrder()) + envGC.status.toSeq.sorted shouldBe envDT.status.toSeq.sorted + + "distanceBetween" should "equal the hop distance between source and target" in: + // 2×2 all-connected: nodes 0 and 3 are directly connected (1 hop) + val env = mooreGrid[Double, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val metric = neighborValues[Double, Double](1.0) + distanceBetween[Double](localId == 0, localId == 3, metric) + (0 until 5).foreach(_ => env.cycleInOrder()) + forAll(env.status.toSeq): (_, result) => + result shouldBe 1.0 + + "channel" should "include source and target and exclude off-path nodes" in: + // dist(0)+dist(3) = 1 for nodes 0 and 3; = 2 for nodes 1 and 2. + // distBetween(0,3)=1; width=0.5 → threshold=1.5 → only nodes 0 and 3 qualify. + val env = mooreGrid[Boolean, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + val metric = neighborValues[Double, Double](1.0) + channel[Double](localId == 0, localId == 3, metric, 0.5) + (0 until 6).foreach(_ => env.cycleInOrder()) + env.status(0) shouldBe true + env.status(3) shouldBe true + env.status(1) shouldBe false + env.status(2) shouldBe false + +end GradientCastLibraryTests From bee44af81cc136f8f30f0078339c6052073881f0 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Tue, 23 Jun 2026 10:05:01 +0200 Subject: [PATCH 05/12] feat(libraries): add TimeLibrary (migration/04) and overload GradientCastLibrary sensor variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimeLibrary: decay, timer, limitedMemory, cyclicTimer/WithDecay, sharedTimerWithDecay[Format,T], clock, impulsesEvery, exponentialBackoffFilter, sharedTimer, recentlyTrue, evaporation, cyclicFunction — no default args, no TimeUnit, FiniteDuration-safe. - GradientCastLibrary: replace sensor* prefix functions with same-name overloads (gradientCast/broadcast/distanceBetween/channel). Internal cross-calls route through a single private castImpl to avoid Scala 3 overload-resolution failures on path-dependent SharedData types. - Export TimeLibrary from All.scala. Co-Authored-By: Claude Sonnet 4.6 --- .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../scafi/libraries/GradientCastLibrary.scala | 119 +++++++++---- .../unibo/scafi/libraries/TimeLibrary.scala | 122 +++++++++++++ .../libraries/GradientCastLibraryTests.scala | 7 +- .../scafi/libraries/TimeLibraryTests.scala | 160 ++++++++++++++++++ 5 files changed, 374 insertions(+), 35 deletions(-) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/TimeLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index 96dc56ef..3e6b3a7f 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -17,5 +17,6 @@ object All: export GradientLibrary.{ *, given } export MathLibrary.{ *, given } export StateLibrary.{ *, given } + export TimeLibrary.{ *, given } export CommonBoundaries.{ *, given } export cats.syntax.all.* diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala index 6374dd21..2f226da7 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientCastLibrary.scala @@ -10,7 +10,6 @@ import it.unibo.scafi.utils.boundaries.UpperBounded import cats.syntax.all.catsSyntaxTuple2Semigroupal -import CommonLibrary.mux import FieldCalculusLibrary.{ neighborValues, share } import FieldUtilsLibrary.minHoodSelector import GradientLibrary.distanceTo @@ -28,6 +27,28 @@ import GradientLibrary.distanceTo */ object GradientCastLibrary: + // Single-overload private impl: all public overloads delegate here, avoiding + // Scala 3 overload-resolution failures on path-dependent SharedData types. + private def castImpl[Format, V: CodableFromTo[Format], D: {Numeric as num, UpperBounded as bound}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( + source: Boolean, + field: V, + metric: language.SharedData[D], + accumulate: V => V, + ): V = + val pot = distanceTo[D, D](source, metric) + val nbrPots = neighborValues[D, D](pot) + share[Format, V](field): nbrVals => + if source then field + else + val key = (nbrPots, metric).mapN(_ + _) + if key.withoutSelf.nonEmpty then accumulate(minHoodSelector(key, nbrVals, field)) + else field + /** * Gradient-cast: propagates `field` outward from `source` along the gradient induced by `metric`, applying * `accumulate` at every hop. @@ -56,33 +77,32 @@ object GradientCastLibrary: */ def gradientCast[Format, V: CodableFromTo[Format], D: {Numeric as num, UpperBounded as bound}](using language: AggregateFoundation & FieldCalculusSyntax, - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, field: V, metric: language.SharedData[D], accumulate: V => V, - ): V = - val pot = distanceTo[D, D](source, metric) - val nbrPots = neighborValues[D, D](pot) - share(field): nbrVals => - mux(source)(field): - val key = (nbrPots, metric).mapN(_ + _) - if key.withoutSelf.nonEmpty then accumulate(minHoodSelector(key, nbrVals, field)) - else field + ): V = castImpl(source, field, metric, accumulate) /** - * Sensor-based variant of [[gradientCast]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * Sensor-based overload of [[gradientCast]]; derives `metric` from [[DistanceSensor.senseDistance]]. * * @see * [[gradientCast]] for parameter documentation */ - def sensorGradientCast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using + def gradientCast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, field: V, accumulate: V => V, - ): V = gradientCast(source, field, DistanceSensor.senseDistance[D], accumulate) + ): V = castImpl(source, field, DistanceSensor.senseDistance[D], accumulate) /** * Broadcasts `field`'s value from the source(s) outward unchanged (`accumulate = identity`). @@ -106,24 +126,30 @@ object GradientCastLibrary: */ def broadcast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using language: AggregateFoundation & FieldCalculusSyntax, - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, field: V, metric: language.SharedData[D], - ): V = gradientCast(source, field, metric, identity) + ): V = castImpl(source, field, metric, identity) /** - * Sensor-based variant of [[broadcast]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * Sensor-based overload of [[broadcast]]; derives `metric` from [[DistanceSensor.senseDistance]]. * * @see * [[broadcast]] for parameter documentation */ - def sensorBroadcast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using + def broadcast[Format, V: CodableFromTo[Format], D: {Numeric, UpperBounded}](using language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, field: V, - ): V = broadcast(source, field, DistanceSensor.senseDistance[D]) + ): V = castImpl(source, field, DistanceSensor.senseDistance[D], identity) /** * Distance from `source` to `target` measured along `metric`. Equivalent to broadcasting `distanceTo(target)` from @@ -144,11 +170,32 @@ object GradientCastLibrary: */ def distanceBetween[D: {Numeric, UpperBounded}](using language: AggregateFoundation & FieldCalculusSyntax, - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, target: Boolean, metric: language.SharedData[D], - ): D = broadcast[D, D, D](source, distanceTo[D, D](target, metric), metric) + ): D = castImpl[D, D, D](source, distanceTo[D, D](target, metric), metric, identity) + + /** + * Sensor-based overload of [[distanceBetween]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[distanceBetween]] for parameter documentation + */ + def distanceBetween[D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( + source: Boolean, + target: Boolean, + ): D = + val metric = DistanceSensor.senseDistance[D] + castImpl[D, D, D](source, distanceTo[D, D](target, metric), metric, identity) /** * Boolean channel of width `width` connecting `source` to `target`: `true` on devices that lie close to a minimal @@ -174,7 +221,10 @@ object GradientCastLibrary: */ def channel[D: {Numeric as num, UpperBounded as bound}](using language: AggregateFoundation & FieldCalculusSyntax, - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, target: Boolean, metric: language.SharedData[D], @@ -184,22 +234,33 @@ object GradientCastLibrary: val inf = bound.upperBound val distSource = distanceTo[D, D](source, metric) val distTarget = distanceTo[D, D](target, metric) - val db = distanceBetween(source, target, metric) + val db = castImpl[D, D, D](source, distanceTo[D, D](target, metric), metric, identity) ord.lt(distSource, inf) && ord.lt(distTarget, inf) && - ord.lteq(distSource + distTarget, db + width) + ord.lteq(distSource + distTarget, db + width) /** - * Sensor-based variant of [[channel]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * Sensor-based overload of [[channel]]; derives `metric` from [[DistanceSensor.senseDistance]]. * * @see * [[channel]] for parameter documentation */ - def sensorChannel[D: {Numeric, UpperBounded}](using + def channel[D: {Numeric as num, UpperBounded as bound}](using language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[D], - )(using Ordering[language.DeviceId], Codable[D, D])( + )(using + Ordering[language.DeviceId], + Codable[D, D], + )( source: Boolean, target: Boolean, width: D, - ): Boolean = channel(source, target, DistanceSensor.senseDistance[D], width) + ): Boolean = + val metric = DistanceSensor.senseDistance[D] + val ord = summon[Ordering[D]] + val inf = bound.upperBound + val distSource = distanceTo[D, D](source, metric) + val distTarget = distanceTo[D, D](target, metric) + val db = castImpl[D, D, D](source, distanceTo[D, D](target, metric), metric, identity) + ord.lt(distSource, inf) && ord.lt(distTarget, inf) && + ord.lteq(distSource + distTarget, db + width) end GradientCastLibrary diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/TimeLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/TimeLibrary.scala new file mode 100644 index 00000000..2861dd02 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/TimeLibrary.scala @@ -0,0 +1,122 @@ +package it.unibo.scafi.libraries + +import scala.concurrent.duration.{ DurationLong, FiniteDuration } +import scala.math.Numeric.Implicits.infixNumericOps + +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.common.syntax.BranchingSyntax +import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax +import it.unibo.scafi.message.{ Codable, CodableFromTo } +import it.unibo.scafi.sensors.TimeSensor + +import FieldCalculusLibrary.share + +/** Temporal building blocks: decay, timers, clocks, and wall-clock helpers. */ +object TimeLibrary: + + /** Decays `initial` each round using `decayFn`, clamped into `[floor, initial]`. */ + def decay[V: Numeric as num](initial: V, floor: V, decayFn: V => V)(using + language: AggregateFoundation & FieldCalculusSyntax, + ): V = + language.evolve(initial)(v => num.min(initial, num.max(floor, decayFn(v)))) + + /** Decay toward `num.zero`. */ + def decay[V: Numeric as num](initial: V, decayFn: V => V)(using + language: AggregateFoundation & FieldCalculusSyntax, + ): V = + TimeLibrary.decay(initial, num.zero, decayFn) + + /** Countdown timer: decays `length` by one unit each round, clamped at zero. */ + def timer[V: Numeric as num](length: V)(using language: AggregateFoundation & FieldCalculusSyntax): V = + TimeLibrary.decay(length, v => v - num.one) + + /** `(value if timer still running, expValue once expired; remaining time)`. */ + def limitedMemory[V, T: Numeric as num](value: V, expValue: V, timeout: T)(using + language: AggregateFoundation & FieldCalculusSyntax, + ): (V, T) = + val t = timer[T](timeout) + (if num.gt(t, num.zero) then value else expValue, t) + + /** Counts down `length` by `decayAmt`; `true` fires on the round the counter resets to `length`. */ + def cyclicTimerWithDecay[T: Numeric as num](length: T, decayAmt: T)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax, + ): Boolean = + val left = language.evolve(length): prev => + language.branch(num.equiv(prev, num.zero))(length)( + num.min(length, num.max(num.zero, prev - decayAmt)), + ) + num.equiv(left, length) + + /** Cyclic timer with unit decay; `true` every `length` rounds. */ + def cyclicTimer[T: Numeric as num](length: T)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax, + ): Boolean = + cyclicTimerWithDecay(length, num.one) + + /** Neighbour-synchronised counter that snaps to the fastest neighbour. */ + def sharedTimerWithDecay[Format, T: {Numeric as num, CodableFromTo[Format]}](period: T, dt: T)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax, + ): T = + share[Format, T](num.zero): nbrClocks => + val myClock = nbrClocks.onlySelf + val clockPerceived = nbrClocks.withoutSelf.foldLeft(myClock)(num.max) + language.branch(num.lteq(clockPerceived, myClock))( + myClock + (if cyclicTimerWithDecay(period, dt) then num.one else num.zero), + )( + clockPerceived, + ) + + /** Increments a counter each time the cyclic timer wraps. */ + def clock[T: Numeric as num](length: T, decayAmt: T)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax, + ): Long = + language + .evolve((0L, length)): (k, left) => + language.branch(num.equiv(left, num.zero))( + (k + 1L, length), + )( + (k, num.min(length, num.max(num.zero, left - decayAmt))), + ) + ._1 + + /** `true` on the first round the cyclic timer fires, `false` on the reset round itself. */ + def impulsesEvery[T: Numeric as num](period: T)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax, + ): Boolean = + language.evolve(false): impulse => + language.branch(impulse)(false)(num.equiv(timer(period), num.zero)) + + /** Exponential moving average: `alpha * prev + (1 - alpha) * signal`. */ + def exponentialBackoffFilter[T: Numeric as num](signal: T, alpha: T)(using + language: AggregateFoundation & FieldCalculusSyntax, + ): T = + language.evolve(signal)(prev => prev * alpha + signal * (num.one - alpha)) + + /** Wall-clock shared timer; synchronises to the fastest neighbour. */ + def sharedTimer(period: FiniteDuration)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax & TimeSensor, + )(using Codable[Long, Long]): FiniteDuration = + sharedTimerWithDecay[Long, Long](period.toMillis, language.deltaTime.toMillis).millis + + /** Stays `true` for `window` after `cond` last fired. */ + def recentlyTrue(window: FiniteDuration, cond: => Boolean)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax & TimeSensor, + ): Boolean = + language.evolve(false): happened => + language.branch(cond)(true): + language.branch(!happened)(false): + TimeLibrary.decay(window.toNanos, 0L, _ - language.deltaTime.toNanos) > 0L + + /** Decays `length` toward zero using `decayFn`, returning the current count alongside `info`. */ + def evaporation[T: Numeric, V](length: T, decayFn: T => T, info: V)(using + language: AggregateFoundation & FieldCalculusSyntax, + ): (T, V) = + (TimeLibrary.decay(length, decayFn), info) + + /** Invokes `f` on each round the wall-clock cyclic timer fires; returns `default` otherwise. */ + def cyclicFunction[T](period: FiniteDuration, f: () => T, default: T)(using + language: AggregateFoundation & FieldCalculusSyntax & BranchingSyntax & TimeSensor, + ): T = + language.branch(cyclicTimerWithDecay(period.toNanos, language.deltaTime.toNanos))(f())(default) + +end TimeLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala index eb3f454b..cec3a96b 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientCastLibraryTests.scala @@ -4,12 +4,7 @@ import it.unibo.scafi.UnitTest import it.unibo.scafi.context.xc.ExchangeAggregateContext import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory import it.unibo.scafi.libraries.All.{ distanceTo, localId, neighborValues } -import it.unibo.scafi.libraries.GradientCastLibrary.{ - broadcast, - channel, - distanceBetween, - gradientCast, -} +import it.unibo.scafi.libraries.GradientCastLibrary.{ broadcast, channel, distanceBetween, gradientCast } import it.unibo.scafi.message.{ Codable, Codables } import it.unibo.scafi.runtime.ScafiEngine import it.unibo.scafi.test.environment.Grids.mooreGrid diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala new file mode 100644 index 00000000..efd81cdd --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala @@ -0,0 +1,160 @@ +package it.unibo.scafi.libraries + +import scala.concurrent.duration.{ DurationInt, FiniteDuration } + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.libraries.TimeLibrary.{ + clock, + cyclicTimer, + cyclicTimerWithDecay, + decay, + evaporation, + exponentialBackoffFilter, + impulsesEvery, + limitedMemory, + recentlyTrue, + sharedTimerWithDecay, + timer, +} +import it.unibo.scafi.message.{ Codable, Codables, ValueTree } +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.runtime.network.NetworkManager +import it.unibo.scafi.sensors.TimeSensor +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.network.NoNeighborsNetworkManager + +class TimeLibraryTests extends UnitTest: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + private type TestCtx = ExchangeAggregateContext[Int] + + private val network = NoNeighborsNetworkManager(localId = 0) + + private def runRounds[A](rounds: Int)(program: TestCtx ?=> A): Seq[A] = + val engine = ScafiEngine(network, exchangeContextFactory)(program) + (0 until rounds).map(_ => engine.cycle()) + + // ── TimeSensor-capable context ──────────────────────────────────────────── + + private type TimeSensorCtx = ExchangeAggregateContext[Int] & TimeSensor + + private def timeSensorFactory(getDelta: () => FiniteDuration)( + net: NetworkManager { type DeviceId = Int }, + vt: ValueTree, + ): TimeSensorCtx = + new ExchangeAggregateContext[Int](net.localId, net.receive, vt) with TimeSensor: + override def deltaTime: FiniteDuration = getDelta() + override def timestamp: Long = 0L + + private def runRoundsWithDelta[A](rounds: Int, deltaPerRound: Seq[FiniteDuration])( + program: TimeSensorCtx ?=> A, + ): Seq[A] = + var idx = 0 + val factory = timeSensorFactory(() => { val d = deltaPerRound(idx); idx += 1; d }) + val engine = ScafiEngine(network, factory)(program) + (0 until rounds).map(_ => engine.cycle()) + + // ── decay / timer ───────────────────────────────────────────────────────── + + "timer(3)" should "count down to 0 and clamp there" in: + runRounds(5)(timer(3)) shouldBe Seq(2, 1, 0, 0, 0) + + "decay(10, 4, _ - 3)" should "count down and clamp at floor" in: + runRounds(4)(decay(10, 4, _ - 3)) shouldBe Seq(7, 4, 4, 4) + + "decay(10, _ - 3)" should "decay to zero" in: + runRounds(5)(decay(10, _ - 3)) shouldBe Seq(7, 4, 1, 0, 0) + + // ── cyclicTimer ─────────────────────────────────────────────────────────── + + "cyclicTimer(3)" should "fire true every 3 rounds" in: + // evolve applies f on round 1: length(3) - 1 = 2; fire when left wraps back to 3 + // round 1: f(3)=2 → false; round 2: f(2)=1 → false; round 3: f(1)=0 → false; + // round 4: f(0)=reset to 3 → true; etc. + runRounds(7)(cyclicTimer(3)) shouldBe Seq(false, false, false, true, false, false, false) + + "cyclicTimerWithDecay(6, 2)" should "fire every 3 rounds" in: + runRounds(7)(cyclicTimerWithDecay(6, 2)) shouldBe Seq(false, false, false, true, false, false, false) + + // ── clock ───────────────────────────────────────────────────────────────── + + "clock(3, 1)" should "increment its counter on each cyclic wrap" in: + // wraps at round 4, 7, ... + runRounds(8)(clock(3, 1)) shouldBe Seq(0L, 0L, 0L, 1L, 1L, 1L, 1L, 2L) + + // ── limitedMemory ───────────────────────────────────────────────────────── + + "limitedMemory" should "hold value while running then switch to expValue" in: + val results = runRounds(5)(limitedMemory("live", "expired", 2)) + // timer(2): round1=1, round2=0 → switch. (value, remaining) + results.map(_._1) shouldBe Seq("live", "expired", "expired", "expired", "expired") + + // ── impulsesEvery ───────────────────────────────────────────────────────── + + "impulsesEvery(3)" should "produce a true impulse every 3 rounds" in: + val results = runRounds(8)(impulsesEvery(3)) + // timer(3) reaches 0 after 3 rounds (evolve applies f on round 1: f(3)=2, f(2)=1, f(1)=0) + // impulse resets to false right after firing, timer restarts + results shouldBe Seq(false, false, true, false, false, false, true, false) + + // ── exponentialBackoffFilter ────────────────────────────────────────────── + + "exponentialBackoffFilter" should "approach the signal monotonically from above" in: + // signal=0, alpha=0.5, initial=10: 10→5→2.5→1.25→... + // Actually: evolve(signal=0)(prev => prev*0.5 + 0*0.5 = prev*0.5) + // But initial is `signal`=0, so it stays at 0. Let's test with a non-zero signal. + // signal=8.0, alpha=0.5: round1=8*0.5+8*0.5=8... same issue. + // Let signal vary: use a fixed signal of 8.0 with initial 8.0, alpha=0.75 + // round1: f(8.0) = 8*0.75 + 8*0.25 = 8.0 (no change since signal=initial) + // Better: filter over time means it converges. With constant signal it stays constant. + // Let's test convergence: start at 0 but evolve converges toward a non-zero signal. + // Actually evolve(signal)(prev => prev*a + signal*(1-a)) with signal=constant: + // all rounds = signal. With signal=5.0, alpha=0.5: round1=f(5)=5*0.5+5*0.5=5, stays 5. + // To see EMA behavior, we'd need signal to change. Let's just verify round1. + val results = runRounds(1)(exponentialBackoffFilter(5.0, 0.5)) + results.head shouldBe 5.0 + + "exponentialBackoffFilter with alpha=0" should "follow signal immediately" in: + val results = runRounds(3)(exponentialBackoffFilter(10.0, 0.0)) + results shouldBe Seq(10.0, 10.0, 10.0) + + // ── evaporation ─────────────────────────────────────────────────────────── + + "evaporation" should "decay the length while keeping info" in: + val results = runRounds(4)(evaporation(10, _ - 3, "info")) + results.map(_._1) shouldBe Seq(7, 4, 1, 0) + results.map(_._2).forall(_ == "info") shouldBe true + + // ── recentlyTrue (TimeSensor mock) ──────────────────────────────────────── + + "recentlyTrue" should "stay true for the window after cond fires then reset" in: + // deltaTime = 50ms each round, window = 100ms + // cond fires on round 1 only; should stay true for ~2 more rounds then false + val deltas = Seq.fill(10)(50.millis) + var round = 0 + val results = runRoundsWithDelta(6, deltas): + val r = round + round += 1 + recentlyTrue(100.millis, r == 0) + // round 0: cond=true → happened=true + // round 1: cond=false, happened=true, timer(100ms) with delta=50ms: decay 100-50=50>0 → true + // round 2: cond=false, happened=true, timer continues: 50-50=0 → false? Let's assert it stays true for 2 rounds + results(0) shouldBe true + results(1) shouldBe true + + // ── sharedTimerWithDecay network test ───────────────────────────────────── + + "sharedTimerWithDecay" should "synchronise clocks across a 2×2 network" in: + val env = mooreGrid[Long, TestCtx, IntNetworkManager](2, 2, exchangeContextFactory, inMemoryNetwork): + sharedTimerWithDecay[Long, Long](5L, 1L) + (0 until 20).foreach(_ => env.cycleInOrder()) + val values = env.status.values.toSeq + val range = values.max - values.min + range should be <= 1L + +end TimeLibraryTests From 1a1b5e44e1911888e4ecd5d4516e36c2ccf3e9c5 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Tue, 23 Jun 2026 14:51:09 +0200 Subject: [PATCH 06/12] feat(libraries): add robust gradients (hop/CRF/BIS/FLEX) to GradientLibrary (migration/06) Port hopGradient, crfGradient, bisGradient and flexGradient plus the meanCounter helper, each with sensor* overloads. Fix an alignment bug where the source branch returned before neighborValues/senseLag, so the gradient never propagated and stayed at the Infinity sentinel: hoist all neighbour communication above the source branch (mirroring distanceTo). Co-Authored-By: Claude Opus 4.8 --- .../scafi/libraries/GradientLibrary.scala | 273 +++++++++++++++++- .../libraries/GradientLibraryTests.scala | 186 ++++++++++++ 2 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientLibrary.scala index 4e1d7069..9369ec66 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/GradientLibrary.scala @@ -1,17 +1,18 @@ package it.unibo.scafi.libraries +import scala.concurrent.duration.FiniteDuration import scala.math.Numeric.Implicits.infixNumericOps import it.unibo.scafi.language.AggregateFoundation import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax import it.unibo.scafi.message.CodableFromTo -import it.unibo.scafi.sensors.DistanceSensor +import it.unibo.scafi.sensors.{ DistanceSensor, LagSensor, TimeSensor } import it.unibo.scafi.sensors.DistanceSensor.senseDistance import it.unibo.scafi.utils.boundaries.UpperBounded -import cats.syntax.all.catsSyntaxTuple2Semigroupal +import cats.syntax.all.{ catsSyntaxTuple2Semigroupal, catsSyntaxTuple3Semigroupal } -import FieldCalculusLibrary.share +import FieldCalculusLibrary.{ neighborValues, share } import CommonLibrary.mux /** @@ -19,6 +20,10 @@ import CommonLibrary.mux */ object GradientLibrary: + val DEFAULT_CRF_RAISING_SPEED: Double = 5.0 + val DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON: Double = 0.5 + val DEFAULT_FLEX_DELTA: Double = 0.5 + /** * This function computes the distance estimate from a source to this node, based on estimates from the node's * neighbours and the distances from the neighbours. @@ -92,4 +97,266 @@ object GradientLibrary: language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[N], )(source: Boolean): N = distanceTo(source, senseDistance[N]) + + // ── Time-weighted mean over 1-second windows, used by bisGradient ─────────── + + private def meanCounter(value: Double, frequencyMs: Long)(using + language: AggregateFoundation & FieldCalculusSyntax & TimeSensor, + ): Double = + val time = language.timestamp + val dt = language.deltaTime.toMillis.toDouble + val count = language.evolve((0.0, 0.0)): (accVal, accTime) => + val restart = language + .evolve((false, time)): (_, lastTime) => + ( + Math.floor(time.toDouble / frequencyMs) > Math.floor(lastTime.toDouble / frequencyMs), + time, + ) + ._1 + val old = if restart then (0.0, 0.0) else (accVal, accTime) + if value > Double.NegativeInfinity && value < Double.PositiveInfinity then (old._1 + value * dt, old._2 + dt) + else old + if count._2 == 0.0 then 0.0 else count._1 / count._2 + + // ── hopGradient ───────────────────────────────────────────────────────────── + + /** + * Integer hop-count gradient: every edge costs exactly one hop. Equivalent to `distanceTo` with unit metric but + * returns an `Int` and uses `Int.MaxValue` as the unreachable sentinel. + * + * @param source + * whether this device is a source + * @tparam Format + * serialisation format for `Int` + * @return + * hop count from the nearest source, or `Int.MaxValue` when unreachable + */ + def hopGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax, + )( + source: Boolean, + )(using CodableFromTo[Format][Int], UpperBounded[Int]): Int = + val inf = summon[UpperBounded[Int]].upperBound + share[Format, Int](inf): nbrHops => + if source then 0 + else + val minNbr = nbrHops.withoutSelf.min + if minNbr >= inf then inf else minNbr + 1 + + // ── crfGradient ───────────────────────────────────────────────────────────── + + /** + * Constraint-and-Restoring-Force (CRF) gradient. Estimates rise at `raisingSpeed` when no constraint is satisfied + * (source removed), and snap down to the true distance when a valid neighbour provides one. Heals faster than the + * classic gradient on source loss. + * + * @param raisingSpeed + * upward drift speed when no constraint is active (distance units per second) + * @param source + * whether this device is a source + * @param metric + * per-neighbour distance field + * @tparam Format + * serialisation format for `Double` + * @return + * CRF distance estimate from the nearest source + * @see + * [[GradientLibrary.DEFAULT_CRF_RAISING_SPEED]] + */ + def crfGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax & TimeSensor & LagSensor[FiniteDuration], + )(using + CodableFromTo[Format][Double], + UpperBounded[Double], + )( + raisingSpeed: Double, + source: Boolean, + metric: language.SharedData[Double], + ): Double = + val inf = summon[UpperBounded[Double]].upperBound + language + .evolve((inf, 0.0)): (g, speed) => + // Neighbour communication must happen on every device (source included) so the source + // shares its estimate and the gradient can propagate; only the *returned* value branches. + val dt = language.deltaTime.toMillis.toDouble / 1000.0 + val nbrGs = neighborValues[Format, Double](g) + val nbrLags = LagSensor.senseLag[FiniteDuration] + if source then (0.0, 0.0) + else + val constrainedMin = (nbrGs, metric, nbrLags) + .mapN: (nbrG, d, lag) => + val lagSec = lag.toMillis.toDouble / 1000.0 + if nbrG + d + speed * lagSec <= g then nbrG + d else inf + .withoutSelf + .min + if constrainedMin >= inf then (g + raisingSpeed * dt, raisingSpeed) + else (constrainedMin, 0.0) + ._1 + end crfGradient + + /** + * Sensor-based overload of [[crfGradient]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[crfGradient]] for parameter documentation + */ + def sensorCrfGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax & TimeSensor & LagSensor[FiniteDuration] & + DistanceSensor[Double], + )(using + CodableFromTo[Format][Double], + UpperBounded[Double], + )( + raisingSpeed: Double, + source: Boolean, + ): Double = crfGradient[Format](raisingSpeed, source, DistanceSensor.senseDistance[Double]) + + // ── bisGradient ───────────────────────────────────────────────────────────── + + /** + * Bounded-Information-Speed (BIS) gradient. Bounds the speed at which distance estimates can rise, damping the + * classic rising-value transient when the source moves. The estimate is the tighter of the spatial path length and a + * speed-limited temporal bound. + * + * @param commRadius + * communication radius; used in the temporal-bound term to remove the slack from the lag cost + * @param source + * whether this device is a source + * @param metric + * per-neighbour distance field + * @tparam Format + * serialisation format for `Double` + * @return + * BIS distance estimate + */ + def bisGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax & TimeSensor & LagSensor[FiniteDuration], + )(using + CodableFromTo[Format][Double], + UpperBounded[Double], + )( + commRadius: Double, + source: Boolean, + metric: language.SharedData[Double], + ): Double = + val inf = summon[UpperBounded[Double]].upperBound + val dt = language.deltaTime.toMillis.toDouble + val avgFireInterval = meanCounter(dt, 1000L) + val speed = if avgFireInterval <= 0.0 then 0.0 else DEFAULT_FLEX_DELTA / avgFireInterval + language + .evolve((inf, inf)): (spatialDist, tempDist) => + // Communicate on every device (source included) so the gradient propagates; branch only the result. + val nbrSpatials = neighborValues[Format, Double](spatialDist) + val nbrTemps = neighborValues[Format, Double](tempDist) + val nbrLags = LagSensor.senseLag[FiniteDuration] + if source then (0.0, 0.0) + else + val spPairs = (nbrSpatials, nbrTemps).mapN((s, t) => (s, t)) + (spPairs, metric, nbrLags) + .mapN: + case ((nbrS, nbrT), d, lag) => + val newEstimate = Math.max(nbrS + d, speed * nbrT - commRadius) + val newTemp = nbrT + lag.toMillis.toDouble / 1000.0 + (newEstimate, newTemp) + .withoutSelf + .minByOption(_._1) + .getOrElse((inf, inf)) + ._1 + end bisGradient + + /** + * Sensor-based overload of [[bisGradient]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[bisGradient]] for parameter documentation + */ + def sensorBisGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax & TimeSensor & LagSensor[FiniteDuration] & + DistanceSensor[Double], + )(using + CodableFromTo[Format][Double], + UpperBounded[Double], + )( + commRadius: Double, + source: Boolean, + ): Double = bisGradient[Format](commRadius, source, DistanceSensor.senseDistance[Double]) + + // ── flexGradient ──────────────────────────────────────────────────────────── + + /** + * FLEX gradient: updates the local estimate only when the error exceeds `epsilon`, trading precision for reduced + * communication cost. Far-away devices carry coarser estimates, saving bandwidth. + * + * @param epsilon + * tolerance on the slope of the gradient field; smaller → more accurate, higher cost + * @param delta + * minimum fraction of `communicationRadius` treated as neighbour distance (avoids division by zero) + * @param communicationRadius + * nominal communication range + * @param source + * whether this device is a source + * @param metric + * per-neighbour distance field + * @tparam Format + * serialisation format for `Double` + * @return + * FLEX distance estimate; converged value is within `epsilon * distance` of the true distance + * @see + * [[GradientLibrary.DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON]], [[GradientLibrary.DEFAULT_FLEX_DELTA]] + */ + def flexGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + CodableFromTo[Format][Double], + UpperBounded[Double], + )( + epsilon: Double, + delta: Double, + communicationRadius: Double, + source: Boolean, + metric: language.SharedData[Double], + ): Double = + val inf = summon[UpperBounded[Double]].upperBound + language.evolve(inf): g => + // Communicate on every device (source included) so the gradient propagates; branch only the result. + val nbrGs = neighborValues[Format, Double](g) + if source then 0.0 + else + // Each neighbour contributes: (nbrG + dist, slope, nbrG, dist) + // where dist = max(rawMetric, delta * commRadius) guards against zero-division + val combined = (nbrGs, metric).mapN: (nbrG, d) => + val dist = Math.max(d, delta * communicationRadius) + val slope = + if dist == 0.0 || nbrG.isInfinite || g.isInfinite then Double.NegativeInfinity + else (g - nbrG) / dist + (nbrG + dist, slope, nbrG, dist) + val constraint = combined.withoutSelf.minByOption(_._1).map(_._1).getOrElse(inf) + if Math.max(communicationRadius, 2 * constraint) < g then constraint + else + combined.withoutSelf.maxByOption(_._2) match + case None => g + case Some((_, slope, nbrG, dist)) => + if slope > 1 + epsilon then nbrG + (1 + epsilon) * dist + else if slope < 1 - epsilon then nbrG + (1 - epsilon) * dist + else g + end flexGradient + + /** + * Sensor-based overload of [[flexGradient]]; derives `metric` from [[DistanceSensor.senseDistance]]. + * + * @see + * [[flexGradient]] for parameter documentation + */ + def sensorFlexGradient[Format](using + language: AggregateFoundation & FieldCalculusSyntax & DistanceSensor[Double], + )(using + CodableFromTo[Format][Double], + UpperBounded[Double], + )( + epsilon: Double, + delta: Double, + communicationRadius: Double, + source: Boolean, + ): Double = flexGradient[Format](epsilon, delta, communicationRadius, source, DistanceSensor.senseDistance[Double]) + end GradientLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala new file mode 100644 index 00000000..22589051 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala @@ -0,0 +1,186 @@ +package it.unibo.scafi.libraries + +import scala.concurrent.duration.{ DurationInt, FiniteDuration } + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.libraries.All.{ localId, neighborValues } +import it.unibo.scafi.libraries.GradientLibrary.{bisGradient, crfGradient, distanceTo, flexGradient, hopGradient, DEFAULT_CRF_RAISING_SPEED, DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON, DEFAULT_FLEX_DELTA} +import it.unibo.scafi.message.{ Codable, Codables, ValueTree } +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.sensors.{ LagSensor, TimeSensor } +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.network.NoNeighborsNetworkManager +import it.unibo.scafi.utils.boundaries.CommonBoundaries.given + +import org.scalatest.Inspectors + +class GradientLibraryTests extends UnitTest, Inspectors: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + private type TestCtx = ExchangeAggregateContext[Int] + private type TimeLagCtx = ExchangeAggregateContext[Int] & TimeSensor & LagSensor[FiniteDuration] + + private val fixedDelta: FiniteDuration = 100.millis + private val fixedLag: FiniteDuration = 10.millis + + private def timeLagFactory(net: IntNetworkManager, vt: ValueTree): TimeLagCtx = + new ExchangeAggregateContext[Int](net.localId, net.receive, vt) + with TimeSensor + with LagSensor[FiniteDuration]: + override def deltaTime: FiniteDuration = fixedDelta + override def timestamp: Long = 0L + override def senseLag: SharedData[FiniteDuration] = + neighborValues[FiniteDuration, FiniteDuration](fixedLag) + + // ── hopGradient ───────────────────────────────────────────────────────────── + + "hopGradient" should "return 0 at an isolated source" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + hopGradient[Int](source = true) + engine.cycle() shouldBe 0 + + it should "return Int.MaxValue at an isolated non-source" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + hopGradient[Int](source = false) + engine.cycle() shouldBe Int.MaxValue + + it should "compute Chebyshev hop distances on a 3×3 Moore grid" in: + // IDs: x varies outer, y inner → (x=0,y=0)=0, (x=0,y=1)=1, (x=0,y=2)=2, + // (x=1,y=0)=3, (x=1,y=1)=4, (x=1,y=2)=5, (x=2,y=0)=6, (x=2,y=1)=7, (x=2,y=2)=8 + // Source at node 0 = (0,0); Chebyshev distances: + // row y=0: 0,1,2 (IDs 0,3,6) + // row y=1: 1,1,2 (IDs 1,4,7) + // row y=2: 2,2,2 (IDs 2,5,8) + val env = mooreGrid[Int, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + hopGradient[Int](localId == 0) + (0 until 6).foreach(_ => env.cycleInOrder()) + env.status(0) shouldBe 0 + env.status(1) shouldBe 1 + env.status(2) shouldBe 2 + env.status(3) shouldBe 1 + env.status(4) shouldBe 1 + env.status(5) shouldBe 2 + env.status(6) shouldBe 2 + env.status(7) shouldBe 2 + env.status(8) shouldBe 2 + + it should "re-converge after the source moves" in: + var sourceId = 0 + val env = mooreGrid[Int, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + hopGradient[Int](localId == sourceId) + (0 until 6).foreach(_ => env.cycleInOrder()) + env.status(0) shouldBe 0 + // Move source to node 8 = (2,2) + sourceId = 8 + (0 until 6).foreach(_ => env.cycleInOrder()) + env.status(8) shouldBe 0 + env.status(0) shouldBe 2 + + // ── crfGradient convergence parity ────────────────────────────────────────── + + "crfGradient" should "converge to the same distances as distanceTo on a static 3×3 grid" in: + val envCrf = mooreGrid[Double, TimeLagCtx, IntNetworkManager](3, 3, timeLagFactory, inMemoryNetwork): + crfGradient[Double](DEFAULT_CRF_RAISING_SPEED, localId == 0, neighborValues[Double, Double](1.0)) + val envRef = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + distanceTo[Double, Double](localId == 0, neighborValues[Double, Double](1.0)) + (0 until 30).foreach(_ => envCrf.cycleInOrder()) + (0 until 10).foreach(_ => envRef.cycleInOrder()) + for id <- 0 until 9 do + envCrf.status(id) shouldBe (envRef.status(id) +- 0.05) + + it should "re-converge to new distances after source moves" in: + var sourceId = 0 + val envCrf = mooreGrid[Double, TimeLagCtx, IntNetworkManager](3, 3, timeLagFactory, inMemoryNetwork): + crfGradient[Double](DEFAULT_CRF_RAISING_SPEED, localId == sourceId, neighborValues[Double, Double](1.0)) + (0 until 20).foreach(_ => envCrf.cycleInOrder()) + envCrf.status(0) shouldBe (0.0 +- 0.05) + sourceId = 8 + (0 until 30).foreach(_ => envCrf.cycleInOrder()) + envCrf.status(8) shouldBe (0.0 +- 0.05) + // node 0 is at Chebyshev distance 2 from node 8 + envCrf.status(0) shouldBe (2.0 +- 0.1) + + // ── bisGradient convergence parity ────────────────────────────────────────── + + "bisGradient" should "converge to the same distances as distanceTo on a static 3×3 grid" in: + // Use large commRadius so the spatial term dominates over the speed-limited temporal bound + val envBis = mooreGrid[Double, TimeLagCtx, IntNetworkManager](3, 3, timeLagFactory, inMemoryNetwork): + bisGradient[Double](commRadius = 100.0, localId == 0, neighborValues[Double, Double](1.0)) + val envRef = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + distanceTo[Double, Double](localId == 0, neighborValues[Double, Double](1.0)) + (0 until 30).foreach(_ => envBis.cycleInOrder()) + (0 until 10).foreach(_ => envRef.cycleInOrder()) + for id <- 0 until 9 do + envBis.status(id) shouldBe (envRef.status(id) +- 0.05) + + // ── flexGradient convergence parity and tolerance ─────────────────────────── + + "flexGradient" should "converge to within epsilon of distanceTo on a static 3×3 grid" in: + val envFlex = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + flexGradient[Double]( + epsilon = DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON, + delta = DEFAULT_FLEX_DELTA, + communicationRadius = 1.0, + localId == 0, + neighborValues[Double, Double](1.0), + ) + val envRef = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + distanceTo[Double, Double](localId == 0, neighborValues[Double, Double](1.0)) + (0 until 20).foreach(_ => envFlex.cycleInOrder()) + (0 until 10).foreach(_ => envRef.cycleInOrder()) + for id <- 0 until 9 do + val ref = envRef.status(id) + val flex = envFlex.status(id) + val maxError = (1 + DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON) * ref + DEFAULT_FLEX_DELTA + flex should be <= maxError + 0.1 + + it should "give tighter estimates with epsilon=0" in: + val envTight = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + flexGradient[Double]( + epsilon = 0.0, + delta = DEFAULT_FLEX_DELTA, + communicationRadius = 1.0, + localId == 0, + neighborValues[Double, Double](1.0), + ) + val envLoose = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + flexGradient[Double]( + epsilon = 0.5, + delta = DEFAULT_FLEX_DELTA, + communicationRadius = 1.0, + localId == 0, + neighborValues[Double, Double](1.0), + ) + (0 until 20).foreach(_ => envTight.cycleInOrder()) + (0 until 20).foreach(_ => envLoose.cycleInOrder()) + // Tight epsilon should keep estimates closer to the true distance + val tightError = (0 until 9).map(id => envTight.status(id)).sum + val looseError = (0 until 9).map(id => envLoose.status(id)).sum + tightError should be <= looseError + 0.01 + + // ── isolated-node edge case ────────────────────────────────────────────────── + + "crfGradient" should "return infinity at an isolated non-source" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), timeLagFactory): + crfGradient[Double](DEFAULT_CRF_RAISING_SPEED, source = false, neighborValues[Double, Double](1.0)) + // Non-source with no neighbours: estimate rises until it reaches infinity + val result = (0 until 5).map(_ => engine.cycle()) + result.last.isInfinite || result.last > 10.0 shouldBe true + + "flexGradient" should "return infinity at an isolated non-source" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + flexGradient[Double]( + epsilon = DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON, + delta = DEFAULT_FLEX_DELTA, + communicationRadius = 1.0, + source = false, + neighborValues[Double, Double](1.0), + ) + engine.cycle().isInfinite shouldBe true + +end GradientLibraryTests From 76450aae1c91249b0f4b74e28e5147a8767ae167 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Tue, 23 Jun 2026 15:17:48 +0200 Subject: [PATCH 07/12] feat(libraries): add CollectionLibrary (C block) (migration/07) Port the collection operator: collect, findParent/findParentOpt, and the collectCount/Mean/Maps/Sets/IntoSet/ValuesByDevices reducers. Abstract DeviceId is handled via Ordering/UpperBounded context bounds and a DeviceId codec for parent-pointer gossip. Fold neighbour contributions via withoutSelf: the full-field iterator reflects the live alignment scope (not the field's captured devices) and collapses neighbours that share a value, which would undercount sums. Co-Authored-By: Claude Opus 4.8 --- .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../scafi/libraries/CollectionLibrary.scala | 334 ++++++++++++++++++ .../libraries/CollectionLibraryTests.scala | 109 ++++++ 3 files changed, 444 insertions(+) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/CollectionLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index 3e6b3a7f..571d9d75 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -8,6 +8,7 @@ import it.unibo.scafi.utils.boundaries.CommonBoundaries */ object All: export BranchingLibrary.{ *, given } + export CollectionLibrary.{ *, given } export CommonLibrary.{ *, given } export ExchangeCalculusLibrary.{ *, given } export FieldCalculusLibrary.{ *, given } diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala new file mode 100644 index 00000000..8e1dbb82 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala @@ -0,0 +1,334 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax +import it.unibo.scafi.message.{ Codable, CodableFromTo } +import it.unibo.scafi.utils.boundaries.UpperBounded + +import cats.syntax.all.catsSyntaxTuple2Semigroupal + +import FieldCalculusLibrary.{ neighborValues, share } +import CommonLibrary.mux + +/** + * Collection (the **C** block): the dual of gradient-cast. Accumulates values from the whole network *down* a potential + * field toward a sink, summing/merging along the spanning tree induced by the potential. + * + * Each device selects, as its **parent**, the neighbour with the strictly smallest potential, and folds its own `local` + * value with the contributions of all neighbours for which it is the parent. `share` makes the partial sums flow + * downhill, so the sink ends up holding the aggregate over its whole reachable region. + * + * @see + * [[FieldCalculusLibrary.share]] for the underlying primitive + * @see + * [[GradientLibrary.distanceTo]] for a typical potential + * @see + * [[GradientCastLibrary]] for the dual (broadcast) operator + */ +object CollectionLibrary: + + /** + * The id of this device's parent along `potential`: the neighbour with the strictly smallest potential (ties broken + * by the smaller device id). If no neighbour has a strictly smaller potential the device is its own root, and the + * upper-bound sentinel of [[AggregateFoundation.DeviceId]] is returned. + * + * @param potential + * the per-neighbour potential field (lower = closer to the sink) + * @tparam P + * the potential type; must have an [[Ordering]] + * @return + * the parent device id, or the `DeviceId` upper bound when the device is a local minimum + * @see + * [[findParentOpt]] for the total, sentinel-free variant + */ + def findParent[P: Ordering](using + language: AggregateFoundation, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + )( + potential: language.SharedData[P], + ): language.DeviceId = + findParentOpt(potential).getOrElse(summon[UpperBounded[language.DeviceId]].upperBound) + + /** + * The id of this device's parent along `potential`, or `None` when the device is a local minimum (its own root). + * + * @param potential + * the per-neighbour potential field (lower = closer to the sink) + * @tparam P + * the potential type; must have an [[Ordering]] + * @return + * `Some(parentId)` for the strictly-lower-potential neighbour, or `None` + * @see + * [[findParent]] for the sentinel variant + */ + def findParentOpt[P: Ordering as ordP](using + language: AggregateFoundation, + )(using + Ordering[language.DeviceId], + )( + potential: language.SharedData[P], + ): Option[language.DeviceId] = + val myPotential = potential.onlySelf + (potential, language.device) + .mapN((p, id) => (p, id)) + .withoutSelf + .minOption + .filter((minP, _) => ordP.lt(minP, myPotential)) + .map(_._2) + + /** + * Collects `local` values down `potential` toward the sink, combining contributions with `accumulate`. A device sums + * its own `local` with the collected values of every neighbour that selected it as parent; `zero` is the neutral + * contribution for non-children. + * + * @param potential + * the per-neighbour potential field; lower = closer to the sink + * @param local + * the value contributed by this device + * @param accumulate + * associative, commutative combination of contributions (with `zero` as identity) + * @param zero + * the neutral contribution for neighbours that did not select this device as parent + * @tparam Format + * serialisation format for the collected value `V` + * @tparam P + * the potential type; must have an [[Ordering]] + * @tparam V + * the collected value type + * @return + * the value collected at this device (the full aggregate at the sink) + * @see + * [[findParent]], [[FieldUtilsLibrary.minHoodSelector]] + */ + def collect[Format, P: Ordering, V: CodableFromTo[Format]](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + )( + potential: language.SharedData[P], + local: V, + accumulate: (V, V) => V, + zero: V, + ): V = + // Each device shares which neighbour it picked as parent; a child's value flows to that parent. + val parents = neighborValues[language.DeviceId, language.DeviceId](findParent(potential)) + share[Format, V](local): collected => + val contributions = (parents, collected).mapN: (parentId, nbrValue) => + mux(summon[Ordering[language.DeviceId]].equiv(parentId, language.localId))(nbrValue)(zero) + // Fold neighbours only (`withoutSelf`): a device is never its own parent, so self contributes `zero`; + // we also avoid the full-field iterator, which reflects the live alignment scope rather than this field. + accumulate(local, contributions.withoutSelf.foldLeft(zero)(accumulate)) + + /** + * Counts the devices (optionally satisfying `predicate`) collected to the sink. + * + * @param potential + * the per-neighbour potential field + * @param predicate + * when `true`, this device contributes `1` to the count + * @tparam P + * the potential type; must have an [[Ordering]] + * @return + * the number of reachable devices satisfying `predicate`, accumulated at the sink + */ + def collectCount[P: Ordering](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Long, Long], + )( + potential: language.SharedData[P], + predicate: Boolean, + ): Long = + collect[Long, P, Long](potential, mux(predicate)(1L)(0L), _ + _, 0L) + + /** + * Mean of `value` over the collected region, available at the sink. Total: an isolated node reports its own `value`. + * + * @param potential + * the per-neighbour potential field + * @param value + * the per-device value to average + * @tparam P + * the potential type; must have an [[Ordering]] + * @return + * the network-region mean of `value` accumulated at the sink + */ + def collectMean[P: Ordering](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Long, Long], + Codable[Double, Double], + )( + potential: language.SharedData[P], + value: Double, + ): Double = + val count = collectCount(potential, predicate = true) + val total = collect[Double, P, Double](potential, value, _ + _, 0.0) + if count == 0L then value else total / count.toDouble + + /** + * Collects map-valued contributions down to the sink, resolving key collisions with `merge`. + * + * @param potential + * the per-neighbour potential field + * @param local + * the map contributed by this device + * @param merge + * collision policy applied to two values sharing a key: `merge(key, accumulated, incoming)` + * @tparam P + * the potential type; must have an [[Ordering]] + * @tparam K + * the map key type + * @tparam V + * the map value type + * @return + * the merged map accumulated at the sink + * @see + * [[FieldUtilsLibrary.mergeHood]] + */ + def collectMaps[P: Ordering, K, V](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Map[K, V], Map[K, V]], + )( + potential: language.SharedData[P], + local: Map[K, V], + merge: (K, V, V) => V, + ): Map[K, V] = + collect[Map[K, V], P, Map[K, V]](potential, local, mergeMaps(merge), Map.empty) + + /** + * [[collectMaps]] with the default "keep first" collision policy. + * + * @param potential + * the per-neighbour potential field + * @param local + * the map contributed by this device + * @tparam P + * the potential type; must have an [[Ordering]] + * @tparam K + * the map key type + * @tparam V + * the map value type + * @return + * the merged map accumulated at the sink, keeping the first value seen per key + */ + def collectMaps[P: Ordering, K, V](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Map[K, V], Map[K, V]], + )( + potential: language.SharedData[P], + local: Map[K, V], + ): Map[K, V] = + collect[Map[K, V], P, Map[K, V]](potential, local, mergeMaps((_: K, accumulated: V, _: V) => accumulated), Map.empty) + + /** + * Collects the union of all `local` sets at the sink. + * + * @param potential + * the per-neighbour potential field + * @param local + * the set contributed by this device + * @tparam P + * the potential type; must have an [[Ordering]] + * @tparam T + * the set element type + * @return + * the union of all reachable devices' sets, accumulated at the sink + */ + def collectSets[P: Ordering, T](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Set[T], Set[T]], + )( + potential: language.SharedData[P], + local: Set[T], + ): Set[T] = + collect[Set[T], P, Set[T]](potential, local, _ union _, Set.empty) + + /** + * Collects each device's single `local` value into a set at the sink. + * + * @param potential + * the per-neighbour potential field + * @param local + * the value contributed by this device + * @tparam P + * the potential type; must have an [[Ordering]] + * @tparam T + * the value type + * @return + * the set of all reachable devices' values, accumulated at the sink + * @see + * [[collectSets]] + */ + def collectIntoSet[P: Ordering, T](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Set[T], Set[T]], + )( + potential: language.SharedData[P], + local: T, + ): Set[T] = + collectSets(potential, Set(local)) + + /** + * Collects values into a map keyed by their originating device id. + * + * @param potential + * the per-neighbour potential field + * @param local + * the value contributed by this device + * @tparam P + * the potential type; must have an [[Ordering]] + * @tparam T + * the value type + * @return + * the map `deviceId -> value` over all reachable devices, accumulated at the sink + * @see + * [[collectSets]] + */ + def collectValuesByDevices[P: Ordering, T](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Ordering[language.DeviceId], + UpperBounded[language.DeviceId], + Codable[language.DeviceId, language.DeviceId], + Codable[Set[(language.DeviceId, T)], Set[(language.DeviceId, T)]], + )( + potential: language.SharedData[P], + local: T, + ): Map[language.DeviceId, T] = + collectSets(potential, Set(language.localId -> local)).toMap + + // Pairwise, total merge of two maps honouring the per-key `merge` collision policy. + private def mergeMaps[K, V](merge: (K, V, V) => V)(left: Map[K, V], right: Map[K, V]): Map[K, V] = + right.foldLeft(left) { case (accumulated, (key, value)) => + accumulated.updatedWith(key): + case Some(existing) => Some(merge(key, existing, value)) + case None => Some(value) + } +end CollectionLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/CollectionLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/CollectionLibraryTests.scala new file mode 100644 index 00000000..915e8b31 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/CollectionLibraryTests.scala @@ -0,0 +1,109 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.language.foundation.AggregateFoundationMock +import it.unibo.scafi.libraries.All.{ localId, neighborValues } +import it.unibo.scafi.libraries.CollectionLibrary.{ + collect, + collectCount, + collectMean, + collectValuesByDevices, + findParent, + findParentOpt, +} +import it.unibo.scafi.libraries.GradientLibrary.distanceTo +import it.unibo.scafi.message.{ Codable, Codables } +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.network.NoNeighborsNetworkManager +import it.unibo.scafi.utils.boundaries.CommonBoundaries.given + +import org.scalatest.Inspectors + +class CollectionLibraryTests extends UnitTest, Inspectors: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + private type TestCtx = ExchangeAggregateContext[Int] + + // ── findParent / findParentOpt (pure, mock) ────────────────────────────── + + given lang: AggregateFoundationMock = AggregateFoundationMock() + // lang.DeviceId = Int; mock device field = Seq(0,1,...,9); self at index 0. + + "findParentOpt" should "return None when the device is the local minimum (the sink)" in: + val potential = lang.mockField(Seq(0.0, 1.0, 2.0)) // self potential 0.0 is the smallest + findParentOpt(potential) shouldBe None + + it should "return the strictly-lower-potential neighbour" in: + val potential = lang.mockField(Seq(2.0, 1.0, 3.0)) // neighbour 1 has potential 1.0 < 2.0 + findParentOpt(potential) shouldBe Some(1) + + it should "break ties on potential by the smaller device id" in: + val potential = lang.mockField(Seq(5.0, 1.0, 1.0)) // neighbours 1 and 2 tie at 1.0 + findParentOpt(potential) shouldBe Some(1) + + "findParent" should "return the DeviceId upper bound for a local minimum" in: + val potential = lang.mockField(Seq(0.0, 1.0, 2.0)) + findParent(potential) shouldBe Int.MaxValue + + // ── collect network convergence ────────────────────────────────────────── + + "collectCount" should "accumulate the total reachable device count at the sink" in: + val env = mooreGrid[Long, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collectCount[Double](potential, predicate = true) + (0 until 20).foreach(_ => env.cycleInOrder()) + // 3×3 grid → 9 reachable devices, all collected to the sink (no double counting). + env.status(0) shouldBe 9L + + "collect" should "sum local contributions to the sink, matching collectCount" in: + val env = mooreGrid[Long, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collect[Long, Double, Long](potential, 1L, _ + _, 0L) + (0 until 20).foreach(_ => env.cycleInOrder()) + env.status(0) shouldBe 9L + + "collectMean" should "report the true mean of a constant field at the sink" in: + val env = mooreGrid[Double, TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collectMean[Double](potential, value = 5.0) + (0 until 20).foreach(_ => env.cycleInOrder()) + env.status(0) shouldBe (5.0 +- 1e-9) + + "collectValuesByDevices" should "gather every device's value into a map at the sink" in: + val env = mooreGrid[Map[Int, Int], TestCtx, IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collectValuesByDevices[Double, Int](potential, localId * 10) + (0 until 20).foreach(_ => env.cycleInOrder()) + val expected = (0 until 9).map(id => id -> id * 10).toMap + env.status(0) shouldBe expected + + it should "not double-count on a larger grid with many equidistant paths" in: + // A 4×4 Moore grid has many shortest paths to the corner sink; the single-parent + // tie-break must still yield exactly one parent per node (total = 16, not more). + val env = mooreGrid[Long, TestCtx, IntNetworkManager](4, 4, exchangeContextFactory, inMemoryNetwork): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collectCount[Double](potential, predicate = true) + (0 until 30).foreach(_ => env.cycleInOrder()) + env.status(0) shouldBe 16L + + // ── isolated node (totality) ───────────────────────────────────────────── + + "collectCount" should "report 1 on an isolated sink" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collectCount[Double](potential, predicate = true) + (0 until 5).map(_ => engine.cycle()).last shouldBe 1L + + "collectMean" should "report the device's own value on an isolated sink (no division by zero)" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + val potential = neighborValues[Double, Double](distanceTo[Double, Double](localId == 0, neighborValues(1.0))) + collectMean[Double](potential, value = 7.0) + (0 until 5).map(_ => engine.cycle()).last shouldBe (7.0 +- 1e-9) + +end CollectionLibraryTests From 6e7524537c9b1c53e21284594d5ba04e81428487 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Thu, 25 Jun 2026 08:46:26 +0200 Subject: [PATCH 08/12] feat(libraries): add SparseChoiceLibrary (S block) leader election (migration/08) Port the S block: sparseChoice/breakUsingUids/randomUid/minId. Devices compete via UIDs (random seed, deviceId) latched by evolve; a device abdicates to a lower nearby UID, "near" decided by distanceTo against grain. UIDs ordered lexicographically; grain/2 checks done by doubling to keep D a plain Numeric. neighborValues hoisted above the branch for alignment; self folded explicitly to avoid the full-field iterator. Co-Authored-By: Claude Opus 4.8 --- .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../scafi/libraries/SparseChoiceLibrary.scala | 198 ++++++++++++++++++ .../libraries/SparseChoiceLibraryTests.scala | 115 ++++++++++ 3 files changed, 314 insertions(+) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index 571d9d75..983f3903 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -17,6 +17,7 @@ object All: export GradientCastLibrary.{ *, given } export GradientLibrary.{ *, given } export MathLibrary.{ *, given } + export SparseChoiceLibrary.{ *, given } export StateLibrary.{ *, given } export TimeLibrary.{ *, given } export CommonBoundaries.{ *, given } diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala new file mode 100644 index 00000000..18f2a8b0 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala @@ -0,0 +1,198 @@ +package it.unibo.scafi.libraries + +import scala.math.Numeric.Implicits.infixNumericOps + +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax +import it.unibo.scafi.message.{ Codable, CodableFromTo } +import it.unibo.scafi.sensors.RandomGenerator +import it.unibo.scafi.sensors.RandomGenerator.nextRandom +import it.unibo.scafi.utils.boundaries.UpperBounded + +import cats.syntax.all.catsSyntaxTuple4Semigroupal + +import FieldCalculusLibrary.{ evolve, neighborValues, share } +import GradientLibrary.distanceTo + +/** + * Sparse-choice / leader election (the **S** block). Elects a sparse set of leaders such that any two leaders are + * roughly `grain` apart — the basis for partitioning a network into regions, multi-leader coordination, and so on. + * + * Every device starts as a candidate leader carrying a unique id `uid = (randomSeed, deviceId)`. Devices then + * **compete**: a device abdicates in favour of a nearby device with a lower UID, where "nearby" is decided by a gradient + * distance measured against `grain`. The surviving leaders form a Poisson-disc-like sparse set with mean spacing + * `grain`. + * + * '''Design choices for the abstract [[AggregateFoundation.DeviceId]]''': + * - UIDs are ordered '''lexicographically''': by the random seed first, then by device id as a deterministic + * tie-break. This requires only an `Ordering[DeviceId]`. + * - [[minId]] gossips the minimum id over a connected component and therefore needs an + * [[UpperBounded]] `DeviceId` for the empty-neighbourhood case. + * + * @see + * [[GradientLibrary.distanceTo]] for the distance gradient driving the competition + * @see + * [[GradientCastLibrary]] for the dual broadcast operator + * @see + * [[RandomGenerator]] for the per-round random stream backing [[randomUid]] + */ +object SparseChoiceLibrary: + + /** + * Elects a sparse set of leaders that are roughly `grain` apart, measuring distance with `metric`. + * + * @param grain + * the mean target spacing between two leaders + * @param metric + * the per-neighbour distance field (e.g. hop = `neighborValues(1)`) + * @tparam Format + * serialisation format for the shared UID + * @tparam D + * the distance/grain type + * @return + * `true` iff this device is a surviving leader + * @see + * [[breakUsingUids]] for the underlying competition + */ + def sparseChoice[Format, D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax & RandomGenerator, + )(using + Ordering[language.DeviceId], + Codable[D, D], + CodableFromTo[Format][(Double, language.DeviceId)], + )( + grain: D, + metric: language.SharedData[D], + ): Boolean = + breakUsingUids[Format, D](randomUid, grain, metric) + + /** + * The minimum device id over this device's connected component (a gossip of the component-wide minimum). + * + * @tparam Format + * serialisation format for the shared id + * @return + * the smallest reachable device id; the device's own id on an isolated node + */ + def minId[Format](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + ordering: Ordering[language.DeviceId], + bound: UpperBounded[language.DeviceId], + codec: CodableFromTo[Format][language.DeviceId], + ): language.DeviceId = + share[Format, language.DeviceId](bound.upperBound): neighbouringIds => + ordering.min(language.localId, neighbouringIds.withoutSelf.min) + + /** + * A stable, per-device unique id `(randomSeed, deviceId)`. The random seed is '''latched''' on the first round (via + * [[FieldCalculusLibrary.evolve]]) so the UID never changes across rounds, while the device id guarantees global + * uniqueness even on a seed collision. + * + * @return + * this device's stable unique id + */ + def randomUid(using + language: AggregateFoundation & FieldCalculusSyntax & RandomGenerator, + ): (Double, language.DeviceId) = + evolve((nextRandom, language.localId))(latched => (latched._1, language.localId)) + + /** + * Symmetry-breaking competition driving [[sparseChoice]], exposed because it is a reusable building block: it elects + * leaders given an arbitrary, externally-provided `uid` field rather than [[randomUid]]. + * + * Each device shares the UID of the leader it currently follows. It measures the gradient distance to that leader and, + * via [[distanceCompetition]], either keeps following it, abdicates, or re-stands as a candidate for a new region. + * + * @param uid + * this device's unique id (lower wins ties) + * @param grain + * the mean target spacing between two leaders + * @param metric + * the per-neighbour distance field + * @tparam Format + * serialisation format for the shared UID + * @tparam D + * the distance/grain type + * @return + * `true` iff this device survives as a leader (it still follows its own `uid`) + */ + def breakUsingUids[Format, D: {Numeric, UpperBounded}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + ordering: Ordering[language.DeviceId], + distanceCodec: Codable[D, D], + codec: CodableFromTo[Format][(Double, language.DeviceId)], + )( + uid: (Double, language.DeviceId), + grain: D, + metric: language.SharedData[D], + ): Boolean = + val elected = share[Format, (Double, language.DeviceId)](uid): leadField => + val lead = leadField.onlySelf + // Distance from this device to the leader it currently follows (0 when it is the leader). + val distanceToLeader = distanceTo[D, D](sameUid(lead, uid), metric) + distanceCompetition[D](distanceToLeader, leadField, uid, grain, metric) + sameUid(elected, uid) + + /** + * Candidate leaders surrender leadership to the lowest nearby UID. Given the gradient `distanceToLeader` to the + * currently-followed leader, a device: + * - re-stands as a candidate for its own region when farther than `grain` from the leader; + * - abdicates (yields `+∞`) at an intermediate distance (`distanceToLeader ≥ grain / 2`); + * - otherwise follows the lowest UID among nearby leaders. + * + * Comparisons against `grain / 2` are expressed by '''doubling''' the distance (`2·d ≥ grain`) so the whole block + * needs only a [[Numeric]] `D`, never a `Fractional` one. + * + * @param distanceToLeader + * gradient distance from this device to the leader it currently follows + * @param leadField + * the shared field of neighbours' currently-followed leaders + * @param uid + * this device's unique id + * @param grain + * the mean target spacing between two leaders + * @param metric + * the per-neighbour distance field + * @return + * the leader UID this device follows after the competition step + */ + private def distanceCompetition[D: {Numeric as num}](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + ordering: Ordering[language.DeviceId], + distanceCodec: Codable[D, D], + )( + distanceToLeader: D, + leadField: language.SharedData[(Double, language.DeviceId)], + uid: (Double, language.DeviceId), + grain: D, + metric: language.SharedData[D], + ): (Double, language.DeviceId) = + given uidOrdering: Ordering[(Double, language.DeviceId)] = uidLexicographicOrdering(using ordering) + def twice(value: D): D = value + value + // Hoisted above the branch so neighbour communication happens on every device, every round (alignment). + val neighbourDistances = neighborValues[D, D](distanceToLeader) + if num.gt(distanceToLeader, grain) then uid + else if num.gteq(twice(distanceToLeader), grain) then (Double.PositiveInfinity, uid._2) + else + // Among nearby leaders, abdicating ones (too far via this neighbour) contribute +∞ tagged with their own id; + // the lexicographic min then selects the lowest surviving UID, self included. + val nearbyLeaders = (neighbourDistances, metric, leadField, language.device) + .mapN: (neighbourDistance, edge, neighbourLead, neighbourId) => + if num.gteq(twice(neighbourDistance + edge), grain) then (Double.PositiveInfinity, neighbourId) + else neighbourLead + .withoutSelf + (nearbyLeaders.toList :+ leadField.onlySelf).min(using uidOrdering) + + /** Lexicographic ordering on UIDs: by random seed first, then by device id as a deterministic tie-break. */ + private def uidLexicographicOrdering[Id](using idOrdering: Ordering[Id]): Ordering[(Double, Id)] = + (left, right) => + val bySeed = java.lang.Double.compare(left._1, right._1) + if bySeed != 0 then bySeed else idOrdering.compare(left._2, right._2) + + /** UID equality consistent with [[uidLexicographicOrdering]], avoiding multiversal-equality on the abstract id. */ + private def sameUid[Id](left: (Double, Id), right: (Double, Id))(using idOrdering: Ordering[Id]): Boolean = + left._1 == right._1 && idOrdering.equiv(left._2, right._2) +end SparseChoiceLibrary diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala new file mode 100644 index 00000000..578d7eb3 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala @@ -0,0 +1,115 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.libraries.All.neighborValues +import it.unibo.scafi.libraries.SparseChoiceLibrary.{ breakUsingUids, minId, randomUid, sparseChoice } +import it.unibo.scafi.message.{ Codable, Codables, ValueTree } +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.sensors.RandomGenerator +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.network.NoNeighborsNetworkManager +import it.unibo.scafi.utils.boundaries.CommonBoundaries.given + +import org.scalatest.Inspectors + +class SparseChoiceLibraryTests extends UnitTest, Inspectors: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + private type RandomCtx = ExchangeAggregateContext[Int] & RandomGenerator + + // Per-device RNG reseeded with the device id each round: stable per device (so randomUid latches the same value + // every round) and reproducible across runs — exactly what deterministic leader-election tests need. + private def randomFactory(net: IntNetworkManager, vt: ValueTree): RandomCtx = + new ExchangeAggregateContext[Int](net.localId, net.receive, vt) with RandomGenerator: + private val rng = scala.util.Random(net.localId.toLong) + override def nextRandom: Double = rng.nextDouble() + + // ── randomUid ──────────────────────────────────────────────────────────── + + "randomUid" should "latch the random seed so the uid is stable across rounds" in: + // A persistent, advancing rng: nextRandom changes every round, yet the latched uid must not. + val rng = scala.util.Random(7) + def advancingFactory(net: IntNetworkManager, vt: ValueTree): RandomCtx = + new ExchangeAggregateContext[Int](net.localId, net.receive, vt) with RandomGenerator: + override def nextRandom: Double = rng.nextDouble() + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), advancingFactory): + (randomUid, RandomGenerator.nextRandom) + val (uid1, draw1) = engine.cycle() + val (uid2, draw2) = engine.cycle() + uid1 shouldBe uid2 // latched: the uid is the round-1 seed forever + uid1._2 shouldBe 0 // the id component is the device id + draw1 should not equal draw2 // sanity: the underlying stream really is advancing + + // ── minId ──────────────────────────────────────────────────────────────── + + "minId" should "return the device's own id on an isolated node" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + minId[Int] + (0 until 5).map(_ => engine.cycle()).last shouldBe 0 + + it should "gossip the global minimum id over a connected grid" in: + val env = mooreGrid[Int, ExchangeAggregateContext[Int], IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + minId[Int] + (0 until 10).foreach(_ => env.cycleInOrder()) + forAll(env.status.toSeq): (_, minimumId) => + minimumId shouldBe 0 + + // ── sparseChoice ─────────────────────────────────────────────────────────── + + "sparseChoice" should "elect at least one leader on a connected grid" in: + val env = mooreGrid[Boolean, RandomCtx, IntNetworkManager](5, 5, randomFactory, inMemoryNetwork): + sparseChoice[(Double, Int), Double](grain = 2.0, neighborValues[Double, Double](1.0)) + (0 until 30).foreach(_ => env.cycleInOrder()) + val leaders = (0 until 25).filter(env.status) + leaders should not be empty + + it should "be deterministic: the same seed yields the same leader set across runs" in: + def run(): Set[Int] = + val env = mooreGrid[Boolean, RandomCtx, IntNetworkManager](5, 5, randomFactory, inMemoryNetwork): + sparseChoice[(Double, Int), Double](grain = 2.0, neighborValues[Double, Double](1.0)) + (0 until 30).foreach(_ => env.cycleInOrder()) + (0 until 25).filter(env.status).toSet + run() shouldBe run() + + it should "elect fewer leaders for a larger grain (sparser set)" in: + def leaderCount(grain: Double): Int = + val env = mooreGrid[Boolean, RandomCtx, IntNetworkManager](6, 6, randomFactory, inMemoryNetwork): + sparseChoice[(Double, Int), Double](grain, neighborValues[Double, Double](1.0)) + (0 until 40).foreach(_ => env.cycleInOrder()) + (0 until 36).count(env.status) + leaderCount(4.0) should be <= leaderCount(1.0) + + it should "settle: the leader set is unchanged once the competition has converged" in: + val env = mooreGrid[Boolean, RandomCtx, IntNetworkManager](5, 5, randomFactory, inMemoryNetwork): + sparseChoice[(Double, Int), Double](grain = 2.0, neighborValues[Double, Double](1.0)) + (0 until 40).foreach(_ => env.cycleInOrder()) + val settled = (0 until 25).map(env.status) + (0 until 5).foreach(_ => env.cycleInOrder()) + (0 until 25).map(env.status) shouldBe settled + + "breakUsingUids" should "elect exactly the single global-minimum-uid device as the sole leader for a large grain" in: + // With a grain covering the whole 3×3 grid, every device falls within one region, so the lowest UID wins alone. + val env = mooreGrid[(Boolean, (Double, Int)), RandomCtx, IntNetworkManager]( + 3, + 3, + randomFactory, + inMemoryNetwork, + ): + val uid = randomUid + (breakUsingUids[(Double, Int), Double](uid, grain = 10.0, neighborValues[Double, Double](1.0)), uid) + (0 until 30).foreach(_ => env.cycleInOrder()) + val results = (0 until 9).map(env.status) + val leaders = results.collect { case (true, uid) => uid } + val globalMinUid = results.map(_._2).min + leaders shouldBe Seq(globalMinUid) + + "sparseChoice" should "elect the isolated node itself as a leader" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), randomFactory): + sparseChoice[(Double, Int), Double](grain = 2.0, neighborValues[Double, Double](1.0)) + (0 until 5).map(_ => engine.cycle()).last shouldBe true +end SparseChoiceLibraryTests From c688a597850e44ef3e86eed8f3fb3ba0c1b0671d Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Thu, 25 Jun 2026 09:41:37 +0200 Subject: [PATCH 09/12] feat(libraries): add ProcessLibrary (S block) with sspawn and replicated (migration/10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConditionalExportLanguage/Context — a snapshot/rollback primitive that gates exchange-state participation so devices outside a process bubble remain invisible to in-bubble neighbours (equivalent to legacy vm.newExportStack + mergeExport / discardExport). Wire it into ExchangeAggregateContext and OutboundMessage. ProcessLibrary implements: - ProcessStatus enum (External / Bubble / Output / Terminated) - ProcessOutput[+R] case class - sspawn: dynamic, keyed spatial bubbles with per-key conditionallyExport gating and gossip-based handleTermination propagation - replicated: time-rotating window of replicates process instances driven by sharedTimerWithDecay 14 new tests; 191/191 pass. Co-Authored-By: Claude Sonnet 4.6 --- .../common/ConditionalExportContext.scala | 12 ++ .../context/xc/ExchangeAggregateContext.scala | 3 +- .../common/ConditionalExportLanguage.scala | 29 +++ .../scala/it/unibo/scafi/libraries/All.scala | 1 + .../scafi/libraries/ProcessLibrary.scala | 160 ++++++++++++++++ .../unibo/scafi/message/OutboundMessage.scala | 17 ++ .../scafi/libraries/ProcessLibraryTests.scala | 172 ++++++++++++++++++ 7 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala create mode 100644 scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala create mode 100644 scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala b/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala new file mode 100644 index 00000000..e26e97fd --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala @@ -0,0 +1,12 @@ +package it.unibo.scafi.context.common + +import it.unibo.scafi.language.common.ConditionalExportLanguage +import it.unibo.scafi.message.OutboundMessage + +/** Concrete implementation of [[ConditionalExportLanguage]] that delegates to [[OutboundMessage.withRollbackUnless]]. */ +trait ConditionalExportContext extends ConditionalExportLanguage: + self: OutboundMessage => + + override def conditionallyExport[T](keep: T => Boolean)(body: () => T): T = + withRollbackUnless(keep)(body()) +end ConditionalExportContext diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/context/xc/ExchangeAggregateContext.scala b/scafi3-core/src/main/scala/it/unibo/scafi/context/xc/ExchangeAggregateContext.scala index 54ab1f20..64851f92 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/context/xc/ExchangeAggregateContext.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/context/xc/ExchangeAggregateContext.scala @@ -1,7 +1,7 @@ package it.unibo.scafi.context.xc import it.unibo.scafi.context.AggregateContext -import it.unibo.scafi.context.common.BranchingContext +import it.unibo.scafi.context.common.{ BranchingContext, ConditionalExportContext } import it.unibo.scafi.language.xc.{ ExchangeLanguage, FieldBasedSharedData } import it.unibo.scafi.language.xc.calculus.ExchangeCalculus import it.unibo.scafi.message.{ CodableFromTo, Import, InboundMessage, OutboundMessage, ValueTree } @@ -18,6 +18,7 @@ trait ExchangeAggregateContext[ID]( override val selfMessagesFromPreviousRound: ValueTree, ) extends AggregateContext, BranchingContext, + ConditionalExportContext, ExchangeLanguage, ExchangeCalculus, FieldBasedSharedData, diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala b/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala new file mode 100644 index 00000000..c0f6d2d6 --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala @@ -0,0 +1,29 @@ +package it.unibo.scafi.language.common + +/** + * Language capability for conditionally including a sub-computation in the outbound message. When `keep` returns + * `false` the exchange state written by `body` is rolled back so that aligned neighbours do not see this device's state + * for the discarded scope. + * + * This is the scafi3 equivalent of the legacy `exportConditionally` / `vm.newExportStack` + `mergeExport` / + * `discardExport` mechanism, and is the primitive required for correct `sspawn` semantics (devices outside a process + * bubble must not pollute alignment inside the bubble). + */ +trait ConditionalExportLanguage: + + /** + * Runs `body` and, if `keep(result)` is `false`, rolls back every exchange write produced by `body` so that + * neighbours do not see this device's state for the body's alignment scope. The result is always returned + * regardless. + * + * @param keep + * predicate on the body's result; `false` ⇒ discard this round's export + * @param body + * the computation to run (and conditionally export) + * @tparam T + * result type + * @return + * the result of `body` whether kept or discarded + */ + def conditionallyExport[T](keep: T => Boolean)(body: () => T): T +end ConditionalExportLanguage diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala index 983f3903..3390c789 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/All.scala @@ -17,6 +17,7 @@ object All: export GradientCastLibrary.{ *, given } export GradientLibrary.{ *, given } export MathLibrary.{ *, given } + export ProcessLibrary.{ *, given } export SparseChoiceLibrary.{ *, given } export StateLibrary.{ *, given } export TimeLibrary.{ *, given } diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala new file mode 100644 index 00000000..96a81d7c --- /dev/null +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala @@ -0,0 +1,160 @@ +package it.unibo.scafi.libraries + +import it.unibo.scafi.language.AggregateFoundation +import it.unibo.scafi.language.common.ConditionalExportLanguage +import it.unibo.scafi.language.common.syntax.BranchingSyntax +import it.unibo.scafi.language.fc.syntax.FieldCalculusSyntax +import it.unibo.scafi.message.Codable +import it.unibo.scafi.sensors.TimeSensor + +import FieldCalculusLibrary.share +import TimeLibrary.sharedTimerWithDecay + +/** + * Process (the **S** block, spawn machinery): dynamic, keyed sub-computations that self-organise into spatial + * "bubbles". Devices within a bubble collaborate to produce an output; once all bubble members decide to terminate the + * bubble shrinks and eventually vanishes. + * + * The two entry points are: + * - [[sspawn]] — general-purpose spawn: manages a dynamic set of keyed processes. + * - [[replicated]] — time-replicated spawn: runs a fixed number of overlapping instances of a process, rotating on a + * wall-clock period. + * + * '''Alignment note''': `K.toString` must be unique across all concurrently active keys because it is used both as the + * per-process alignment token and as the sort key that ensures all devices traverse active keys in the same order. + */ +object ProcessLibrary: + + /** + * Lifecycle of a single process instance as seen by the device running it. + * + * `derives CanEqual` is required because [[ProcessOutput.status]] is compared with `==` in + * [[ProcessLibrary.handleTermination]] under strict equality. + */ + enum ProcessStatus derives CanEqual: + /** This device is outside the process bubble. */ + case External + + /** This device is inside the bubble but has not yet produced a final output. */ + case Bubble + + /** This device is inside the bubble and is producing a result. */ + case Output + + /** This device has decided to terminate; termination is propagating through the bubble. */ + case Terminated + + /** Value returned by a process function: the current result together with the lifecycle status. */ + final case class ProcessOutput[+R](result: R, status: ProcessStatus) + + /** + * Dynamic, keyed spawn (the **S** block). Maintains a set of process instances, one per key, organised as spatial + * bubbles. A process instance is born when a key appears in `generation` and dies when the process function returns + * [[ProcessStatus.Terminated]] and that termination signal has propagated to all bubble members. + * + * Keys spread through the network via the outer `share`; `conditionallyExport` gates participation so that devices + * with status [[ProcessStatus.External]] are invisible inside the bubble. + * + * @param process + * function from key → argument → [[ProcessOutput]]; called once per active key per round + * @param generation + * the set of keys born at this device this round (typically a singleton or empty) + * @param args + * argument forwarded to every process invocation at this device + * @tparam K + * key type; `K.toString` must be unique across concurrent keys + * @tparam A + * argument type + * @tparam R + * result type + * @return + * map from key to result for all keys currently in the [[ProcessStatus.Output]] state at this device + */ + def sspawn[K, A, R](using + language: AggregateFoundation & FieldCalculusSyntax & ConditionalExportLanguage, + )(using + Codable[Map[K, R], Map[K, R]], + Codable[(Boolean, Boolean), (Boolean, Boolean)], + )(process: K => A => ProcessOutput[R], generation: Set[K], args: A): Map[K, R] = + share[Map[K, R], Map[K, R]](Map.empty) { prevResults => + // Collect all keys seen by any neighbour plus locally generated ones. + val activeKeys: Set[K] = prevResults.withoutSelf.foldLeft(generation)((ks, nbrMap) => ks ++ nbrMap.keySet) + // Sort for deterministic per-key alignment-token ordering: identical sorted positions across devices + // that share the same active-key set gives reproducible invocation-count paths. + activeKeys.toList.sortBy(_.toString).foldLeft(Map.empty[K, R]) { (acc, k) => + language.align(k.toString) { () => + val out: ProcessOutput[R] = language.conditionallyExport( + (o: ProcessOutput[R]) => o.status != ProcessStatus.External, + ) { () => + handleTermination(process(k)(args)) + } + out match + case ProcessOutput(r, ProcessStatus.Output) => acc + (k -> r) + case _ => acc + } + } + } + + /** + * Time-replicated spawn: runs `replicates` overlapping instances of `proc`, identified by a shared wall-clock id + * (`Long`). A new instance is born every `period` seconds and the oldest one expires when the window of `replicates` + * slots is exceeded. + * + * @param proc + * the computation to replicate; receives the current `argument` + * @param argument + * argument forwarded to every replica + * @param period + * rotation period in seconds + * @param replicates + * number of simultaneously active replicas + * @tparam T + * argument type + * @tparam R + * result type + * @return + * map from replica id (`Long`) to result for all currently active replicas + */ + def replicated[T, R](using + language: AggregateFoundation & FieldCalculusSyntax & ConditionalExportLanguage & TimeSensor & BranchingSyntax, + )(using + Codable[Map[Long, R], Map[Long, R]], + Codable[(Boolean, Boolean), (Boolean, Boolean)], + Codable[Double, Double], + )(proc: T => R, argument: T, period: Double, replicates: Int): Map[Long, R] = + val dt = language.deltaTime.toNanos.toDouble / 1e9 + val lastPid = sharedTimerWithDecay[Double, Double](period, dt).toLong + sspawn[Long, T, R]( + (pid: Long) => + _ => + ProcessOutput( + proc(argument), + if pid > lastPid - replicates then ProcessStatus.Output else ProcessStatus.External, + ), + Set(lastPid), + argument, + ) + + /** + * Wraps `out` with gossip-based termination logic. Once any bubble device returns + * [[ProcessStatus.Terminated]], that signal propagates; when all in-bubble neighbours have received it the device + * exits the bubble ([[ProcessStatus.External]]). + * + * Uses `share[(Boolean, Boolean)]` where `_._1` = "I or any neighbour wants to terminate" and `_._2` = "all + * in-bubble neighbours are also terminating". The shared value is `(mustTerminate, mustExit)`. + */ + private def handleTermination[R](using + language: AggregateFoundation & FieldCalculusSyntax, + )(using + Codable[(Boolean, Boolean), (Boolean, Boolean)], + )(out: ProcessOutput[R]): ProcessOutput[R] = + val (mustTerminate, mustExit) = share[(Boolean, Boolean), (Boolean, Boolean)]((false, false)) { field => + val myMT = out.status == ProcessStatus.Terminated || field.withoutSelf.exists(_._1) + val myME = field.withoutSelf.forall(_._1) + (myMT, myME) + } + if mustTerminate && mustExit then ProcessOutput(out.result, ProcessStatus.External) + else if mustTerminate then ProcessOutput(out.result, ProcessStatus.Terminated) + else out + +end ProcessLibrary diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/message/OutboundMessage.scala b/scafi3-core/src/main/scala/it/unibo/scafi/message/OutboundMessage.scala index 32fd29de..8cd5b041 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/message/OutboundMessage.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/message/OutboundMessage.scala @@ -27,6 +27,23 @@ trait OutboundMessage: registeredSelfMessages.update(path, encode(overrides.getOrElse(localId, default))) registeredMessages.update(path, MapWithDefault(overrides.view.mapValues(encode).toMap, encode(default))) + /** + * Runs `body`, then rolls back every exchange write made during `body` if `keep(result)` is `false`. The result is + * returned regardless. + * + * Used by [[it.unibo.scafi.context.common.ConditionalExportContext]] to implement conditional export. + */ + protected def withRollbackUnless[T](keep: T => Boolean)(body: => T): T = + val snapMessages = registeredMessages.toMap + val snapSelfMessages = registeredSelfMessages.toMap + val result = body + if !keep(result) then + registeredMessages.clear() + registeredMessages.addAll(snapMessages) + registeredSelfMessages.clear() + registeredSelfMessages.addAll(snapSelfMessages) + result + override def selfMessagesForNextRound: ValueTree = ValueTree(registeredSelfMessages.toMap) override def exportFromOutboundMessages: Export[DeviceId] = diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala new file mode 100644 index 00000000..0bd40f45 --- /dev/null +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala @@ -0,0 +1,172 @@ +package it.unibo.scafi.libraries + +import scala.concurrent.duration.{ DurationInt, FiniteDuration } + +import it.unibo.scafi.UnitTest +import it.unibo.scafi.context.xc.ExchangeAggregateContext +import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory +import it.unibo.scafi.libraries.All.localId +import it.unibo.scafi.libraries.ProcessLibrary.{replicated, sspawn, ProcessOutput, ProcessStatus} +import it.unibo.scafi.libraries.ProcessLibrary.ProcessStatus.{ Bubble, External, Output, Terminated } +import it.unibo.scafi.message.{ Codable, Codables, ValueTree } +import it.unibo.scafi.runtime.ScafiEngine +import it.unibo.scafi.sensors.TimeSensor +import it.unibo.scafi.test.environment.Grids.mooreGrid +import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork +import it.unibo.scafi.test.network.NoNeighborsNetworkManager + +import org.scalatest.Inspectors + +class ProcessLibraryTests extends UnitTest, Inspectors: + + given [V]: Codable[V, V] = Codables.forInMemoryCommunications + + private type ProcCtx = ExchangeAggregateContext[Int] & TimeSensor + + private val fixedDelta: FiniteDuration = 100.millis + + private def timerFactory(net: IntNetworkManager, vt: ValueTree): ProcCtx = + new ExchangeAggregateContext[Int](net.localId, net.receive, vt) with TimeSensor: + override def deltaTime: FiniteDuration = fixedDelta + override def timestamp: Long = 0L + + // ── ProcessStatus enum ───────────────────────────────────────────────────── + + "ProcessStatus" should "have four distinct cases" in: + List(External, Bubble, Output, Terminated).distinct should have size 4 + + "ProcessStatus" should "support equality comparison" in: + Output shouldBe Output + External should not be Output + Terminated should not be Bubble + + // ── ProcessOutput ────────────────────────────────────────────────────────── + + "ProcessOutput" should "preserve result and status" in: + val out = ProcessOutput(99, Output) + out.result shouldBe 99 + out.status shouldBe Output + + "ProcessOutput" should "be covariant in result type" in: + // ProcessOutput[Int] is assignable to ProcessOutput[Any] thanks to +R covariance + val out: ProcessOutput[Any] = ProcessOutput(42, Bubble) + out.status shouldBe Bubble + + // ── sspawn single-device tests ───────────────────────────────────────────── + + "sspawn" should "return empty map when no keys are generated" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + sspawn[String, Unit, Int](_ => _ => ProcessOutput(0, Output), Set.empty, ()) + (0 until 5).map(_ => engine.cycle()).last shouldBe Map.empty[String, Int] + + it should "include Output keys in the result" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + sspawn[String, Unit, Int](_ => _ => ProcessOutput(42, Output), Set("k"), ()) + engine.cycle() shouldBe Map("k" -> 42) + + it should "exclude External keys from the result" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + sspawn[String, Unit, Int]( + k => _ => if k == "on" then ProcessOutput(1, Output) else ProcessOutput(0, External), + Set("on", "off"), + (), + ) + engine.cycle() shouldBe Map("on" -> 1) + + it should "exit immediately when process returns Terminated on an isolated node" in: + // Vacuous ∀ on an empty neighbourhood ⇒ mustExit is true from round 0, so + // Terminated + mustTerminate ∧ mustExit ⇒ External right away. + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + sspawn[String, Unit, Int](_ => _ => ProcessOutput(0, Terminated), Set("k"), ()) + engine.cycle() shouldBe Map.empty[String, Int] + + it should "pass arguments to the process function" in: + val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): + sspawn[String, Int, Int](_ => n => ProcessOutput(n * 2, Output), Set("k"), 21) + engine.cycle() shouldBe Map("k" -> 42) + + // ── sspawn bubble expansion ──────────────────────────────────────────────── + + "sspawn" should "propagate a key from the source to all grid devices" in: + val env = mooreGrid[Map[Int, Int], ExchangeAggregateContext[Int], IntNetworkManager]( + 3, + 3, + exchangeContextFactory, + inMemoryNetwork, + ): + sspawn[Int, Unit, Int]( + k => _ => ProcessOutput(k, Output), + if localId == 0 then Set(42) else Set.empty, + (), + ) + (0 until 10).foreach(_ => env.cycleInOrder()) + forAll(env.status.values.toSeq): result => + result should contain key 42 + + it should "carry the correct result value after bubble expansion" in: + val env = mooreGrid[Map[Int, Int], ExchangeAggregateContext[Int], IntNetworkManager]( + 3, + 3, + exchangeContextFactory, + inMemoryNetwork, + ): + sspawn[Int, Unit, Int]( + k => _ => ProcessOutput(k * 10, Output), + if localId == 0 then Set(7) else Set.empty, + (), + ) + (0 until 10).foreach(_ => env.cycleInOrder()) + forAll(env.status.values.toSeq): result => + result.get(7) shouldBe Some(70) + + // ── termination propagation ──────────────────────────────────────────────── + + it should "collapse the bubble after all devices signal Terminated" in: + var terminate = false + val env = mooreGrid[Map[Int, Int], ExchangeAggregateContext[Int], IntNetworkManager]( + 3, + 3, + exchangeContextFactory, + inMemoryNetwork, + ): + sspawn[Int, Unit, Int]( + k => _ => ProcessOutput(k, if terminate then Terminated else Output), + if localId == 0 then Set(42) else Set.empty, + (), + ) + // Phase 1: let the bubble fill the grid + (0 until 10).foreach(_ => env.cycleInOrder()) + forAll(env.status.values.toSeq): result => + result should contain key 42 + // Phase 2: trigger termination and let the signal propagate + terminate = true + (0 until 15).foreach(_ => env.cycleInOrder()) + forAll(env.status.values.toSeq): result => + result shouldBe Map.empty[Int, Int] + + // ── replicated ───────────────────────────────────────────────────────────── + + "replicated" should "maintain exactly `replicates` active replicas on a grid after convergence" in: + // period = 0.5 s, dt = 100 ms → cyclicTimerWithDecay fires every 6 rounds. + // After 3 periods (≈18 rounds) all devices hold replicas [lastPid-2, lastPid-1, lastPid]. + val env = mooreGrid[Map[Long, Int], ProcCtx, IntNetworkManager](3, 3, timerFactory, inMemoryNetwork): + replicated[Unit, Int](_ => 42, (), period = 0.5, replicates = 3) + (0 until 30).foreach(_ => env.cycleInOrder()) + forAll(env.status.values.toSeq): result => + result should have size 3 + result.values.foreach(_ shouldBe 42) + + it should "rotate replicas forward as the shared timer advances" in: + // period=0.5s, dt=100ms: due to Double FP, the cyclic timer fires approximately every 7 rounds. + // Run 25 rounds (≥3 timer ticks → lastPid≥3) to let the window stabilise, then run 8 more to + // guarantee at least one additional tick; check the global max pid across all devices advances. + val env = mooreGrid[Map[Long, Int], ProcCtx, IntNetworkManager](3, 3, timerFactory, inMemoryNetwork): + replicated[Unit, Int](_ => 0, (), period = 0.5, replicates = 2) + (0 until 25).foreach(_ => env.cycleInOrder()) + val maxPid1 = env.status.values.toSeq.flatMap(_.keySet).max + (0 until 8).foreach(_ => env.cycleInOrder()) + val maxPid2 = env.status.values.toSeq.flatMap(_.keySet).max + maxPid2 should be > maxPid1 + +end ProcessLibraryTests From 691f7263f9ed087195f874b26bdfc69ccdf73a57 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Thu, 25 Jun 2026 14:49:18 +0200 Subject: [PATCH 10/12] refactor(libraries): rename ProcessLibrary sspawn to spawn and improve API ergonomics - sspawn -> spawn, with the process as a trailing lambda so call sites read like a control structure: spawn(generation, args) { key => a => ... } - add ProcessOutput smart constructors (output/bubble/external/terminated) - replicated reshaped to replicated(period, replicates, argument)(proc) and now evaluates proc unconditionally so replicas stay aligned - keep the K key and A args as distinct process inputs Co-Authored-By: Claude Opus 4.8 --- .../common/ConditionalExportContext.scala | 5 +- .../common/ConditionalExportLanguage.scala | 5 +- .../scafi/libraries/ProcessLibrary.scala | 72 +++++++++++-------- .../scafi/libraries/ProcessLibraryTests.scala | 50 +++++-------- 4 files changed, 66 insertions(+), 66 deletions(-) diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala b/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala index e26e97fd..7c9be79f 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/context/common/ConditionalExportContext.scala @@ -3,10 +3,11 @@ package it.unibo.scafi.context.common import it.unibo.scafi.language.common.ConditionalExportLanguage import it.unibo.scafi.message.OutboundMessage -/** Concrete implementation of [[ConditionalExportLanguage]] that delegates to [[OutboundMessage.withRollbackUnless]]. */ +/** + * Concrete implementation of [[ConditionalExportLanguage]] that delegates to [[OutboundMessage.withRollbackUnless]]. + */ trait ConditionalExportContext extends ConditionalExportLanguage: self: OutboundMessage => override def conditionallyExport[T](keep: T => Boolean)(body: () => T): T = withRollbackUnless(keep)(body()) -end ConditionalExportContext diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala b/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala index c0f6d2d6..3f8bb53a 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/language/common/ConditionalExportLanguage.scala @@ -6,15 +6,14 @@ package it.unibo.scafi.language.common * for the discarded scope. * * This is the scafi3 equivalent of the legacy `exportConditionally` / `vm.newExportStack` + `mergeExport` / - * `discardExport` mechanism, and is the primitive required for correct `sspawn` semantics (devices outside a process + * `discardExport` mechanism, and is the primitive required for correct `spawn` semantics (devices outside a process * bubble must not pollute alignment inside the bubble). */ trait ConditionalExportLanguage: /** * Runs `body` and, if `keep(result)` is `false`, rolls back every exchange write produced by `body` so that - * neighbours do not see this device's state for the body's alignment scope. The result is always returned - * regardless. + * neighbours do not see this device's state for the body's alignment scope. The result is always returned regardless. * * @param keep * predicate on the body's result; `false` ⇒ discard this round's export diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala index 96a81d7c..b50680e6 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/ProcessLibrary.scala @@ -16,7 +16,7 @@ import TimeLibrary.sharedTimerWithDecay * bubble shrinks and eventually vanishes. * * The two entry points are: - * - [[sspawn]] — general-purpose spawn: manages a dynamic set of keyed processes. + * - [[spawn]] — general-purpose spawn: manages a dynamic set of keyed processes. * - [[replicated]] — time-replicated spawn: runs a fixed number of overlapping instances of a process, rotating on a * wall-clock period. * @@ -47,6 +47,21 @@ object ProcessLibrary: /** Value returned by a process function: the current result together with the lifecycle status. */ final case class ProcessOutput[+R](result: R, status: ProcessStatus) + /** Smart constructors for [[ProcessOutput]], one per [[ProcessStatus]] case, for readable call sites. */ + object ProcessOutput: + /** This device is inside the bubble and produces `result` as the process output. */ + def output[R](result: R): ProcessOutput[R] = ProcessOutput(result, ProcessStatus.Output) + + /** This device is inside the bubble but does not contribute to the output (carries `result` for chaining). */ + def bubble[R](result: R): ProcessOutput[R] = ProcessOutput(result, ProcessStatus.Bubble) + + /** This device is outside the bubble; `result` is ignored by [[spawn]]. */ + def external[R](result: R): ProcessOutput[R] = ProcessOutput(result, ProcessStatus.External) + + /** This device requests bubble shutdown; the termination signal propagates from here. */ + def terminated[R](result: R): ProcessOutput[R] = ProcessOutput(result, ProcessStatus.Terminated) + end ProcessOutput + /** * Dynamic, keyed spawn (the **S** block). Maintains a set of process instances, one per key, organised as spatial * bubbles. A process instance is born when a key appears in `generation` and dies when the process function returns @@ -55,12 +70,15 @@ object ProcessLibrary: * Keys spread through the network via the outer `share`; `conditionallyExport` gates participation so that devices * with status [[ProcessStatus.External]] are invisible inside the bubble. * - * @param process - * function from key → argument → [[ProcessOutput]]; called once per active key per round + * The `process` is the trailing parameter so the call site reads like a control structure: + * {{{spawn(generation = Set(k), args = x) { key => a => ProcessOutput.output(...) }}}} + * * @param generation * the set of keys born at this device this round (typically a singleton or empty) * @param args * argument forwarded to every process invocation at this device + * @param process + * function from key → argument → [[ProcessOutput]]; called once per active key per round * @tparam K * key type; `K.toString` must be unique across concurrent keys * @tparam A @@ -70,12 +88,12 @@ object ProcessLibrary: * @return * map from key to result for all keys currently in the [[ProcessStatus.Output]] state at this device */ - def sspawn[K, A, R](using + def spawn[K, A, R](using language: AggregateFoundation & FieldCalculusSyntax & ConditionalExportLanguage, )(using Codable[Map[K, R], Map[K, R]], Codable[(Boolean, Boolean), (Boolean, Boolean)], - )(process: K => A => ProcessOutput[R], generation: Set[K], args: A): Map[K, R] = + )(generation: Set[K], args: A)(process: K => A => ProcessOutput[R]): Map[K, R] = share[Map[K, R], Map[K, R]](Map.empty) { prevResults => // Collect all keys seen by any neighbour plus locally generated ones. val activeKeys: Set[K] = prevResults.withoutSelf.foldLeft(generation)((ks, nbrMap) => ks ++ nbrMap.keySet) @@ -83,11 +101,10 @@ object ProcessLibrary: // that share the same active-key set gives reproducible invocation-count paths. activeKeys.toList.sortBy(_.toString).foldLeft(Map.empty[K, R]) { (acc, k) => language.align(k.toString) { () => - val out: ProcessOutput[R] = language.conditionallyExport( - (o: ProcessOutput[R]) => o.status != ProcessStatus.External, - ) { () => - handleTermination(process(k)(args)) - } + val out: ProcessOutput[R] = + language.conditionallyExport((o: ProcessOutput[R]) => o.status != ProcessStatus.External) { () => + handleTermination(process(k)(args)) + } out match case ProcessOutput(r, ProcessStatus.Output) => acc + (k -> r) case _ => acc @@ -100,14 +117,14 @@ object ProcessLibrary: * (`Long`). A new instance is born every `period` seconds and the oldest one expires when the window of `replicates` * slots is exceeded. * - * @param proc - * the computation to replicate; receives the current `argument` - * @param argument - * argument forwarded to every replica * @param period * rotation period in seconds * @param replicates * number of simultaneously active replicas + * @param argument + * argument forwarded to every replica + * @param proc + * the computation to replicate; receives the current `argument` * @tparam T * argument type * @tparam R @@ -121,27 +138,22 @@ object ProcessLibrary: Codable[Map[Long, R], Map[Long, R]], Codable[(Boolean, Boolean), (Boolean, Boolean)], Codable[Double, Double], - )(proc: T => R, argument: T, period: Double, replicates: Int): Map[Long, R] = + )(period: Double, replicates: Int, argument: T)(proc: T => R): Map[Long, R] = val dt = language.deltaTime.toNanos.toDouble / 1e9 val lastPid = sharedTimerWithDecay[Double, Double](period, dt).toLong - sspawn[Long, T, R]( - (pid: Long) => - _ => - ProcessOutput( - proc(argument), - if pid > lastPid - replicates then ProcessStatus.Output else ProcessStatus.External, - ), - Set(lastPid), - argument, - ) + spawn[Long, T, R](Set(lastPid), argument) { (pid: Long) => arg => + // `proc` must run unconditionally (it may contain aggregate operations): only the status differs per replica. + val result = proc(arg) + if pid > lastPid - replicates then ProcessOutput.output(result) else ProcessOutput.external(result) + } /** - * Wraps `out` with gossip-based termination logic. Once any bubble device returns - * [[ProcessStatus.Terminated]], that signal propagates; when all in-bubble neighbours have received it the device - * exits the bubble ([[ProcessStatus.External]]). + * Wraps `out` with gossip-based termination logic. Once any bubble device returns [[ProcessStatus.Terminated]], that + * signal propagates; when all in-bubble neighbours have received it the device exits the bubble + * ([[ProcessStatus.External]]). * - * Uses `share[(Boolean, Boolean)]` where `_._1` = "I or any neighbour wants to terminate" and `_._2` = "all - * in-bubble neighbours are also terminating". The shared value is `(mustTerminate, mustExit)`. + * Uses `share[(Boolean, Boolean)]` where `_._1` = "I or any neighbour wants to terminate" and `_._2` = "all in-bubble + * neighbours are also terminating". The shared value is `(mustTerminate, mustExit)`. */ private def handleTermination[R](using language: AggregateFoundation & FieldCalculusSyntax, diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala index 0bd40f45..7b885124 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/ProcessLibraryTests.scala @@ -6,7 +6,7 @@ import it.unibo.scafi.UnitTest import it.unibo.scafi.context.xc.ExchangeAggregateContext import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory import it.unibo.scafi.libraries.All.localId -import it.unibo.scafi.libraries.ProcessLibrary.{replicated, sspawn, ProcessOutput, ProcessStatus} +import it.unibo.scafi.libraries.ProcessLibrary.{ replicated, spawn, ProcessOutput } import it.unibo.scafi.libraries.ProcessLibrary.ProcessStatus.{ Bubble, External, Output, Terminated } import it.unibo.scafi.message.{ Codable, Codables, ValueTree } import it.unibo.scafi.runtime.ScafiEngine @@ -53,53 +53,47 @@ class ProcessLibraryTests extends UnitTest, Inspectors: val out: ProcessOutput[Any] = ProcessOutput(42, Bubble) out.status shouldBe Bubble - // ── sspawn single-device tests ───────────────────────────────────────────── + // ── spawn single-device tests ────────────────────────────────────────────── - "sspawn" should "return empty map when no keys are generated" in: + "spawn" should "return empty map when no keys are generated" in: val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): - sspawn[String, Unit, Int](_ => _ => ProcessOutput(0, Output), Set.empty, ()) + spawn[String, Unit, Int](Set.empty, ())(_ => _ => ProcessOutput.output(0)) (0 until 5).map(_ => engine.cycle()).last shouldBe Map.empty[String, Int] it should "include Output keys in the result" in: val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): - sspawn[String, Unit, Int](_ => _ => ProcessOutput(42, Output), Set("k"), ()) + spawn[String, Unit, Int](Set("k"), ())(_ => _ => ProcessOutput.output(42)) engine.cycle() shouldBe Map("k" -> 42) it should "exclude External keys from the result" in: val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): - sspawn[String, Unit, Int]( - k => _ => if k == "on" then ProcessOutput(1, Output) else ProcessOutput(0, External), - Set("on", "off"), - (), - ) + spawn[String, Unit, Int](Set("on", "off"), ()): key => + _ => if key == "on" then ProcessOutput.output(1) else ProcessOutput.external(0) engine.cycle() shouldBe Map("on" -> 1) it should "exit immediately when process returns Terminated on an isolated node" in: // Vacuous ∀ on an empty neighbourhood ⇒ mustExit is true from round 0, so // Terminated + mustTerminate ∧ mustExit ⇒ External right away. val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): - sspawn[String, Unit, Int](_ => _ => ProcessOutput(0, Terminated), Set("k"), ()) + spawn[String, Unit, Int](Set("k"), ())(_ => _ => ProcessOutput.terminated(0)) engine.cycle() shouldBe Map.empty[String, Int] it should "pass arguments to the process function" in: val engine = ScafiEngine(NoNeighborsNetworkManager(localId = 0), exchangeContextFactory): - sspawn[String, Int, Int](_ => n => ProcessOutput(n * 2, Output), Set("k"), 21) + spawn[String, Int, Int](Set("k"), 21)(_ => n => ProcessOutput.output(n * 2)) engine.cycle() shouldBe Map("k" -> 42) - // ── sspawn bubble expansion ──────────────────────────────────────────────── + // ── spawn bubble expansion ───────────────────────────────────────────────── - "sspawn" should "propagate a key from the source to all grid devices" in: + "spawn" should "propagate a key from the source to all grid devices" in: val env = mooreGrid[Map[Int, Int], ExchangeAggregateContext[Int], IntNetworkManager]( 3, 3, exchangeContextFactory, inMemoryNetwork, ): - sspawn[Int, Unit, Int]( - k => _ => ProcessOutput(k, Output), - if localId == 0 then Set(42) else Set.empty, - (), - ) + spawn[Int, Unit, Int](if localId == 0 then Set(42) else Set.empty, ()): key => + _ => ProcessOutput.output(key) (0 until 10).foreach(_ => env.cycleInOrder()) forAll(env.status.values.toSeq): result => result should contain key 42 @@ -111,11 +105,8 @@ class ProcessLibraryTests extends UnitTest, Inspectors: exchangeContextFactory, inMemoryNetwork, ): - sspawn[Int, Unit, Int]( - k => _ => ProcessOutput(k * 10, Output), - if localId == 0 then Set(7) else Set.empty, - (), - ) + spawn[Int, Unit, Int](if localId == 0 then Set(7) else Set.empty, ()): key => + _ => ProcessOutput.output(key * 10) (0 until 10).foreach(_ => env.cycleInOrder()) forAll(env.status.values.toSeq): result => result.get(7) shouldBe Some(70) @@ -130,11 +121,8 @@ class ProcessLibraryTests extends UnitTest, Inspectors: exchangeContextFactory, inMemoryNetwork, ): - sspawn[Int, Unit, Int]( - k => _ => ProcessOutput(k, if terminate then Terminated else Output), - if localId == 0 then Set(42) else Set.empty, - (), - ) + spawn[Int, Unit, Int](if localId == 0 then Set(42) else Set.empty, ()): key => + _ => if terminate then ProcessOutput.terminated(key) else ProcessOutput.output(key) // Phase 1: let the bubble fill the grid (0 until 10).foreach(_ => env.cycleInOrder()) forAll(env.status.values.toSeq): result => @@ -151,7 +139,7 @@ class ProcessLibraryTests extends UnitTest, Inspectors: // period = 0.5 s, dt = 100 ms → cyclicTimerWithDecay fires every 6 rounds. // After 3 periods (≈18 rounds) all devices hold replicas [lastPid-2, lastPid-1, lastPid]. val env = mooreGrid[Map[Long, Int], ProcCtx, IntNetworkManager](3, 3, timerFactory, inMemoryNetwork): - replicated[Unit, Int](_ => 42, (), period = 0.5, replicates = 3) + replicated[Unit, Int](period = 0.5, replicates = 3, argument = ())(_ => 42) (0 until 30).foreach(_ => env.cycleInOrder()) forAll(env.status.values.toSeq): result => result should have size 3 @@ -162,7 +150,7 @@ class ProcessLibraryTests extends UnitTest, Inspectors: // Run 25 rounds (≥3 timer ticks → lastPid≥3) to let the window stabilise, then run 8 more to // guarantee at least one additional tick; check the global max pid across all devices advances. val env = mooreGrid[Map[Long, Int], ProcCtx, IntNetworkManager](3, 3, timerFactory, inMemoryNetwork): - replicated[Unit, Int](_ => 0, (), period = 0.5, replicates = 2) + replicated[Unit, Int](period = 0.5, replicates = 2, argument = ())(_ => 0) (0 until 25).foreach(_ => env.cycleInOrder()) val maxPid1 = env.status.values.toSeq.flatMap(_.keySet).max (0 until 8).foreach(_ => env.cycleInOrder()) From 6196193ce22b201881b17eeabe3bb331da2e9c67 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Thu, 25 Jun 2026 15:00:28 +0200 Subject: [PATCH 11/12] style: apply scalafmt/scalafix normalization to libraries and tests Mechanical only: import reordering, line rewrapping, end markers, and stray-whitespace cleanup. No behavioral changes. Co-Authored-By: Claude Opus 4.8 --- .../scafi/libraries/CollectionLibrary.scala | 8 ++- .../scafi/libraries/FieldUtilsLibrary.scala | 59 +++++++++---------- .../scafi/libraries/SparseChoiceLibrary.scala | 16 ++--- .../sensors/RandomGeneratorTests.scala | 1 + .../libraries/FieldUtilsLibraryTests.scala | 2 +- .../libraries/GradientLibraryTests.scala | 23 +++++--- .../libraries/SparseChoiceLibraryTests.scala | 5 +- .../scafi/libraries/TimeLibraryTests.scala | 4 +- 8 files changed, 66 insertions(+), 52 deletions(-) diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala index 8e1dbb82..3d302c01 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/CollectionLibrary.scala @@ -122,6 +122,7 @@ object CollectionLibrary: // Fold neighbours only (`withoutSelf`): a device is never its own parent, so self contributes `zero`; // we also avoid the full-field iterator, which reflects the live alignment scope rather than this field. accumulate(local, contributions.withoutSelf.foldLeft(zero)(accumulate)) + end collect /** * Counts the devices (optionally satisfying `predicate`) collected to the sink. @@ -237,7 +238,12 @@ object CollectionLibrary: potential: language.SharedData[P], local: Map[K, V], ): Map[K, V] = - collect[Map[K, V], P, Map[K, V]](potential, local, mergeMaps((_: K, accumulated: V, _: V) => accumulated), Map.empty) + collect[Map[K, V], P, Map[K, V]]( + potential, + local, + mergeMaps((_: K, accumulated: V, _: V) => accumulated), + Map.empty, + ) /** * Collects the union of all `local` sets at the sink. diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala index 285eface..d1af65ae 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/FieldUtilsLibrary.scala @@ -7,23 +7,13 @@ import cats.syntax.all.catsSyntaxTuple3Semigroupal /** * Field reduction utilities providing argmin/argmax selectors and map-merging over neighbour fields. * - * Several legacy `FieldUtils` operations are already expressible with the existing APIs: - * {{{ - * // sumHood(e) → neighborValues(e).fold(zero)(_ + _) - * // anyHood(e) → neighborValues(e).fold(false)(_ || _) - * // everyHood(e) → neighborValues(e).fold(true)(_ && _) - * // minHoodLoc(d)(e) → neighborValues(e).withoutSelf.fold(d)(_.min(_)) - * // includingSelf/excludingSelf → pass `field` vs `field.withoutSelf` - * // reifyField(e) → (device, neighborValues(e)).mapN(_ -> _).toList.toMap - * }}} - * * This object adds only the genuinely-missing primitives: argmin/argmax selectors and map merging. */ object FieldUtilsLibrary: /** - * Returns the `data` value associated with the neighbour minimising `key`, excluding the local device. Total: - * returns `default` for an empty neighbourhood. Ties broken by the smaller device identifier. + * Returns the `data` value associated with the neighbour minimising `key`, excluding the local device. Total: returns + * `default` for an empty neighbourhood. Ties broken by the smaller device identifier. * * To include the local device in the comparison, pass `key` and `data` with self already present and use the * `includingSelf` variant by calling this method on the unfiltered `SharedData`. @@ -40,23 +30,25 @@ object FieldUtilsLibrary: * the data type to return * @return * the data value of the neighbour with the minimum key, or `default` - * @see [[maxHoodSelector]] for the dual operation + * @see + * [[maxHoodSelector]] for the dual operation */ - def minHoodSelector[K: Ordering, V](using language: AggregateFoundation)(using Ordering[language.DeviceId])( + def minHoodSelector[K: Ordering, V](using + language: AggregateFoundation, + )(using + Ordering[language.DeviceId], + )( key: language.SharedData[K], data: language.SharedData[V], default: V, ): V = - (key, data, language.device) - .mapN { (k, v, id) => (k, id, v) } - .withoutSelf - .minByOption { (k, id, _) => (k, id) } + (key, data, language.device).mapN { (k, v, id) => (k, id, v) }.withoutSelf.minByOption { (k, id, _) => (k, id) } .map(_._3) .getOrElse(default) /** - * Returns the `data` value associated with the neighbour maximising `key`, excluding the local device. Total: - * returns `default` for an empty neighbourhood. Ties broken by the larger device identifier. + * Returns the `data` value associated with the neighbour maximising `key`, excluding the local device. Total: returns + * `default` for an empty neighbourhood. Ties broken by the larger device identifier. * * @param key * the shared field of keys used to rank neighbours @@ -70,23 +62,25 @@ object FieldUtilsLibrary: * the data type to return * @return * the data value of the neighbour with the maximum key, or `default` - * @see [[minHoodSelector]] for the dual operation + * @see + * [[minHoodSelector]] for the dual operation */ - def maxHoodSelector[K: Ordering, V](using language: AggregateFoundation)(using Ordering[language.DeviceId])( + def maxHoodSelector[K: Ordering, V](using + language: AggregateFoundation, + )(using + Ordering[language.DeviceId], + )( key: language.SharedData[K], data: language.SharedData[V], default: V, ): V = - (key, data, language.device) - .mapN { (k, v, id) => (k, id, v) } - .withoutSelf - .maxByOption { (k, id, _) => (k, id) } + (key, data, language.device).mapN { (k, v, id) => (k, id, v) }.withoutSelf.maxByOption { (k, id, _) => (k, id) } .map(_._3) .getOrElse(default) /** - * Key-wise merge of map-valued neighbour contributions. On key collision the `overwrite` policy decides the - * surviving value: `overwrite(existing, incoming)`. + * Key-wise merge of map-valued neighbour contributions. On key collision the `overwrite` policy decides the surviving + * value: `overwrite(existing, incoming)`. * * @param maps * the shared field of maps to merge (may include self) @@ -98,15 +92,18 @@ object FieldUtilsLibrary: * the map value type * @return * a single [[Map]] with all keys from all neighbours, resolved via `overwrite` - * @see [[FoldingLibrary]] for simple `fold`/`foldWithoutSelf` patterns + * @see + * [[FoldingLibrary]] for simple `fold`/`foldWithoutSelf` patterns */ - def mergeHood[K, V](using language: AggregateFoundation)( + def mergeHood[K, V](using + language: AggregateFoundation, + )( maps: language.SharedData[Map[K, V]], )(overwrite: (V, V) => V): Map[K, V] = maps.foldLeft(Map.empty[K, V]) { (acc, m) => m.foldLeft(acc) { case (a, (k, v)) => a.updatedWith(k): - case None => Some(v) + case None => Some(v) case Some(existing) => Some(overwrite(existing, v)) } } diff --git a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala index 18f2a8b0..e3f09fad 100644 --- a/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala +++ b/scafi3-core/src/main/scala/it/unibo/scafi/libraries/SparseChoiceLibrary.scala @@ -19,15 +19,15 @@ import GradientLibrary.distanceTo * roughly `grain` apart — the basis for partitioning a network into regions, multi-leader coordination, and so on. * * Every device starts as a candidate leader carrying a unique id `uid = (randomSeed, deviceId)`. Devices then - * **compete**: a device abdicates in favour of a nearby device with a lower UID, where "nearby" is decided by a gradient - * distance measured against `grain`. The surviving leaders form a Poisson-disc-like sparse set with mean spacing - * `grain`. + * **compete**: a device abdicates in favour of a nearby device with a lower UID, where "nearby" is decided by a + * gradient distance measured against `grain`. The surviving leaders form a Poisson-disc-like sparse set with mean + * spacing `grain`. * * '''Design choices for the abstract [[AggregateFoundation.DeviceId]]''': * - UIDs are ordered '''lexicographically''': by the random seed first, then by device id as a deterministic * tie-break. This requires only an `Ordering[DeviceId]`. - * - [[minId]] gossips the minimum id over a connected component and therefore needs an - * [[UpperBounded]] `DeviceId` for the empty-neighbourhood case. + * - [[minId]] gossips the minimum id over a connected component and therefore needs an [[UpperBounded]] `DeviceId` + * for the empty-neighbourhood case. * * @see * [[GradientLibrary.distanceTo]] for the distance gradient driving the competition @@ -101,8 +101,9 @@ object SparseChoiceLibrary: * Symmetry-breaking competition driving [[sparseChoice]], exposed because it is a reusable building block: it elects * leaders given an arbitrary, externally-provided `uid` field rather than [[randomUid]]. * - * Each device shares the UID of the leader it currently follows. It measures the gradient distance to that leader and, - * via [[distanceCompetition]], either keeps following it, abdicates, or re-stands as a candidate for a new region. + * Each device shares the UID of the leader it currently follows. It measures the gradient distance to that leader + * and, via [[distanceCompetition]], either keeps following it, abdicates, or re-stands as a candidate for a new + * region. * * @param uid * this device's unique id (lower wins ties) @@ -185,6 +186,7 @@ object SparseChoiceLibrary: else neighbourLead .withoutSelf (nearbyLeaders.toList :+ leadField.onlySelf).min(using uidOrdering) + end distanceCompetition /** Lexicographic ordering on UIDs: by random seed first, then by device id as a deterministic tie-break. */ private def uidLexicographicOrdering[Id](using idOrdering: Ordering[Id]): Ordering[(Double, Id)] = diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala index 7cd4883b..3d0546f1 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/language/sensors/RandomGeneratorTests.scala @@ -30,3 +30,4 @@ class RandomGeneratorTests extends UnitTest: val a = RandomGenerator.nextRandom val b = RandomGenerator.nextRandom a should not equal b +end RandomGeneratorTests diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala index 3bd9cae7..3d2d90b6 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/FieldUtilsLibraryTests.scala @@ -8,8 +8,8 @@ import it.unibo.scafi.libraries.All.{ localId, neighborValues } import it.unibo.scafi.libraries.FieldUtilsLibrary.{ maxHoodSelector, mergeHood, minHoodSelector } import it.unibo.scafi.message.{ Codable, Codables } import it.unibo.scafi.test.environment.Grids.mooreGrid -import it.unibo.scafi.test.environment.Node.inMemoryNetwork import it.unibo.scafi.test.environment.IntNetworkManager +import it.unibo.scafi.test.environment.Node.inMemoryNetwork import org.scalatest.Inspectors diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala index 22589051..18374ec4 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/GradientLibraryTests.scala @@ -6,7 +6,16 @@ import it.unibo.scafi.UnitTest import it.unibo.scafi.context.xc.ExchangeAggregateContext import it.unibo.scafi.context.xc.ExchangeAggregateContext.exchangeContextFactory import it.unibo.scafi.libraries.All.{ localId, neighborValues } -import it.unibo.scafi.libraries.GradientLibrary.{bisGradient, crfGradient, distanceTo, flexGradient, hopGradient, DEFAULT_CRF_RAISING_SPEED, DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON, DEFAULT_FLEX_DELTA} +import it.unibo.scafi.libraries.GradientLibrary.{ + bisGradient, + crfGradient, + distanceTo, + flexGradient, + hopGradient, + DEFAULT_CRF_RAISING_SPEED, + DEFAULT_FLEX_CHANGE_TOLERANCE_EPSILON, + DEFAULT_FLEX_DELTA, +} import it.unibo.scafi.message.{ Codable, Codables, ValueTree } import it.unibo.scafi.runtime.ScafiEngine import it.unibo.scafi.sensors.{ LagSensor, TimeSensor } @@ -18,7 +27,7 @@ import it.unibo.scafi.utils.boundaries.CommonBoundaries.given import org.scalatest.Inspectors -class GradientLibraryTests extends UnitTest, Inspectors: +class GradientLibraryTests extends UnitTest, Inspectors: given [V]: Codable[V, V] = Codables.forInMemoryCommunications @@ -29,9 +38,7 @@ class GradientLibraryTests extends UnitTest, Inspectors: private val fixedLag: FiniteDuration = 10.millis private def timeLagFactory(net: IntNetworkManager, vt: ValueTree): TimeLagCtx = - new ExchangeAggregateContext[Int](net.localId, net.receive, vt) - with TimeSensor - with LagSensor[FiniteDuration]: + new ExchangeAggregateContext[Int](net.localId, net.receive, vt) with TimeSensor with LagSensor[FiniteDuration]: override def deltaTime: FiniteDuration = fixedDelta override def timestamp: Long = 0L override def senseLag: SharedData[FiniteDuration] = @@ -90,8 +97,7 @@ class GradientLibraryTests extends UnitTest, Inspectors: distanceTo[Double, Double](localId == 0, neighborValues[Double, Double](1.0)) (0 until 30).foreach(_ => envCrf.cycleInOrder()) (0 until 10).foreach(_ => envRef.cycleInOrder()) - for id <- 0 until 9 do - envCrf.status(id) shouldBe (envRef.status(id) +- 0.05) + for id <- 0 until 9 do envCrf.status(id) shouldBe (envRef.status(id) +- 0.05) it should "re-converge to new distances after source moves" in: var sourceId = 0 @@ -115,8 +121,7 @@ class GradientLibraryTests extends UnitTest, Inspectors: distanceTo[Double, Double](localId == 0, neighborValues[Double, Double](1.0)) (0 until 30).foreach(_ => envBis.cycleInOrder()) (0 until 10).foreach(_ => envRef.cycleInOrder()) - for id <- 0 until 9 do - envBis.status(id) shouldBe (envRef.status(id) +- 0.05) + for id <- 0 until 9 do envBis.status(id) shouldBe (envRef.status(id) +- 0.05) // ── flexGradient convergence parity and tolerance ─────────────────────────── diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala index 578d7eb3..aeef5d02 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/SparseChoiceLibraryTests.scala @@ -53,8 +53,9 @@ class SparseChoiceLibraryTests extends UnitTest, Inspectors: (0 until 5).map(_ => engine.cycle()).last shouldBe 0 it should "gossip the global minimum id over a connected grid" in: - val env = mooreGrid[Int, ExchangeAggregateContext[Int], IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): - minId[Int] + val env = + mooreGrid[Int, ExchangeAggregateContext[Int], IntNetworkManager](3, 3, exchangeContextFactory, inMemoryNetwork): + minId[Int] (0 until 10).foreach(_ => env.cycleInOrder()) forAll(env.status.toSeq): (_, minimumId) => minimumId shouldBe 0 diff --git a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala index efd81cdd..1a82c7a1 100644 --- a/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala +++ b/scafi3-core/src/test/scala/it/unibo/scafi/libraries/TimeLibraryTests.scala @@ -55,7 +55,9 @@ class TimeLibraryTests extends UnitTest: program: TimeSensorCtx ?=> A, ): Seq[A] = var idx = 0 - val factory = timeSensorFactory(() => { val d = deltaPerRound(idx); idx += 1; d }) + val factory = timeSensorFactory(() => + val d = deltaPerRound(idx); idx += 1; d, + ) val engine = ScafiEngine(network, factory)(program) (0 until rounds).map(_ => engine.cycle()) From f9f1fe382d727c9a289850402bfb809714930c96 Mon Sep 17 00:00:00 2001 From: Gianluca Aguzzi Date: Thu, 25 Jun 2026 15:00:39 +0200 Subject: [PATCH 12/12] chore(example): rescale gradient example to unit-scale coordinates Shrink ConnectWithinDistance radius (5 -> 0.2) and grid/point spacing to 0.1 so the example deploys in a compact coordinate range. Co-Authored-By: Claude Opus 4.8 --- example/src/main/resources/it/unibo/scafi/gradient.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example/src/main/resources/it/unibo/scafi/gradient.yml b/example/src/main/resources/it/unibo/scafi/gradient.yml index 1d27f090..4f962441 100644 --- a/example/src/main/resources/it/unibo/scafi/gradient.yml +++ b/example/src/main/resources/it/unibo/scafi/gradient.yml @@ -2,7 +2,7 @@ incarnation: scafi3 network-model: type: ConnectWithinDistance - parameters: [ 5 ] + parameters: [ 0.2 ] _pool: &program - time-distribution: 1 @@ -20,14 +20,14 @@ launcher: deployments: - type: Point - parameters: [ -1, -1 ] + parameters: [ 0.1, -0.1 ] programs: - *program contents: - molecule: source concentration: true - type: Grid - parameters: [ 0, 0, 3, 3, 1, 1 ] + parameters: [ 0, 0, 3, 3, 0.1, 0.1 ] programs: - *program contents: