diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index 30ee2b1..4106b51 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -12,3 +12,4 @@ them. See [README.md](README.md) for the layout and write protocol. - [Singular package names](singular-package-names.md) — packages are singular (`io.spine.elastic.benchmark`) unless a plural is explicitly approved; modules/directories are unaffected. - [`when (this)` in enum initializers](enum-when-in-initializers.md) — init-order trap (`WhenMappings` references entries that don't exist yet); use computed `get()` properties for exhaustive per-entry dispatch. - [Kotest Double NaN](kotest-double-nan.md) — `shouldBe` compares primitive doubles by IEEE (`NaN != NaN`); filter NaN out of equality-based property-test value generators. +- [Codegen KDoc article check](codegen-template-article-check.md) — the generator's `badArticles` guard rejects "a [%CLASS%]" / "a `%K%`" (renders to `a [Int`); write "of [%CLASS%]" instead. diff --git a/.agents/memory/codegen-template-article-check.md b/.agents/memory/codegen-template-article-check.md new file mode 100644 index 0000000..2e49530 --- /dev/null +++ b/.agents/memory/codegen-template-article-check.md @@ -0,0 +1,19 @@ +# Codegen KDoc: never put `a`/`an` before a bracketed `%CLASS%` or `%K%`/`%V%` + +The `:codegen` `TemplateEngine` runs a blunt `badArticles` check after each +substitution and rejects four literal substrings: `a [Int`, `an [Long`, and the +backtick forms of the same. Because `%CLASS%` renders to `IntIntMap` etc. and +`%K%`/`%V%` render to `Int`/`Long`, a KDoc phrase like "a [%CLASS%]" or "a `%K%`" +becomes "a [IntIntMap]" / "a `Int`" and aborts +`./gradlew :elastic:generateSwissMaps` with an "article mismatch" error naming the +template and the 1-based line. + +**Why:** the guard is a plain `contains`, not a word-boundary match, so it fires on +any `Int`/`Long`-prefixed identifier, not only a bare `Int`/`Long`. The existing +templates sidestep it by writing "of [%CLASS%]" or "each `%K%`", never "a [%CLASS%]". + +**How to apply:** when editing `PrimitiveMap.kt.tpl` / `SwissMap.kt.tpl` (and their +spec templates), reference a substituted type with no indefinite article in front — +"of [%CLASS%]", "yielded by [%CLASS%.forEach]", "each `%K%` key". The same +validation pass enforces ≤100-char lines and zero placeholder residue, so read a +failed generate message literally. (Found adding the non-boxing `forEach` matrix.) diff --git a/.agents/tasks/primitive-map-foreach.md b/.agents/tasks/primitive-map-foreach.md new file mode 100644 index 0000000..a594243 --- /dev/null +++ b/.agents/tasks/primitive-map-foreach.md @@ -0,0 +1,104 @@ +# Task: non-boxing `forEach` for the Swiss map matrix + +**Status:** implemented & verified (`:elastic:check` green) · **Opened:** 2026-07-04 · **Surfaced by:** Phase 6 benchmark work + +## Goal + +Give the primitive-value maps (`LongLongMap`, `LongIntMap`, `LongDoubleMap`, +`IntIntMap`, `IntLongMap`, `IntDoubleMap`) a non-boxing traversal API — the one +capability every competitor primitive-map library offers (fastutil `forEach`, +HPPC cursors, Eclipse `forEachKeyValue`, Agrona `longForEach`) and the reason the +comparative `iterate` benchmark op currently cannot include our maps. Add the +same to the boxed-value maps (`SwissLongMap`, `SwissIntMap`) where it composes +cleanly. + +## Design decision (settled) + +Callback shape: **a generated top-level `public fun interface` per cell**, +mirroring the house style of `LongHasher`/`IntHasher` (and the fastutil/Agrona +`…Consumer` convention). A plain `(K, V) -> Unit` was rejected: it erases to +`Function2`, whose `invoke` boxes each primitive — the exact cost this library +exists to eliminate. A public `inline fun` was rejected: it may not touch the +`private tables` or the `internal object Swar`, so it cannot be a published +entry point. `forEach` is therefore a **member** (not an extension), because it +needs that private/internal state. + +- Primitive cells: `%K%%V%Consumer` with `fun accept(key: %K%, value: %V%)` + (e.g. `LongLongConsumer`, `IntDoubleConsumer`) — both key and value unboxed. +- Boxed cells: `%K%ObjectConsumer` with `fun accept(key: %K%, value: V)` + (`LongObjectConsumer`, `IntObjectConsumer`) — primitive key unboxed; the + value is already an object. +- Each `Consumer` is **co-located in the same generated file as its map**, + following the `LongHasher.kt` precedent (an interface + a related free + declaration share one file; the class matching the filename keeps + `MatchingDeclarationName` satisfied). + +Iteration-safety contract: documented as "must not be structurally modified +during the walk" (undefined behavior otherwise), matching `java.util.Map.forEach` +and the maps' existing "single-threaded, concurrent mutation undefined" stance. +No fail-fast guard: the primitive maps carry no `modCount`, and adding one would +tax the allocation-free hot path — against the maps' core promise. `forEach` +itself allocates nothing; it reuses the existing full-lane control-word walk +(`Swar.matchFull → firstLane → clearLowest`) already in `rehashOrGrow` / +`containsValue`. + +## Files + +Production templates (regenerate after): +- `codegen/.../template/PrimitiveMap.kt.tpl` — add `forEach` member after + `clear()`; add the `%K%%V%Consumer` interface after the class; bump the + `@Suppress("TooManyFunctions")` note (11 → 12 own-API functions). +- `codegen/.../template/SwissMap.kt.tpl` — add `forEach` member after + `asMutableMap()`; add `%K%ObjectConsumer` after the class; bump the + `@Suppress("TooManyFunctions")` note (11 → 12). + +Test templates: +- `codegen/.../template/PrimitiveMapSpec.kt.tpl` — add a `forEach` visits-once test. +- `codegen/.../template/PrimitiveMapPropertiesSpec.kt.tpl` — add a randomized + `forEach`-vs-model property. +- `codegen/.../template/SwissMapViewSpec.kt.tpl` — add a boxed `forEach` test. + +Hand-written frozen-oracle mirrors (must track the templates test-for-test): +- `elastic/.../LongLongMapSpec.kt` — mirror the visits-once test, specialized to `(Long, Long)`. +- `elastic/.../LongLongMapPropertiesSpec.kt` — mirror the property. + +Regeneration: `./gradlew :elastic:generateSwissMaps` rewrites the 6 primitive +maps, 2 boxed maps, and the generated specs. + +## Benchmark op (added) + +The `iterate` op now exists so the Phase 6 matrix can include our maps +(`benchmarks/src/commonMain/.../*Benchmark.kt`, compiles clean on JVM + Native): +- `LongLongMapBenchmark.iterate` — unboxed `Long → Long` via `forEach`. +- `SwissLongMapBenchmark.iterate` — unboxed key, boxed value (the gradient middle). +- `StdlibHashMapBenchmark.iterate` — boxed `HashMap` baseline, the + partner for both Long-axis maps. +- `IntIntMapBenchmark.iterate` / `iterateBoxed` — self-contained primitive-vs-boxed. + +Each sums `key xor value` into a primitive sink consumed once through the +`Blackhole`; the boxed arms iterate via entry destructuring (`{ (k, v) -> … }`) so +the call resolves on Native, not just the JVM `BiConsumer` overload. The one-time +captured-sink `Ref` is symmetric across arms, so the measured delta is the +per-entry boxing. Elastic/Funnel/SingleWriter benchmarks are untouched. + +## Out of scope (deliberately) + +- Running the JMH matrix on pinned hardware and publishing numbers — Phase 6. +- `forEach` on the `OpenAddressing{Long,Int}Map` interface — DP-10 keeps that + contract lean; `forEach` stays a concrete member of the Swiss classes for now. +- `SingleWriterSwissLongMap` — hand-written concurrent variant, not part of the + mechanically-substituted matrix (a lock-free snapshot walk is a separate task). + +## Verification + +- `./gradlew :elastic:verifyGeneratedSwissMaps` (drift-clean after regeneration). +- `./gradlew :codegen:test` (engine + drift specs still green). +- `./gradlew :elastic:build` / `check` — compile all targets, detekt, and the + generated + hand-written specs, including the new `forEach` coverage. +- Confirm no `%…%` residue and no >100-char line (the generator validates both). + +## Follow-ups + +- Phase 6: run the `iterate` column (with the rest of the matrix) on pinned + hardware and publish the boxing-gradient numbers + (`HashMap` boxes both · `SwissLongMap` boxes value only · `LongLongMap` boxes neither). diff --git a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/IntIntMapBenchmark.kt b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/IntIntMapBenchmark.kt index 38808f1..4be3e47 100644 --- a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/IntIntMapBenchmark.kt +++ b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/IntIntMapBenchmark.kt @@ -54,6 +54,8 @@ import kotlinx.benchmark.Warmup * boxing. `lookupHitShuffled` is the fair random-access gate (see * [SwissLongMapBenchmark]); the `FastHash` arm quantifies [IntHasher.Fibonacci] * against the default finalizer, exactly like the `LongLongMap` benchmark. + * `iterate` / `iterateBoxed` contrast a full non-boxing [IntIntMap.forEach] + * traversal against the boxed `HashMap` entry-set walk. */ @State(Scope.Benchmark) @BenchmarkMode(Mode.AverageTime) @@ -125,6 +127,24 @@ class IntIntMapBenchmark { blackhole.consume(sink) } + @Benchmark + fun iterate(blackhole: Blackhole) { + val m = map + var sink = 0 + m.forEach { key, value -> sink += key xor value } + blackhole.consume(sink) + } + + @Benchmark + fun iterateBoxed(blackhole: Blackhole) { + val m = boxed + var sink = 0 + for ((key, value) in m) { + sink += key xor value + } + blackhole.consume(sink) + } + @Benchmark fun insertAllPresized(blackhole: Blackhole) { val fresh = IntIntMap(expectedSize = size) diff --git a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/LongLongMapBenchmark.kt b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/LongLongMapBenchmark.kt index ab64cd0..c54a937 100644 --- a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/LongLongMapBenchmark.kt +++ b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/LongLongMapBenchmark.kt @@ -50,6 +50,9 @@ import kotlinx.benchmark.Warmup * into a sink consumed once through the [Blackhole], so the hot path stays free of * the boxing a per-result `consume` would introduce — the whole point of this map. * `lookupHitShuffled` is the fair random-access gate (see [SwissLongMapBenchmark]). + * `iterate` walks every entry through the non-boxing [LongLongMap.forEach] — the + * traversal the boxed baseline in [StdlibHashMapBenchmark] cannot match without + * boxing each key and value. */ @State(Scope.Benchmark) @BenchmarkMode(Mode.AverageTime) @@ -115,6 +118,14 @@ class LongLongMapBenchmark { blackhole.consume(sink) } + @Benchmark + fun iterate(blackhole: Blackhole) { + val m = map + var sink = 0L + m.forEach { key, value -> sink += key xor value } + blackhole.consume(sink) + } + @Benchmark fun insertAllPresized(blackhole: Blackhole) { val fresh = LongLongMap(expectedSize = size) diff --git a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/StdlibHashMapBenchmark.kt b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/StdlibHashMapBenchmark.kt index 32405fc..5225296 100644 --- a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/StdlibHashMapBenchmark.kt +++ b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/StdlibHashMapBenchmark.kt @@ -51,7 +51,9 @@ import kotlinx.benchmark.Warmup * baseline, with the map pre-sized in its own units (capacity for `size` entries * at the JDK load factor) as the fairness gate requires; and `insertAllGrowing` — * the same inserts into a default-capacity map, isolating resize/rehash cost. - * Each result is consumed through a [Blackhole] to defeat dead-code elimination. + * `iterate` walks the entry set — the boxed counterpart of the primitive maps' + * `forEach` traversal, boxing each key and value it visits. Each result is consumed + * through a [Blackhole] to defeat dead-code elimination. */ @State(Scope.Benchmark) @BenchmarkMode(Mode.AverageTime) @@ -99,6 +101,16 @@ class StdlibHashMapBenchmark { blackhole.consume(sink) } + @Benchmark + fun iterate(blackhole: Blackhole) { + val m = map + var sink = 0L + for ((key, value) in m) { + sink += key xor value + } + blackhole.consume(sink) + } + @Benchmark fun insertAllPresized(blackhole: Blackhole) { val fresh = HashMap((size / JDK_LOAD_FACTOR).toInt() + 1) diff --git a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/SwissLongMapBenchmark.kt b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/SwissLongMapBenchmark.kt index 7462f6c..d784339 100644 --- a/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/SwissLongMapBenchmark.kt +++ b/benchmarks/src/commonMain/kotlin/io/spine/elastic/benchmark/SwissLongMapBenchmark.kt @@ -50,7 +50,10 @@ import kotlinx.benchmark.Warmup * steady-state insert with the map pre-sized in its own units ([SwissLongMap]'s * `expectedSize`); and `insertAllGrowing`, the same inserts into a default-capacity * map, folding in resize/rehash cost — plus `lookupMiss` (keys absent from the map, - * exercising the stop-on-empty probe). Each result is consumed through a [Blackhole] + * exercising the stop-on-empty probe). `iterate` walks every entry through + * [SwissLongMap.forEach], which hands out the `Long` key unboxed and the value as its + * stored object — so it sits between the fully unboxed [LongLongMap] and the + * doubly-boxed [StdlibHashMapBenchmark]. Each result is consumed through a [Blackhole] * to defeat dead-code elimination. */ @State(Scope.Benchmark) @@ -110,6 +113,14 @@ class SwissLongMapBenchmark { blackhole.consume(sink) } + @Benchmark + fun iterate(blackhole: Blackhole) { + val m = map + var sink = 0L + m.forEach { key, value -> sink += key xor value } + blackhole.consume(sink) + } + @Benchmark fun insertAllPresized(blackhole: Blackhole) { val fresh = SwissLongMap(expectedSize = size) diff --git a/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMap.kt.tpl b/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMap.kt.tpl index 1abd685..1298ba4 100644 --- a/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMap.kt.tpl +++ b/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMap.kt.tpl @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to %V_ZERO_DOC% * @param hasher the hash function applied to keys; defaults to [%HASHER%.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class %CLASS%( expectedSize: Int = 0, public val absentValue: %V% = %V_ZERO%, @@ -177,6 +177,29 @@ public class %CLASS%( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `%K%` + * key and `%V%` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: %K%%V%Consumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class %CLASS%( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [%CLASS%]. + * + * The functional-interface counterpart of `(%K%, %V%) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [%CLASS%.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface %K%%V%Consumer { + + /** Consumes one key-value pair yielded by [%CLASS%.forEach]. */ + public fun accept(key: %K%, value: %V%) +} diff --git a/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapPropertiesSpec.kt.tpl b/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapPropertiesSpec.kt.tpl index f1e210d..fd4ee0b 100644 --- a/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapPropertiesSpec.kt.tpl +++ b/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapPropertiesSpec.kt.tpl @@ -99,6 +99,25 @@ internal class %CLASS%PropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(%K_ARB%()), 0..600)) { ops -> + val map = %CLASS%() + val model = HashMap<%K%, %V%>() + for (op in ops) { + applyAndCompare(op, map, model, %V_ZERO%) + } + var count = 0 + val collected = HashMap<%K%, %V%>() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: %CLASS%, diff --git a/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapSpec.kt.tpl b/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapSpec.kt.tpl index ce46c92..f817d35 100644 --- a/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapSpec.kt.tpl +++ b/codegen/src/main/resources/io/spine/elastic/codegen/template/PrimitiveMapSpec.kt.tpl @@ -201,6 +201,24 @@ internal class %CLASS%Spec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = %CLASS%() + val model = HashMap<%K%, %V%>() + for (key in %K(0)% until %K(100)%) { + map.put(key, (key * %K(2)%).to%V%()) + model[key] = (key * %K(2)%).to%V%() + } + var count = 0 + val seen = HashMap<%K%, %V%>() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMap.kt.tpl b/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMap.kt.tpl index 9b5e889..c9a64b3 100644 --- a/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMap.kt.tpl +++ b/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMap.kt.tpl @@ -85,7 +85,7 @@ import io.spine.elastic.internal.Swar * so that holding that many entries needs no resize; defaults to empty * @param hasher the hash function applied to keys; defaults to [%HASHER%.Default] */ -@Suppress("TooManyFunctions") // The map's own API is ten functions; `asMutableMap` is the 11th. +@Suppress("TooManyFunctions") // Ten core functions; `asMutableMap` the 11th, `forEach` the 12th. public class %CLASS%( expectedSize: Int = 0, private val hasher: %HASHER% = %HASHER%.Default, @@ -211,6 +211,28 @@ public class %CLASS%( */ public fun asMutableMap(): MutableMap<%K%, V> = BoxedView() + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold traversal that walks the control words directly — the allocation-free + * scan behind [asMutableMap]'s iterator — handing each `%K%` key to [action] + * without boxing it (the value is already an object). The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: %K%ObjectConsumer) { + val current = tables + val control = current.control + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + @Suppress("UNCHECKED_CAST") // A full slot always stores a `V`. + action.accept(current.keys[slot], current.valueAt(slot) as V) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -535,3 +557,19 @@ public class %CLASS%( "The map was structurally modified during iteration." } } + +/** + * A callback over the entries of [%CLASS%] that keeps the primitive key unboxed. + * + * The `Function2`-free counterpart of `(%K%, V) -> Unit`: a Kotlin function type + * boxes the `%K%` key on every call, whereas [accept] takes it as a primitive (the + * value is already an object). It is the argument of [%CLASS%.forEach]; a lambda + * `{ key, value -> … }` converts to it. + * + * @param V the type of mapped values, matching the map's own + */ +public fun interface %K%ObjectConsumer { + + /** Consumes one key-value pair yielded by [%CLASS%.forEach]. */ + public fun accept(key: %K%, value: V) +} diff --git a/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMapViewSpec.kt.tpl b/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMapViewSpec.kt.tpl index 7323710..72afe47 100644 --- a/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMapViewSpec.kt.tpl +++ b/codegen/src/main/resources/io/spine/elastic/codegen/template/SwissMapViewSpec.kt.tpl @@ -109,6 +109,24 @@ internal class %CLASS%ViewSpec { seen shouldBe model } + @Test + fun `forEach visits every entry of the map`() { + val map = %CLASS%() + val model = mutableMapOf<%K%, String>() + for (key in %K(0)% until %K(40)%) { + map.put(key, "v$key") + model[key] = "v$key" + } + var count = 0 + val seen = mutableMapOf<%K%, String>() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `equals a standard map with the same entries including a null value`() { val map = %CLASS%() diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index cbbfe39..d2617db 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine:benchmarks:1.0.0-SNAPSHOT-010` +# Dependencies of `io.spine:benchmarks:1.0.0-SNAPSHOT-011` ## Runtime ## Compile, tests, and tooling @@ -298,14 +298,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jul 03 04:23:38 WEST 2026** using +This report was generated on **Sat Jul 04 01:56:14 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:benchmarks-jvm:1.0.0-SNAPSHOT-010` +# Dependencies of `io.spine:benchmarks-jvm:1.0.0-SNAPSHOT-011` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 13.0. @@ -559,14 +559,14 @@ This report was generated on **Fri Jul 03 04:23:38 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jul 03 04:17:56 WEST 2026** using +This report was generated on **Sun Jul 05 00:15:28 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:codegen:1.0.0-SNAPSHOT-010` +# Dependencies of `io.spine:codegen:1.0.0-SNAPSHOT-011` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 13.0. @@ -1058,14 +1058,14 @@ This report was generated on **Fri Jul 03 04:17:56 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jul 03 04:17:56 WEST 2026** using +This report was generated on **Sat Jul 04 01:55:23 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:elastic:1.0.0-SNAPSHOT-010` +# Dependencies of `io.spine:elastic:1.0.0-SNAPSHOT-011` ## Runtime ## Compile, tests, and tooling @@ -2303,6 +2303,6 @@ This report was generated on **Fri Jul 03 04:17:56 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jul 03 04:17:56 WEST 2026** using +This report was generated on **Sat Jul 04 01:55:23 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml index 9044c2a..b7f4bc4 100644 --- a/docs/dependencies/pom.xml +++ b/docs/dependencies/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine elastic -1.0.0-SNAPSHOT-010 +1.0.0-SNAPSHOT-011 2015 diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/IntDoubleMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/IntDoubleMap.kt index 8d3ae7b..c09f1b3 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/IntDoubleMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/IntDoubleMap.kt @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to `0.0` * @param hasher the hash function applied to keys; defaults to [IntHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class IntDoubleMap( expectedSize: Int = 0, public val absentValue: Double = 0.0, @@ -177,6 +177,29 @@ public class IntDoubleMap( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `Int` + * key and `Double` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: IntDoubleConsumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class IntDoubleMap( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [IntDoubleMap]. + * + * The functional-interface counterpart of `(Int, Double) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [IntDoubleMap.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface IntDoubleConsumer { + + /** Consumes one key-value pair yielded by [IntDoubleMap.forEach]. */ + public fun accept(key: Int, value: Double) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/IntIntMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/IntIntMap.kt index 386655b..e10dcd1 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/IntIntMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/IntIntMap.kt @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to `0` * @param hasher the hash function applied to keys; defaults to [IntHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class IntIntMap( expectedSize: Int = 0, public val absentValue: Int = 0, @@ -177,6 +177,29 @@ public class IntIntMap( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `Int` + * key and `Int` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: IntIntConsumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class IntIntMap( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [IntIntMap]. + * + * The functional-interface counterpart of `(Int, Int) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [IntIntMap.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface IntIntConsumer { + + /** Consumes one key-value pair yielded by [IntIntMap.forEach]. */ + public fun accept(key: Int, value: Int) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/IntLongMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/IntLongMap.kt index 212973c..571263c 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/IntLongMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/IntLongMap.kt @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to `0` * @param hasher the hash function applied to keys; defaults to [IntHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class IntLongMap( expectedSize: Int = 0, public val absentValue: Long = 0L, @@ -177,6 +177,29 @@ public class IntLongMap( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `Int` + * key and `Long` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: IntLongConsumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class IntLongMap( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [IntLongMap]. + * + * The functional-interface counterpart of `(Int, Long) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [IntLongMap.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface IntLongConsumer { + + /** Consumes one key-value pair yielded by [IntLongMap.forEach]. */ + public fun accept(key: Int, value: Long) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/LongDoubleMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/LongDoubleMap.kt index 60bef73..a75f651 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/LongDoubleMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/LongDoubleMap.kt @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to `0.0` * @param hasher the hash function applied to keys; defaults to [LongHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class LongDoubleMap( expectedSize: Int = 0, public val absentValue: Double = 0.0, @@ -177,6 +177,29 @@ public class LongDoubleMap( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `Long` + * key and `Double` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: LongDoubleConsumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class LongDoubleMap( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [LongDoubleMap]. + * + * The functional-interface counterpart of `(Long, Double) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [LongDoubleMap.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface LongDoubleConsumer { + + /** Consumes one key-value pair yielded by [LongDoubleMap.forEach]. */ + public fun accept(key: Long, value: Double) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/LongIntMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/LongIntMap.kt index 3e591cb..94125e4 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/LongIntMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/LongIntMap.kt @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to `0` * @param hasher the hash function applied to keys; defaults to [LongHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class LongIntMap( expectedSize: Int = 0, public val absentValue: Int = 0, @@ -177,6 +177,29 @@ public class LongIntMap( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `Long` + * key and `Int` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: LongIntConsumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class LongIntMap( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [LongIntMap]. + * + * The functional-interface counterpart of `(Long, Int) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [LongIntMap.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface LongIntConsumer { + + /** Consumes one key-value pair yielded by [LongIntMap.forEach]. */ + public fun accept(key: Long, value: Int) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/LongLongMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/LongLongMap.kt index d4730d1..6b852e2 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/LongLongMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/LongLongMap.kt @@ -66,7 +66,7 @@ import io.spine.elastic.internal.Swar * @param absentValue the value returned for an absent key; defaults to `0` * @param hasher the hash function applied to keys; defaults to [LongHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is eleven functions, one over the limit. +@Suppress("TooManyFunctions") // The map's own API is twelve functions, two over the limit. public class LongLongMap( expectedSize: Int = 0, public val absentValue: Long = 0L, @@ -177,6 +177,29 @@ public class LongLongMap( growthLeft = Capacity.maxLoad(capacity) } + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold, allocation-free traversal kept off the hot path: it walks the control + * words directly — the same full-lane scan a rebuild uses — and hands each `Long` + * key and `Long` value to [action] without boxing either. The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: LongLongConsumer) { + val current = tables + val control = current.control + val keys = current.keys + val values = current.values + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + action.accept(keys[slot], values[slot]) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -323,3 +346,17 @@ public class LongLongMap( private const val SLOT_ABSENT = -1 } } + +/** + * A non-boxing callback over the entries of [LongLongMap]. + * + * The functional-interface counterpart of `(Long, Long) -> Unit` that boxes neither + * argument: a Kotlin function type erases to `Function2`, whose `invoke` boxes each + * primitive, whereas [accept] takes them as primitives. It is the argument of + * [LongLongMap.forEach]; a lambda `{ key, value -> … }` converts to it. + */ +public fun interface LongLongConsumer { + + /** Consumes one key-value pair yielded by [LongLongMap.forEach]. */ + public fun accept(key: Long, value: Long) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/SwissIntMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/SwissIntMap.kt index 817cf25..6b6ab41 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/SwissIntMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/SwissIntMap.kt @@ -73,7 +73,7 @@ import io.spine.elastic.internal.Swar * so that holding that many entries needs no resize; defaults to empty * @param hasher the hash function applied to keys; defaults to [IntHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is ten functions; `asMutableMap` is the 11th. +@Suppress("TooManyFunctions") // Ten core functions; `asMutableMap` the 11th, `forEach` the 12th. public class SwissIntMap( expectedSize: Int = 0, private val hasher: IntHasher = IntHasher.Default, @@ -199,6 +199,28 @@ public class SwissIntMap( */ public fun asMutableMap(): MutableMap = BoxedView() + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold traversal that walks the control words directly — the allocation-free + * scan behind [asMutableMap]'s iterator — handing each `Int` key to [action] + * without boxing it (the value is already an object). The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: IntObjectConsumer) { + val current = tables + val control = current.control + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + @Suppress("UNCHECKED_CAST") // A full slot always stores a `V`. + action.accept(current.keys[slot], current.valueAt(slot) as V) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -523,3 +545,19 @@ public class SwissIntMap( "The map was structurally modified during iteration." } } + +/** + * A callback over the entries of [SwissIntMap] that keeps the primitive key unboxed. + * + * The `Function2`-free counterpart of `(Int, V) -> Unit`: a Kotlin function type + * boxes the `Int` key on every call, whereas [accept] takes it as a primitive (the + * value is already an object). It is the argument of [SwissIntMap.forEach]; a lambda + * `{ key, value -> … }` converts to it. + * + * @param V the type of mapped values, matching the map's own + */ +public fun interface IntObjectConsumer { + + /** Consumes one key-value pair yielded by [SwissIntMap.forEach]. */ + public fun accept(key: Int, value: V) +} diff --git a/elastic/src/commonMain/kotlin/io/spine/elastic/SwissLongMap.kt b/elastic/src/commonMain/kotlin/io/spine/elastic/SwissLongMap.kt index 7d5ff3f..9394d6a 100644 --- a/elastic/src/commonMain/kotlin/io/spine/elastic/SwissLongMap.kt +++ b/elastic/src/commonMain/kotlin/io/spine/elastic/SwissLongMap.kt @@ -74,7 +74,7 @@ import io.spine.elastic.internal.Swar * so that holding that many entries needs no resize; defaults to empty * @param hasher the hash function applied to keys; defaults to [LongHasher.Default] */ -@Suppress("TooManyFunctions") // The map's own API is ten functions; `asMutableMap` is the 11th. +@Suppress("TooManyFunctions") // Ten core functions; `asMutableMap` the 11th, `forEach` the 12th. public class SwissLongMap( expectedSize: Int = 0, private val hasher: LongHasher = LongHasher.Default, @@ -200,6 +200,28 @@ public class SwissLongMap( */ public fun asMutableMap(): MutableMap = BoxedView() + /** + * Applies [action] to every key-value pair, in unspecified order. + * + * A cold traversal that walks the control words directly — the allocation-free + * scan behind [asMutableMap]'s iterator — handing each `Long` key to [action] + * without boxing it (the value is already an object). The map must not be + * structurally modified during the walk. + */ + public fun forEach(action: LongObjectConsumer) { + val current = tables + val control = current.control + for (group in control.indices) { + var fulls = Swar.matchFull(control[group]) + while (fulls != 0L) { + val slot = (group shl Swar.GROUP_SHIFT) + Swar.firstLane(fulls) + @Suppress("UNCHECKED_CAST") // A full slot always stores a `V`. + action.accept(current.keys[slot], current.valueAt(slot) as V) + fulls = Swar.clearLowest(fulls) + } + } + } + /** * Returns the slot holding [key] in [table], or [SLOT_ABSENT] if it is not * present. The probe stops at the first empty lane, which proves absence. @@ -524,3 +546,19 @@ public class SwissLongMap( "The map was structurally modified during iteration." } } + +/** + * A callback over the entries of [SwissLongMap] that keeps the primitive key unboxed. + * + * The `Function2`-free counterpart of `(Long, V) -> Unit`: a Kotlin function type + * boxes the `Long` key on every call, whereas [accept] takes it as a primitive (the + * value is already an object). It is the argument of [SwissLongMap.forEach]; a lambda + * `{ key, value -> … }` converts to it. + * + * @param V the type of mapped values, matching the map's own + */ +public fun interface LongObjectConsumer { + + /** Consumes one key-value pair yielded by [SwissLongMap.forEach]. */ + public fun accept(key: Long, value: V) +} diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapPropertiesSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapPropertiesSpec.kt index 1bf4121..0056c5a 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapPropertiesSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapPropertiesSpec.kt @@ -101,6 +101,25 @@ internal class IntDoubleMapPropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(Arb.int()), 0..600)) { ops -> + val map = IntDoubleMap() + val model = HashMap() + for (op in ops) { + applyAndCompare(op, map, model, 0.0) + } + var count = 0 + val collected = HashMap() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: IntDoubleMap, diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapSpec.kt index c96fb46..f37faad 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/IntDoubleMapSpec.kt @@ -201,6 +201,24 @@ internal class IntDoubleMapSpec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = IntDoubleMap() + val model = HashMap() + for (key in 0 until 100) { + map.put(key, (key * 2).toDouble()) + model[key] = (key * 2).toDouble() + } + var count = 0 + val seen = HashMap() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapPropertiesSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapPropertiesSpec.kt index aa1b4be..554bd03 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapPropertiesSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapPropertiesSpec.kt @@ -99,6 +99,25 @@ internal class IntIntMapPropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(Arb.int()), 0..600)) { ops -> + val map = IntIntMap() + val model = HashMap() + for (op in ops) { + applyAndCompare(op, map, model, 0) + } + var count = 0 + val collected = HashMap() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: IntIntMap, diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapSpec.kt index 908004d..8c92ec6 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/IntIntMapSpec.kt @@ -201,6 +201,24 @@ internal class IntIntMapSpec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = IntIntMap() + val model = HashMap() + for (key in 0 until 100) { + map.put(key, (key * 2).toInt()) + model[key] = (key * 2).toInt() + } + var count = 0 + val seen = HashMap() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapPropertiesSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapPropertiesSpec.kt index a3e6227..e96f2ec 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapPropertiesSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapPropertiesSpec.kt @@ -100,6 +100,25 @@ internal class IntLongMapPropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(Arb.int()), 0..600)) { ops -> + val map = IntLongMap() + val model = HashMap() + for (op in ops) { + applyAndCompare(op, map, model, 0L) + } + var count = 0 + val collected = HashMap() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: IntLongMap, diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapSpec.kt index 2a718b2..9dbff88 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/IntLongMapSpec.kt @@ -201,6 +201,24 @@ internal class IntLongMapSpec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = IntLongMap() + val model = HashMap() + for (key in 0 until 100) { + map.put(key, (key * 2).toLong()) + model[key] = (key * 2).toLong() + } + var count = 0 + val seen = HashMap() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapPropertiesSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapPropertiesSpec.kt index cf2de34..95451ba 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapPropertiesSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapPropertiesSpec.kt @@ -101,6 +101,25 @@ internal class LongDoubleMapPropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(Arb.long()), 0..600)) { ops -> + val map = LongDoubleMap() + val model = HashMap() + for (op in ops) { + applyAndCompare(op, map, model, 0.0) + } + var count = 0 + val collected = HashMap() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: LongDoubleMap, diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapSpec.kt index 86346d6..58da21b 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/LongDoubleMapSpec.kt @@ -201,6 +201,24 @@ internal class LongDoubleMapSpec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = LongDoubleMap() + val model = HashMap() + for (key in 0L until 100L) { + map.put(key, (key * 2L).toDouble()) + model[key] = (key * 2L).toDouble() + } + var count = 0 + val seen = HashMap() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapPropertiesSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapPropertiesSpec.kt index f11feec..75c591f 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapPropertiesSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapPropertiesSpec.kt @@ -100,6 +100,25 @@ internal class LongIntMapPropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(Arb.long()), 0..600)) { ops -> + val map = LongIntMap() + val model = HashMap() + for (op in ops) { + applyAndCompare(op, map, model, 0) + } + var count = 0 + val collected = HashMap() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: LongIntMap, diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapSpec.kt index 455f027..84d943d 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/LongIntMapSpec.kt @@ -201,6 +201,24 @@ internal class LongIntMapSpec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = LongIntMap() + val model = HashMap() + for (key in 0L until 100L) { + map.put(key, (key * 2L).toInt()) + model[key] = (key * 2L).toInt() + } + var count = 0 + val seen = HashMap() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapPropertiesSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapPropertiesSpec.kt index 79e8b64..663f17e 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapPropertiesSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapPropertiesSpec.kt @@ -93,6 +93,25 @@ internal class LongLongMapPropertiesSpec { } } + @Test + fun `forEach visits exactly the entries the model holds`() = runTest { + checkAll(120, Arb.list(operations(Arb.long()), 0..600)) { ops -> + val map = LongLongMap() + val model = HashMap() + for (op in ops) { + applyAndCompare(op, map, model, 0L) + } + var count = 0 + val collected = HashMap() + map.forEach { key, value -> + collected[key] = value + count++ + } + count shouldBe model.size + collected shouldBe model + } + } + private fun applyAndCompare( op: Op, map: LongLongMap, diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapSpec.kt index 3602fdb..4e6023d 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/LongLongMapSpec.kt @@ -190,6 +190,24 @@ internal class LongLongMapSpec { map.size shouldBe 1 } + @Test + fun `visits every entry exactly once via forEach`() { + val map = LongLongMap() + val model = HashMap() + for (key in 0L until 100L) { + map.put(key, key * 2L) + model[key] = key * 2L + } + var count = 0 + val seen = HashMap() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `rejects a negative expected size`() { shouldThrow { diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/SwissIntMapViewSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/SwissIntMapViewSpec.kt index a5c4f5d..65a2709 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/SwissIntMapViewSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/SwissIntMapViewSpec.kt @@ -109,6 +109,24 @@ internal class SwissIntMapViewSpec { seen shouldBe model } + @Test + fun `forEach visits every entry of the map`() { + val map = SwissIntMap() + val model = mutableMapOf() + for (key in 0 until 40) { + map.put(key, "v$key") + model[key] = "v$key" + } + var count = 0 + val seen = mutableMapOf() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `equals a standard map with the same entries including a null value`() { val map = SwissIntMap() diff --git a/elastic/src/commonTest/kotlin/io/spine/elastic/SwissLongMapViewSpec.kt b/elastic/src/commonTest/kotlin/io/spine/elastic/SwissLongMapViewSpec.kt index 6a041e5..c1ed6a1 100644 --- a/elastic/src/commonTest/kotlin/io/spine/elastic/SwissLongMapViewSpec.kt +++ b/elastic/src/commonTest/kotlin/io/spine/elastic/SwissLongMapViewSpec.kt @@ -109,6 +109,24 @@ internal class SwissLongMapViewSpec { seen shouldBe model } + @Test + fun `forEach visits every entry of the map`() { + val map = SwissLongMap() + val model = mutableMapOf() + for (key in 0L until 40L) { + map.put(key, "v$key") + model[key] = "v$key" + } + var count = 0 + val seen = mutableMapOf() + map.forEach { key, value -> + seen[key] = value + count++ + } + count shouldBe model.size + seen shouldBe model + } + @Test fun `equals a standard map with the same entries including a null value`() { val map = SwissLongMap() diff --git a/version.gradle.kts b/version.gradle.kts index 547e4a0..7970b0f 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -24,4 +24,4 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -extra.set("versionToPublish", "1.0.0-SNAPSHOT-010") +extra.set("versionToPublish", "1.0.0-SNAPSHOT-011")