Repository navigation
Conversation
# 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
…ring callback invocation
…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.
…es instead of entire LSASpace
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
…nt of EnvironmentWithDynamics
…ween firings rather than by the rate
… with an explicit error
…ling of a reaction
…removing null concentrations
…drop the made-up empty concentration
…watching the whole map
…ay affect, and skip moves to the same position
…ce when replacing its contents
|
@codex review |
|
DanySK
left a comment
There was a problem hiding this comment.
Second review round. Suggestions are included only where the fix is small and local.
| override fun stopWatching(registrant: Any) { | ||
| members.stopWatching(registrant) | ||
| if (!isActive) { | ||
| activeRangeQueries -= this | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
| 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)) { |
There was a problem hiding this comment.
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:
| 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))); |
There was a problem hiding this comment.
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.
| 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)); | ||
| } |
There was a problem hiding this comment.
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:
| 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); |
| moleculeCount.update(c -> c - removed); | ||
| allInstances.removeAll(lsa::equals); | ||
| bucketChanged(lsa, name, bucket); |
There was a problem hiding this comment.
Same as in addLsa: update the count last.
| 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); |
There was a problem hiding this comment.
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.
| 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() | ||
| } | ||
| } |
There was a problem hiding this comment.
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):
| 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() | |
| } | |
| } | |
| } |
| fun put(key: K, value: V) { | ||
| if (backingMap.valueWouldBeNewOrChanged(key, value)) { | ||
| backingMap = backingMap.put(key, value) | ||
| notifyKeyObservers(key, value.some()) | ||
| notifyMapObservers() | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
| 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() | |
| } | |
| } | |
| } |
| backingMap = backingMap.remove(key) | ||
| notifyKeyObservers(key, none()) | ||
| notifyMapObservers() | ||
| } |
There was a problem hiding this comment.
Same as put:
| backingMap = backingMap.remove(key) | |
| notifyKeyObservers(key, none()) | |
| notifyMapObservers() | |
| } | |
| val updated = backingMap.remove(key) | |
| backingMap = updated | |
| notifyKeyObservers(key, none()) | |
| if (backingMap === updated) { | |
| notifyMapObservers() | |
| } | |
| } |
| 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) { |
There was a problem hiding this comment.
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:
| 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) { |
There was a problem hiding this comment.
💡 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".
| getPosition(node).switchMap { position -> | ||
| getLayer(molecule)?.observeValue(position)?.map { it } ?: observe(null) |
There was a problem hiding this comment.
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 } |
There was a problem hiding this comment.
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 👍 / 👎.



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.