Skip to content

feat!: replace dependency graph with reactive invalidation - #5641

Open
DanySK wants to merge 214 commits into
masterfrom
marmellata
Open

DanySK wants to merge 214 commits into
masterfrom
marmellata

Conversation

@DanySK

@DanySK DanySK commented Oct 8, 2026

Copy link
Copy Markdown
Member

This PR supersedes #5131. It refines and propagates the excellent base layed out by @S-furi. Alchemist becomes a fully reactive DES.
The API changes are widespread.

Comments are welcome, though I doubt anyone can write meaningful reviews in less than a month for this changeset.
It is fundamentally a new simulator.

S-furi and others added 30 commits April 30, 2026 01:56
# Conflicts:
#	.github/workflows/build-and-deploy.yml
#	.github/workflows/update-ancillary-files.yml
#	CHANGELOG.md
#	dokka-cache/ch.qos.logback/logback-classic/1.5.21.list
#	dokka-cache/ch.qos.logback/logback-classic/1.5.22.list
#	dokka-cache/ch.qos.logback/logback-classic/1.5.26.list
#	dokka-cache/de.flapdoodle.embed/de.flapdoodle.embed.mongo/4.21.0.list
#	dokka-cache/de.flapdoodle.embed/de.flapdoodle.embed.mongo/4.22.0.list
#	dokka-cache/de.flapdoodle.embed/de.flapdoodle.embed.mongo/4.24.0.list
#	dokka-cache/it.unibo.alchemist/alchemist-api/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-api/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-api/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-cognitive-agents/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-cognitive-agents/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-cognitive-agents/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-composeui/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-composeui/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-composeui/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-engine/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-engine/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-engine/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-euclidean-geometry/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-euclidean-geometry/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-euclidean-geometry/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-full/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-full/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-full/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-graphql-surrogates/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-graphql-surrogates/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-graphql-surrogates/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-graphql/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-graphql/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-graphql/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-implementationbase/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-implementationbase/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-implementationbase/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-biochemistry/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-biochemistry/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-biochemistry/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-protelis/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-protelis/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-protelis/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-sapere/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-sapere/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-incarnation-sapere/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-loading/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-loading/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-loading/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-maintenance-tooling/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-maintenance-tooling/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-maintenance-tooling/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-maps/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-maps/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-maps/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-physics/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-physics/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-physics/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-sapere-mathexp/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-sapere-mathexp/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-sapere-mathexp/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-smartcam/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-smartcam/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-smartcam/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-swingui/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-swingui/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-swingui/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-test/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-test/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-test/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-ui-tooling/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-ui-tooling/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-ui-tooling/42.3.32.list
#	dokka-cache/it.unibo.alchemist/alchemist-web-renderer/42.3.18.list
#	dokka-cache/it.unibo.alchemist/alchemist-web-renderer/42.3.22.list
#	dokka-cache/it.unibo.alchemist/alchemist-web-renderer/42.3.32.list
#	gradle/libs.versions.toml
#	package-lock.json
#	package.json
#	settings.gradle.kts

# Conflicts:
#	alchemist-engine/src/main/kotlin/it/unibo/alchemist/core/Engine.kt

# Conflicts:
#	alchemist-api/build.gradle.kts
#	alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt
#	alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/loader/LoadingSystem.kt
…l callback

Now the "laziness" concept is applied to dervied observables too.
For actions in general, it is better to use an imperative approach and retrieve the current neighborhood instead of having an up-to-date neighborhood version stored locally.
…ency graph

fix: remove obsolete reactive engine loading
chore(engine): use explicit synchronisation instead of thread contexts
This idea is borrowed by Androidx, where by means of Lifecycle state machines we are able to bound the lifecycle of the dependencies to the lifecycle of the owner (i.e. the registrant), properly disposing and releasing observers references withtout using weak references, hence not impacting too much negatively performance.
commit cbc430612a9a9063264da4f229fec1f7dc270ecd
Author: S-furi <stefano.furi7@gmail.com>
Date:   Fri Feb 20 14:02:00 2026 +0100

    perf(api): make use of persistent collections in observable collections

    this led to a great improvement in memory usage especially for neighbor-related streams where set of nodes where occupying large chunks of memory.

    This usage of persistent structures improved equality checks speed with an overall time reduction of approx a 10% wrt standard kotlin collections usage (from 22s independent test (SAPERE) to 20s)

commit ef9301aed3555d7d97c262a755085ace39bc9c24
Author: S-furi <stefano.furi7@gmail.com>
Date:   Fri Feb 20 11:53:17 2026 +0100

    perf(engine): introduce `BatchManager` to avoid redundant reschedule requests

commit 6c04f40fcea4ce9f644cf2b9e162b999199d524e
Author: S-furi <stefano.furi7@gmail.com>
Date:   Fri Feb 20 11:18:14 2026 +0100

    perf(sapere): introduce finer-grained observables for lsa spaces
DanySK added 26 commits October 13, 2026 11:57
…ay affect, and skip moves to the same position
@DanySK

DanySK commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

@sonarqubecloud

Copy link
Copy Markdown

@DanySK DanySK left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second review round. Suggestions are included only where the fix is small and local.

Comment on lines +590 to +595
override fun stopWatching(registrant: Any) {
members.stopWatching(registrant)
if (!isActive) {
activeRangeQueries -= this
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A resubscribed RangeQuery sends stale members after its center is removed.

When the last observer leaves, members keeps its contents. Example: observe observeNodesWithinRange(a, r) while b is in range, unsubscribe, then removeNode(a). Because a is removed, the next onChange skips clearAndAddAll, and members.onChange(invokeOnRegistration = true) delivers {b} to the new subscriber, although current returns the empty set.

Dropping the members when the query goes inactive fixes it. Nobody is observing at that point, so the clear notifies no one:

Suggested change
override fun stopWatching(registrant: Any) {
members.stopWatching(registrant)
if (!isActive) {
activeRangeQueries -= this
}
}
override fun stopWatching(registrant: Any) {
members.stopWatching(registrant)
if (!isActive) {
activeRangeQueries -= this
// Unobserved members would go stale: a later subscriber recomputes them, or gets none if the center left.
members.clearAndAddAll(emptySet())
}
}

* new position.
*/
if (pos != p) {
require(pos == null || spatialIndex.move(n, pos.coordinates, p.coordinates)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setPosition accepts a node that is not in the environment.

For such a node pos == null, so the require passes. The node's id is then written to nodesToPositions, but the node is never added to the spatial index or to nodeList. Since contains() now checks nodesToPositions, moveNodeTo(foreignNode, p) makes the node count as present. refreshNeighborhoodsAround then links it into other nodes' neighborhoods, getNodesWithinRange(node, r) later fails its check, and removeNode corrupts the index.

addNode no longer goes through setPosition, and moveNodeTo is its only caller, so a missing position can simply be rejected:

Suggested change
require(pos == null || spatialIndex.move(n, pos.coordinates, p.coordinates)) {
require(pos != null && spatialIndex.move(n, pos.coordinates, p.coordinates)) {

}
return res;
return observeCandidates(lsaTemplate).map(candidates -> new Some<>(matchesOf(lsaTemplate, candidates)));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

observeConcentration (and therefore node[t]) is always Some, even when nothing matches.

The Node docs say the result is Some if the value is present and None otherwise. Here node[t] is Some(emptyList()) while node.contains(t) is false, so generic code that treats get(m).isSome() as presence will think the LSA is there.

On master getConcentration returned an empty list in this case, so SAPERE callers may rely on getting a list. Either return None for no matches (and update the SAPERE call sites to use getOrElse { emptyList() }), or document on LsaNode/ILsaNode that LSA nodes always answer Some.

Comment on lines +69 to +75
moleculeCount.update(c -> c + 1);
allInstances.add(inst);
updateCount(inst, list);
final var obs = observablesByName.get(name);
if (obs != null) {
obs.update(it -> new ArrayList<>(list));
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moleculeCount is updated before the rest of the node.

Its observers are notified while allInstances, contents and the per-name observable still hold the old state. A count observer that reads getLsaSpace() or matching(t) sees the new count with the old LSAs. The same happens in removeConcentration and removeOneMatchedLsa.

Updating the count last, and reusing bucketChanged, which already does the other two updates:

Suggested change
moleculeCount.update(c -> c + 1);
allInstances.add(inst);
updateCount(inst, list);
final var obs = observablesByName.get(name);
if (obs != null) {
obs.update(it -> new ArrayList<>(list));
}
allInstances.add(inst);
bucketChanged(inst, name, list);
moleculeCount.update(c -> c + 1);

Comment on lines +159 to +161
moleculeCount.update(c -> c - removed);
allInstances.removeAll(lsa::equals);
bucketChanged(lsa, name, bucket);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as in addLsa: update the count last.

Suggested change
moleculeCount.update(c -> c - removed);
allInstances.removeAll(lsa::equals);
bucketChanged(lsa, name, bucket);
allInstances.removeAll(lsa::equals);
bucketChanged(lsa, name, bucket);
moleculeCount.update(c -> c - removed);

ifEngineAvailable(sim -> sim.nodeMoved(node));
public void moveNodeTo(@Nonnull final Node<T> node, @Nonnull final P position) {
includeObject(position);
setPosition(node, position);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a move, range-query observers are notified before the neighborhoods are refreshed.

setPosition calls updateRangeQueries, and refreshNeighborhoodsAround only runs afterwards. That is the opposite of addNode, which links first so that range observers "find it indexed, positioned, listed, and linked". An observer of observeNodesWithinRange(c, r) that reads getNeighborhood(n) or getNeighborhood(c) when n enters the range gets the neighborhood from before the move.

There is no small local fix: the range-query update has to come out of setPosition (or be deferred) and run after refreshNeighborhoodsAround, as in addNode.

Comment on lines +59 to +79
fun clearAndPutAll(from: Map<K, V>) {
var changed = false
val keysToRemove = backingMap.keys - from.keys
if (keysToRemove.isNotEmpty()) {
backingMap = backingMap.mutate { it -= keysToRemove }
keysToRemove.forEach { key -> notifyKeyObservers(key, none()) }
changed = true
}
if (from.isNotEmpty()) {
from.forEach { (key, value) ->
if (backingMap.valueWouldBeNewOrChanged(key, value)) {
backingMap = backingMap.put(key, value)
notifyKeyObservers(key, value.some())
changed = true
}
}
}
if (changed) {
notifyMapObservers()
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-key observers see an intermediate map.

Removed keys are notified before the new keys are inserted. For example, a RangeQuery going from {a} to {b} notifies containsItem(a) while the map is empty, so a callback that reads current or containsItem(b).current sees neither the old nor the new contents.

Applying the whole change first, then notifying (with the map-level guard from the comment on put):

Suggested change
fun clearAndPutAll(from: Map<K, V>) {
var changed = false
val keysToRemove = backingMap.keys - from.keys
if (keysToRemove.isNotEmpty()) {
backingMap = backingMap.mutate { it -= keysToRemove }
keysToRemove.forEach { key -> notifyKeyObservers(key, none()) }
changed = true
}
if (from.isNotEmpty()) {
from.forEach { (key, value) ->
if (backingMap.valueWouldBeNewOrChanged(key, value)) {
backingMap = backingMap.put(key, value)
notifyKeyObservers(key, value.some())
changed = true
}
}
}
if (changed) {
notifyMapObservers()
}
}
fun clearAndPutAll(from: Map<K, V>) {
val keysToRemove = backingMap.keys - from.keys
val changedEntries = from.filter { (key, value) -> backingMap.valueWouldBeNewOrChanged(key, value) }
if (keysToRemove.isNotEmpty() || changedEntries.isNotEmpty()) {
// The whole replacement is applied first, so that per-key observers never see an intermediate map.
val updated = backingMap.mutate {
it -= keysToRemove
it += changedEntries
}
backingMap = updated
keysToRemove.forEach { key -> notifyKeyObservers(key, none()) }
changedEntries.forEach { (key, value) -> notifyKeyObservers(key, value.some()) }
if (backingMap === updated) {
notifyMapObservers()
}
}
}

Comment on lines +101 to +107
fun put(key: K, value: V) {
if (backingMap.valueWouldBeNewOrChanged(key, value)) {
backingMap = backingMap.put(key, value)
notifyKeyObservers(key, value.some())
notifyMapObservers()
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Map observers can receive the same map twice.

Per-key observers are notified before map observers. If a key callback changes the map (e.g. put("j", v) when "k" changes), the nested change already notifies the map observers with the latest map. The outer notifyMapObservers() then reads backingMap again and sends that same map a second time. Observers that don't skip duplicates (ObservableMutableSet callbacks, emitOnDistinct = false derivations) do the work twice.

Skipping the outer notification when a nested change already happened:

Suggested change
fun put(key: K, value: V) {
if (backingMap.valueWouldBeNewOrChanged(key, value)) {
backingMap = backingMap.put(key, value)
notifyKeyObservers(key, value.some())
notifyMapObservers()
}
}
fun put(key: K, value: V) {
if (backingMap.valueWouldBeNewOrChanged(key, value)) {
val updated = backingMap.put(key, value)
backingMap = updated
notifyKeyObservers(key, value.some())
// A key observer that changed the map again has already notified the map observers of the newer map.
if (backingMap === updated) {
notifyMapObservers()
}
}
}

Comment on lines +119 to +122
backingMap = backingMap.remove(key)
notifyKeyObservers(key, none())
notifyMapObservers()
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as put:

Suggested change
backingMap = backingMap.remove(key)
notifyKeyObservers(key, none())
notifyMapObservers()
}
val updated = backingMap.remove(key)
backingMap = updated
notifyKeyObservers(key, none())
if (backingMap === updated) {
notifyMapObservers()
}
}

Comment on lines +59 to +70
if (wasEmpty) {
isListening = true
if (invokeOnRegistration) {
val initial = computeFresh()
cached = initial.some()
// Monitor before notifying, so that source changes made by the callback itself are not missed.
startMonitoring(true)
callback(initial)
} else {
startMonitoring(true)
}
} else if (invokeOnRegistration) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first observer registered with invokeOnRegistration = false breaks emitOnDistinct.

cached stays none(), so in updateAndNotify the first source emission always counts as a change, even when the derived value is equal. In the engine this means e.g. a condition merge that stays true still triggers reactionInvalidated and an extra rescheduling.

Caching the initial value in both cases fixes it. The cost is one eager computeFresh() on a lazy first subscription:

Suggested change
if (wasEmpty) {
isListening = true
if (invokeOnRegistration) {
val initial = computeFresh()
cached = initial.some()
// Monitor before notifying, so that source changes made by the callback itself are not missed.
startMonitoring(true)
callback(initial)
} else {
startMonitoring(true)
}
} else if (invokeOnRegistration) {
if (wasEmpty) {
isListening = true
// Cached even if not delivered, so that the first source emission is compared against it.
val initial = computeFresh()
cached = initial.some()
// Monitor before notifying, so that source changes made by the callback itself are not missed.
startMonitoring(true)
if (invokeOnRegistration) {
callback(initial)
}
} else if (invokeOnRegistration) {

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fa989b1a0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +226 to +227
getPosition(node).switchMap { position ->
getLayer(molecule)?.observeValue(position)?.map { it } ?: observe(null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-resolve layers added after subscription

When this observable is subscribed before addLayer associates the requested molecule, switchMap selects the constant observe(null) source and caches null. Adding the layer emits nothing through the position observable, so the subscription and even current remain stale until the node happens to move. Observe layer association changes as another source, or otherwise invalidate existing layer-value observables from addLayer.

Useful? React with 👍 / 👎.


override val observingCallbacks: MutableMap<Any, List<(Set<T>) -> Unit>> = mutableMapOf()

override val size: Observable<Int> = backing.map { it.size }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispose the union's size observable

If a caller subscribes through union.size and then calls union.dispose(), the default Observable.dispose() only removes registrants tracked by the union itself. This derived size observable subscribes directly to backing, so it remains connected to both source sets and continues invoking the supposedly disposed callback on later mutations. Override disposal to release size and the backing observable, as the mutable set implementation does.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants