diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java index 5f860a4..6978390 100644 --- a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java @@ -25,9 +25,17 @@ public interface CountStore extends VelocityBackend { * APPLIED|FAILED|SKIPPED} — a {@code SKIPPED} being a backend-owned outcome such as an idempotent * replay) alongside the {@link FeatureResult} (ADR 0009, FR-34). * + *
The result cardinality is one {@link
+ * com.codeheadsystems.velocity.spi.model.PerFeature PerFeature} per {@code (intent × each of the
+ * intent's feature's windows)}, not one per intent: a feature tracked over several
+ * windows contributes one entry per window, each carrying that window's post-apply value. The
+ * entries are therefore not positionally aligned with {@code intents}; disambiguate by the
+ * entry's feature and its {@link com.codeheadsystems.velocity.spi.model.FeatureValue#window()
+ * window}.
+ *
* @param ctx the namespace + optional deadline context
* @param intents the count intents to apply
- * @return the per-intent apply outcomes, positionally aligned with {@code intents}
+ * @return one apply outcome per {@code (intent × the intent's feature's windows)}
*/
ApplyResult applyCount(ApplyContext ctx, List The result cardinality is one {@link
+ * com.codeheadsystems.velocity.spi.model.PerFeature PerFeature} per {@code (intent × each of the
+ * intent's feature's windows)}, not one per intent: a feature tracked over several
+ * windows contributes one entry per window, each carrying that window's post-apply value. The
+ * entries are therefore not positionally aligned with {@code intents}; disambiguate by the
+ * entry's feature and its {@link com.codeheadsystems.velocity.spi.model.FeatureValue#window()
+ * window}.
+ *
* @param ctx the namespace + optional deadline context
* @param intents the distinct intents to apply
- * @return the per-intent apply outcomes, positionally aligned with {@code intents}
+ * @return one apply outcome per {@code (intent × the intent's feature's windows)}
*/
ApplyResult applyDistinct(ApplyContext ctx, List The result cardinality is one {@link
+ * com.codeheadsystems.velocity.spi.model.PerFeature PerFeature} per {@code (intent × each of the
+ * intent's feature's windows)}, not one per intent: a feature tracked over several
+ * windows contributes one entry per window, each carrying that window's post-apply value. The
+ * entries are therefore not positionally aligned with {@code intents}; disambiguate by the
+ * entry's feature and its {@link com.codeheadsystems.velocity.spi.model.FeatureValue#window()
+ * window}.
+ *
* @param ctx the namespace + optional deadline context
* @param intents the sum intents to apply
- * @return the per-intent apply outcomes, positionally aligned with {@code intents}
+ * @return one apply outcome per {@code (intent × the intent's feature's windows)}
*/
ApplyResult applySum(ApplyContext ctx, List Two jobs: it proves the frozen SPI contract is implementable as an exact,
+ * atomic, thread-safe store, and it is the primary subject the conformance TCK ({@code
+ * com.codeheadsystems.velocity.testkit.tck}) runs against. It implements every data-plane mix-in —
+ * {@link CountStore}, {@link SumStore}, {@link DistinctStore} — over both window markers ({@link
+ * SlidingSupport}, {@link TumblingSupport}) plus {@link SeedSupport}, and declares {@link
+ * ReadYourWriteLevel#ATOMIC} because an apply's returned value already reflects that write.
+ *
+ * Model. Windowing lives in the backend (ADR 0003). The store keeps raw
+ * events per {@code (namespace, subject, aggregation)} — a list of instants for COUNT, of
+ * {@code (instant, cents)} for SUM, of {@code (instant, member)} for DISTINCT — and every window a
+ * feature declares is computed from that one stored event stream at read time (so one
+ * apply records a single underlying event, not one per window). This is exactly the property that
+ * lets a seeded bucket and an organically recorded bucket merge identically (ADR 0008). Window
+ * arithmetic is {@link WindowMath}. The backend is the clock authority (FR-3): inject a {@link
+ * MutableClock} to drive window aging deterministically.
+ *
+ * Distinct is exact-only here. The clamp/threshold are set effectively
+ * infinite, so this backend never degrades to HLL; it therefore rejects an {@link HllDistinct} seed
+ * (ADR 0005/0006).
+ */
+public final class InMemoryVelocityBackend
+ implements CountStore, SumStore, DistinctStore, SlidingSupport, TumblingSupport, SeedSupport {
+
+ private final Clock clock;
+ private final BackendCapabilities capabilities;
+ private final ConcurrentMap The backend is the clock authority for windowing (FR-3, ADR 0003), so the conformance TCK
+ * needs to move time deterministically — age an event out of a sliding window, cross a tumbling
+ * bucket boundary — without sleeping. Inject one of these into any backend that accepts a {@link
+ * Clock} (the {@link InMemoryVelocityBackend} does) and the same instance drives both the backend's
+ * bucketing and the scenario's expectations.
+ *
+ * The stored instant is {@code volatile} so an advance on one thread is visible to a concurrent
+ * apply/query on another; it is not a substitute for the backend's own per-key locking.
+ */
+public final class MutableClock extends Clock {
+
+ private final ZoneId zone;
+ private volatile Instant instant;
+
+ /**
+ * Creates a clock fixed at {@code start} in UTC.
+ *
+ * @param start the initial instant
+ */
+ public MutableClock(final Instant start) {
+ this(start, ZoneOffset.UTC);
+ }
+
+ private MutableClock(final Instant start, final ZoneId zone) {
+ this.instant = Objects.requireNonNull(start, "start");
+ this.zone = Objects.requireNonNull(zone, "zone");
+ }
+
+ /**
+ * A clock fixed at the current system instant, truncated to milliseconds so bucket-boundary math
+ * is free of sub-millisecond noise.
+ *
+ * @return a new mutable clock at "now"
+ */
+ public static MutableClock atNow() {
+ return new MutableClock(Instant.ofEpochMilli(System.currentTimeMillis()));
+ }
+
+ /**
+ * Sets the clock to an explicit instant.
+ *
+ * @param newInstant the instant the clock should report from now on
+ */
+ public void setInstant(final Instant newInstant) {
+ this.instant = Objects.requireNonNull(newInstant, "newInstant");
+ }
+
+ /**
+ * Advances the clock forward by the given amount.
+ *
+ * @param amount the (non-negative) duration to move forward
+ */
+ public void advance(final Duration amount) {
+ Objects.requireNonNull(amount, "amount");
+ this.instant = this.instant.plus(amount);
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return zone;
+ }
+
+ @Override
+ public Clock withZone(final ZoneId newZone) {
+ return new MutableClock(instant, newZone);
+ }
+
+ @Override
+ public Instant instant() {
+ return instant;
+ }
+
+ @Override
+ public long millis() {
+ return instant.toEpochMilli();
+ }
+}
diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/WindowMath.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/WindowMath.java
new file mode 100644
index 0000000..24f17bc
--- /dev/null
+++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/WindowMath.java
@@ -0,0 +1,86 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit;
+
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowBounds;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * The window-bounds arithmetic the {@link InMemoryVelocityBackend} computes at read time (ADR 0003:
+ * the backend owns time-shaping). Exposed as a small pure utility so both the backend and the
+ * conformance TCK compute the same bounds — a scenario can seed exactly the bucket a query
+ * will later merge.
+ *
+ * Semantics (FR-14):
+ *
+ * {@link com.codeheadsystems.velocity.testkit.InMemoryVelocityBackend} is the exact, atomic,
+ * thread-safe reference implementation that proves the frozen SPI contract is implementable (ADR
+ * 0004, NFR-13/18); {@link com.codeheadsystems.velocity.testkit.MutableClock} and {@link
+ * com.codeheadsystems.velocity.testkit.WindowMath} let the conformance TCK ({@link
+ * com.codeheadsystems.velocity.testkit.tck}) drive window aging deterministically. Every {@code
+ * velocity-backend-*} must pass that TCK for its declared capabilities.
*/
+@NullMarked
package com.codeheadsystems.velocity.testkit;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java
new file mode 100644
index 0000000..76e23c1
--- /dev/null
+++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CapabilityConformanceScenarios.java
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit.tck;
+
+import static com.codeheadsystems.velocity.testkit.tck.Tck.DIMENSION;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_A;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_A;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.UNSUPPORTED;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.assertFailure;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.assertValue;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.spi.CountStore;
+import com.codeheadsystems.velocity.spi.DistinctStore;
+import com.codeheadsystems.velocity.spi.SeedSupport;
+import com.codeheadsystems.velocity.spi.SlidingSupport;
+import com.codeheadsystems.velocity.spi.SumStore;
+import com.codeheadsystems.velocity.spi.TumblingSupport;
+import com.codeheadsystems.velocity.spi.VelocityBackend;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.AggregationType;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities;
+import com.codeheadsystems.velocity.spi.model.FailureCode;
+import com.codeheadsystems.velocity.spi.model.FeatureResult;
+import com.codeheadsystems.velocity.spi.model.QueryContext;
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowType;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The ADR 0004 coherence suite: what a backend declares in {@link BackendCapabilities}
+ * must match what it implements, and the negatives are contract.
+ * Declaration↔implementation agreement, {@code distinctHllSliding == false}, an unsupported window
+ * that fast-rejects with a distinguishable {@link FailureCode#UNSUPPORTED_WINDOW} (never a silent
+ * {@code 0}), and a supported empty window that returns a known {@code Success(0)} rather than a
+ * failure.
+ */
+public final class CapabilityConformanceScenarios {
+
+ private final VelocityBackend backend;
+
+ /**
+ * @param backend the backend under test
+ */
+ public CapabilityConformanceScenarios(final VelocityBackend backend) {
+ this.backend = Objects.requireNonNull(backend, "backend");
+ }
+
+ /** Each declared aggregation is implemented as its mix-in, and each undeclared one is not. */
+ public void declaredAggregationsMatchImplementedMixins() {
+ final BackendCapabilities caps = backend.capabilities();
+ assertThat(caps.supportsAggregation(AggregationType.COUNT))
+ .isEqualTo(backend instanceof CountStore);
+ assertThat(caps.supportsAggregation(AggregationType.SUM))
+ .isEqualTo(backend instanceof SumStore);
+ assertThat(caps.supportsAggregation(AggregationType.DISTINCT))
+ .isEqualTo(backend instanceof DistinctStore);
+ }
+
+ /** The window markers agree with the declared window specs' types. */
+ public void declaredWindowMarkersMatchWindowSpecs() {
+ final BackendCapabilities caps = backend.capabilities();
+ final boolean anySliding =
+ caps.windows().stream().anyMatch(spec -> spec.window().type() == WindowType.SLIDING);
+ final boolean anyTumbling =
+ caps.windows().stream().anyMatch(spec -> spec.window().type() == WindowType.TUMBLING);
+ assertThat(backend instanceof SlidingSupport).isEqualTo(anySliding);
+ assertThat(backend instanceof TumblingSupport).isEqualTo(anyTumbling);
+ }
+
+ /** The seed capability flag agrees with the presence of the {@link SeedSupport} mix-in. */
+ public void seedSupportFlagMatchesMixin() {
+ assertThat(backend.capabilities().seedSupported()).isEqualTo(backend instanceof SeedSupport);
+ }
+
+ /**
+ * HLL-distinct on a sliding window is structurally impossible: the flag must be false (ADR 0005).
+ */
+ public void distinctHllSlidingIsFalse() {
+ assertThat(backend.capabilities().distinctHllSliding()).isFalse();
+ }
+
+ /**
+ * Querying an unsupported window fast-rejects with a distinguishable {@link
+ * FailureCode#UNSUPPORTED_WINDOW} — never a silent {@code 0} (ADR 0009 rule 1, FR-13).
+ */
+ public void unsupportedWindowQueryIsDistinguishableFailure() {
+ final FeatureResult result = queryOne(UNSUPPORTED);
+ assertThat(result.isSuccess()).isFalse();
+ assertFailure(result, FailureCode.UNSUPPORTED_WINDOW);
+ }
+
+ /**
+ * A supported window with no data returns a known {@code Success(0)}, not a failure — the backend
+ * knows the answer is zero (distinct from "I could not answer").
+ */
+ public void supportedEmptyWindowReturnsKnownZero() {
+ final Window supported = backend.capabilities().windows().get(0).window();
+ assertValue(queryOne(supported), 0);
+ }
+
+ private FeatureResult queryOne(final Window window) {
+ final QueryContext ctx = Tck.query(NS_A);
+ if (backend instanceof CountStore countStore) {
+ return countStore
+ .queryCount(ctx, List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), window)))
+ .get(0);
+ }
+ if (backend instanceof SumStore sumStore) {
+ return sumStore
+ .querySum(ctx, List.of(Tck.tuple(SUBJECT_A, Aggregation.sum(), window)))
+ .get(0);
+ }
+ if (backend instanceof DistinctStore distinctStore) {
+ return distinctStore
+ .queryDistinct(
+ ctx, List.of(Tck.tuple(SUBJECT_A, Aggregation.distinct(DIMENSION), window)))
+ .get(0);
+ }
+ throw new IllegalStateException("backend implements no aggregation store to query");
+ }
+}
diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java
new file mode 100644
index 0000000..d8d4385
--- /dev/null
+++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/tck/CountStoreScenarios.java
@@ -0,0 +1,184 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit.tck;
+
+import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_A;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.NS_B;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_1M;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.SLIDING_5S;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_A;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.SUBJECT_B;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.TUMBLING_1H;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.assertValue;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.countFeature;
+import static com.codeheadsystems.velocity.testkit.tck.Tck.successValue;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.spi.CountStore;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.ApplyResult;
+import com.codeheadsystems.velocity.spi.model.ApplyStatus;
+import com.codeheadsystems.velocity.spi.model.Exactness;
+import com.codeheadsystems.velocity.spi.model.Feature;
+import com.codeheadsystems.velocity.spi.model.Intent.CountIntent;
+import com.codeheadsystems.velocity.spi.model.PerFeature;
+import com.codeheadsystems.velocity.spi.model.ReadYourWriteLevel;
+import com.codeheadsystems.velocity.testkit.MutableClock;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * The {@link CountStore} contract: apply records events, query reads back the exact windowed count,
+ * the write is reflected read-your-write, and values are isolated per namespace and subject.
+ */
+public final class CountStoreScenarios {
+
+ private final CountStore store;
+ private final MutableClock clock;
+
+ /**
+ * @param store a fresh count-capable backend under test
+ * @param clock the backend's controllable clock
+ */
+ public CountStoreScenarios(final CountStore store, final MutableClock clock) {
+ this.store = Objects.requireNonNull(store, "store");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ /** Apply N events then query the window returns exactly N, flagged EXACT/ATOMIC. */
+ public void applyThenQueryReturnsExactCount() {
+ final Feature feature = countFeature(SLIDING_1M);
+ store.applyCount(Tck.apply(NS_A), intents(feature, 3));
+
+ final var results =
+ store.queryCount(
+ Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M)));
+ assertThat(results).hasSize(1);
+ assertValue(results.get(0), 3);
+ assertThat(successValue(results.get(0)).exactness()).isEqualTo(Exactness.EXACT);
+ assertThat(successValue(results.get(0)).readYourWriteLevel())
+ .isEqualTo(ReadYourWriteLevel.ATOMIC);
+ // The value is as-of the backend clock (FR-3/FR-7), not the caller's.
+ assertThat(successValue(results.get(0)).asOf()).isEqualTo(clock.instant());
+ }
+
+ /** The value returned by apply already reflects the caller's own write (ADR 0007, NFR-7). */
+ public void applyResultReflectsWriteReadYourWrite() {
+ final Feature feature = countFeature(SLIDING_1M);
+
+ final ApplyResult first = store.applyCount(Tck.apply(NS_A), intents(feature, 1));
+ assertThat(first.perFeature()).hasSize(1);
+ assertThat(first.perFeature().get(0).status()).isEqualTo(ApplyStatus.APPLIED);
+ assertValue(first.perFeature().get(0).result(), 1);
+
+ final ApplyResult second = store.applyCount(Tck.apply(NS_A), intents(feature, 1));
+ assertValue(second.perFeature().get(0).result(), 2);
+ }
+
+ /** A multi-window feature yields one {@link PerFeature} per window (apply cardinality). */
+ public void applyEmitsOneResultPerWindow() {
+ final Feature feature = countFeature(SLIDING_5S, TUMBLING_1H);
+ final ApplyResult result = store.applyCount(Tck.apply(NS_A), intents(feature, 1));
+
+ assertThat(result.perFeature()).hasSize(2);
+ assertThat(result.perFeature())
+ .allSatisfy(pf -> assertThat(pf.status()).isEqualTo(ApplyStatus.APPLIED));
+ assertThat(result.perFeature().stream().map(pf -> successValue(pf.result()).window()).toList())
+ .containsExactlyInAnyOrder(SLIDING_5S, TUMBLING_1H);
+ // One underlying event, not one per window: each window sees exactly the single apply.
+ assertThat(result.perFeature()).allSatisfy(pf -> assertValue(pf.result(), 1));
+ }
+
+ /** Counts in one namespace are invisible in another; the other reads a known zero. */
+ public void valuesIsolatedByNamespace() {
+ store.applyCount(Tck.apply(NS_A), intents(countFeature(SLIDING_1M), 2));
+
+ assertValue(
+ store
+ .queryCount(
+ Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M)))
+ .get(0),
+ 2);
+ assertValue(
+ store
+ .queryCount(
+ Tck.query(NS_B), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M)))
+ .get(0),
+ 0);
+ }
+
+ /** Counts for one subject are invisible for another subject in the same namespace. */
+ public void valuesIsolatedBySubject() {
+ store.applyCount(
+ Tck.apply(NS_A), List.of(new CountIntent(countFeature(SLIDING_1M), SUBJECT_A)));
+
+ assertValue(
+ store
+ .queryCount(
+ Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_A, Aggregation.count(), SLIDING_1M)))
+ .get(0),
+ 1);
+ assertValue(
+ store
+ .queryCount(
+ Tck.query(NS_A), List.of(Tck.tuple(SUBJECT_B, Aggregation.count(), SLIDING_1M)))
+ .get(0),
+ 0);
+ }
+
+ /**
+ * Concurrent applies to the same subject are atomic: N threads each record once and the final
+ * count is exactly N, never lost to a read-modify-write race (NFR-6).
+ */
+ public void concurrentApplyIsAtomic() throws Exception {
+ final Feature feature = countFeature(SLIDING_1M);
+ final int threads = 16;
+ final CountDownLatch ready = new CountDownLatch(threads);
+ final CountDownLatch fire = new CountDownLatch(1);
+ try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
+ final List Each {@code *Scenarios} class is a backend-neutral contract suite: constructed with a backend
+ * (the specific mix-in type) plus a {@link com.codeheadsystems.velocity.testkit.MutableClock}, it
+ * exposes public methods that assert one facet of the frozen contract with AssertJ. A backend
+ * module drives them from its own JUnit test against a fresh store and a controllable clock, so the
+ * same contract — exact aggregates, read-your-write, window aging, seed/record equivalence,
+ * distinguishable failures instead of a silent {@code 0} — is verified identically for every
+ * backend. The suites deliberately have no JUnit dependency so a backend picks its own test runner.
+ */
+@NullMarked
+package com.codeheadsystems.velocity.testkit.tck;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java
new file mode 100644
index 0000000..5b433af
--- /dev/null
+++ b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTck.java
@@ -0,0 +1,204 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit;
+
+import com.codeheadsystems.velocity.testkit.tck.CapabilityConformanceScenarios;
+import com.codeheadsystems.velocity.testkit.tck.CountStoreScenarios;
+import com.codeheadsystems.velocity.testkit.tck.DistinctStoreScenarios;
+import com.codeheadsystems.velocity.testkit.tck.PurgeScenarios;
+import com.codeheadsystems.velocity.testkit.tck.SeedSupportScenarios;
+import com.codeheadsystems.velocity.testkit.tck.SlidingScenarios;
+import com.codeheadsystems.velocity.testkit.tck.SumStoreScenarios;
+import com.codeheadsystems.velocity.testkit.tck.TumblingScenarios;
+import java.time.Instant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Drives every conformance {@code *Scenarios} suite against the {@link InMemoryVelocityBackend}, so
+ * the reference backend passes its own TCK (ADR 0004). This is both the correctness proof for the
+ * reference implementation and the coverage driver for the testkit's own gate.
+ */
+class InMemoryVelocityBackendTck {
+
+ /** A deterministic, mid-minute/mid-hour instant so time-independent scenarios are stable. */
+ private static final Instant FIXED = Instant.parse("2026-07-18T12:00:30Z");
+
+ private InMemoryVelocityBackend backend;
+ private MutableClock clock;
+
+ @BeforeEach
+ void freshBackend() {
+ clock = new MutableClock(FIXED);
+ backend = new InMemoryVelocityBackend(clock);
+ }
+
+ @Nested
+ class Count {
+ @Test
+ void applyThenQueryReturnsExactCount() {
+ new CountStoreScenarios(backend, clock).applyThenQueryReturnsExactCount();
+ }
+
+ @Test
+ void applyResultReflectsWriteReadYourWrite() {
+ new CountStoreScenarios(backend, clock).applyResultReflectsWriteReadYourWrite();
+ }
+
+ @Test
+ void applyEmitsOneResultPerWindow() {
+ new CountStoreScenarios(backend, clock).applyEmitsOneResultPerWindow();
+ }
+
+ @Test
+ void valuesIsolatedByNamespace() {
+ new CountStoreScenarios(backend, clock).valuesIsolatedByNamespace();
+ }
+
+ @Test
+ void valuesIsolatedBySubject() {
+ new CountStoreScenarios(backend, clock).valuesIsolatedBySubject();
+ }
+
+ @Test
+ void concurrentApplyIsAtomic() throws Exception {
+ new CountStoreScenarios(backend, clock).concurrentApplyIsAtomic();
+ }
+ }
+
+ @Nested
+ class Sum {
+ @Test
+ void applyThenQueryReturnsExactSumCents() {
+ new SumStoreScenarios(backend, clock).applyThenQueryReturnsExactSumCents();
+ }
+
+ @Test
+ void applyResultReflectsRunningSum() {
+ new SumStoreScenarios(backend, clock).applyResultReflectsRunningSum();
+ }
+
+ @Test
+ void bigDecimalCentsPreservedWithoutOverflow() {
+ new SumStoreScenarios(backend, clock).bigDecimalCentsPreservedWithoutOverflow();
+ }
+
+ @Test
+ void negativeValuesForRefunds() {
+ new SumStoreScenarios(backend, clock).negativeValuesForRefunds();
+ }
+
+ @Test
+ void valuesIsolatedByNamespace() {
+ new SumStoreScenarios(backend, clock).valuesIsolatedByNamespace();
+ }
+ }
+
+ @Nested
+ class Distinct {
+ @Test
+ void applyThenQueryReturnsCardinality() {
+ new DistinctStoreScenarios(backend, clock).applyThenQueryReturnsCardinality();
+ }
+
+ @Test
+ void deDupesRepeatedMembers() {
+ new DistinctStoreScenarios(backend, clock).deDupesRepeatedMembers();
+ }
+
+ @Test
+ void applyResultReflectsCardinality() {
+ new DistinctStoreScenarios(backend, clock).applyResultReflectsCardinality();
+ }
+
+ @Test
+ void valuesIsolatedBySubject() {
+ new DistinctStoreScenarios(backend, clock).valuesIsolatedBySubject();
+ }
+ }
+
+ @Nested
+ class Sliding {
+ @Test
+ void eventsAgeOutAsClockAdvances() {
+ new SlidingScenarios(backend, clock).eventsAgeOutAsClockAdvances();
+ }
+
+ @Test
+ void slidingLeadingEdgeIsExclusive() {
+ new SlidingScenarios(backend, clock).slidingLeadingEdgeIsExclusive();
+ }
+ }
+
+ @Nested
+ class Tumbling {
+ @Test
+ void tumblingAccumulatesThenResetsAtBoundary() {
+ new TumblingScenarios(backend, clock).tumblingAccumulatesThenResetsAtBoundary();
+ }
+ }
+
+ @Nested
+ class Seed {
+ @Test
+ void seededBucketMergesIdenticallyWithRecorded() {
+ new SeedSupportScenarios<>(backend, clock).seededBucketMergesIdenticallyWithRecorded();
+ }
+
+ @Test
+ void hllDistinctSeedRejected() {
+ new SeedSupportScenarios<>(backend, clock).hllDistinctSeedRejected();
+ }
+
+ @Test
+ void seedWithUnsupportedWindowRejected() {
+ new SeedSupportScenarios<>(backend, clock).seedWithUnsupportedWindowRejected();
+ }
+ }
+
+ @Nested
+ class Capability {
+ @Test
+ void declaredAggregationsMatchImplementedMixins() {
+ new CapabilityConformanceScenarios(backend).declaredAggregationsMatchImplementedMixins();
+ }
+
+ @Test
+ void declaredWindowMarkersMatchWindowSpecs() {
+ new CapabilityConformanceScenarios(backend).declaredWindowMarkersMatchWindowSpecs();
+ }
+
+ @Test
+ void seedSupportFlagMatchesMixin() {
+ new CapabilityConformanceScenarios(backend).seedSupportFlagMatchesMixin();
+ }
+
+ @Test
+ void distinctHllSlidingIsFalse() {
+ new CapabilityConformanceScenarios(backend).distinctHllSlidingIsFalse();
+ }
+
+ @Test
+ void unsupportedWindowQueryIsDistinguishableFailure() {
+ new CapabilityConformanceScenarios(backend).unsupportedWindowQueryIsDistinguishableFailure();
+ }
+
+ @Test
+ void supportedEmptyWindowReturnsKnownZero() {
+ new CapabilityConformanceScenarios(backend).supportedEmptyWindowReturnsKnownZero();
+ }
+ }
+
+ @Nested
+ class Purge {
+ @Test
+ void purgeSubjectClearsThatSubjectOnly() {
+ new PurgeScenarios(backend).purgeSubjectClearsThatSubjectOnly();
+ }
+
+ @Test
+ void purgeNamespaceClearsEntireNamespace() {
+ new PurgeScenarios(backend).purgeNamespaceClearsEntireNamespace();
+ }
+ }
+}
diff --git a/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTest.java b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTest.java
new file mode 100644
index 0000000..98b6437
--- /dev/null
+++ b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/InMemoryVelocityBackendTest.java
@@ -0,0 +1,193 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.AggregationType;
+import com.codeheadsystems.velocity.spi.model.ApplyContext;
+import com.codeheadsystems.velocity.spi.model.ApplyResult;
+import com.codeheadsystems.velocity.spi.model.ApplyStatus;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities;
+import com.codeheadsystems.velocity.spi.model.BucketValue;
+import com.codeheadsystems.velocity.spi.model.DistinctMember;
+import com.codeheadsystems.velocity.spi.model.FailureCode;
+import com.codeheadsystems.velocity.spi.model.Feature;
+import com.codeheadsystems.velocity.spi.model.FeatureResult;
+import com.codeheadsystems.velocity.spi.model.Intent.CountIntent;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import com.codeheadsystems.velocity.spi.model.QueryContext;
+import com.codeheadsystems.velocity.spi.model.QueryTuple;
+import com.codeheadsystems.velocity.spi.model.ReadYourWriteLevel;
+import com.codeheadsystems.velocity.spi.model.SeedAggregate;
+import com.codeheadsystems.velocity.spi.model.Subject;
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowBounds;
+import com.codeheadsystems.velocity.spi.model.WindowType;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Focused tests for backend logic beyond what the shared TCK scenarios exercise. */
+class InMemoryVelocityBackendTest {
+
+ private static final Instant FIXED = Instant.parse("2026-07-18T12:00:30Z");
+ private static final Namespace NS = new Namespace("ns");
+ private static final Subject SUBJECT = new Subject("card", "s1");
+ private static final Window SLIDING_1M = new Window(Duration.ofMinutes(1), WindowType.SLIDING);
+ private static final Window TUMBLING_1M = new Window(Duration.ofMinutes(1), WindowType.TUMBLING);
+ private static final Window UNSUPPORTED = new Window(Duration.ofDays(7), WindowType.SLIDING);
+
+ private MutableClock clock;
+ private InMemoryVelocityBackend backend;
+
+ @BeforeEach
+ void setUp() {
+ clock = new MutableClock(FIXED);
+ backend = new InMemoryVelocityBackend(clock);
+ }
+
+ @Test
+ void noArgConstructorUsesSystemUtc() {
+ assertThat(new InMemoryVelocityBackend().clock().getZone()).isEqualTo(ZoneOffset.UTC);
+ }
+
+ @Test
+ void capabilitiesDeclareTheReferenceProfile() {
+ final BackendCapabilities caps = backend.capabilities();
+ assertThat(caps.supportsAggregation(AggregationType.COUNT)).isTrue();
+ assertThat(caps.supportsAggregation(AggregationType.SUM)).isTrue();
+ assertThat(caps.supportsAggregation(AggregationType.DISTINCT)).isTrue();
+ assertThat(caps.windows()).hasSize(4);
+ assertThat(caps.distinctHllSliding()).isFalse();
+ assertThat(caps.readYourWriteLevel()).isEqualTo(ReadYourWriteLevel.ATOMIC);
+ assertThat(caps.idempotencySupported()).isFalse();
+ assertThat(caps.seedSupported()).isTrue();
+ assertThat(caps.maxAtomicFanOut()).isEqualTo(Integer.MAX_VALUE);
+ assertThat(caps.maxRetention()).isEqualTo(Duration.ofDays(30));
+ assertThat(caps.distinctExactCardinalityClamp()).isEqualTo(Long.MAX_VALUE);
+ }
+
+ @Test
+ void applyWithMixedWindowsFailsOnlyTheUnsupportedWindowButStillRecords() {
+ final Feature feature =
+ new Feature("mixed", Aggregation.count(), List.of(SLIDING_1M, UNSUPPORTED));
+ final ApplyResult result =
+ backend.applyCount(new ApplyContext(NS, null), List.of(new CountIntent(feature, SUBJECT)));
+
+ assertThat(result.perFeature()).hasSize(2);
+ final FeatureResult supported =
+ result.perFeature().stream()
+ .filter(pf -> pf.status() == ApplyStatus.APPLIED)
+ .findFirst()
+ .orElseThrow()
+ .result();
+ assertThat(((FeatureResult.Success) supported).value().value())
+ .isEqualByComparingTo(BigDecimal.ONE);
+
+ final FeatureResult failed =
+ result.perFeature().stream()
+ .filter(pf -> pf.status() == ApplyStatus.FAILED)
+ .findFirst()
+ .orElseThrow()
+ .result();
+ assertThat(((FeatureResult.Failure) failed).code()).isEqualTo(FailureCode.UNSUPPORTED_WINDOW);
+
+ // The supported window still recorded the underlying event.
+ assertThat(countOver(SLIDING_1M)).isEqualByComparingTo(BigDecimal.ONE);
+ }
+
+ @Test
+ void applyWithOnlyUnsupportedWindowsRecordsNothing() {
+ final Feature feature = new Feature("only-bad", Aggregation.count(), List.of(UNSUPPORTED));
+ final ApplyResult result =
+ backend.applyCount(new ApplyContext(NS, null), List.of(new CountIntent(feature, SUBJECT)));
+
+ assertThat(result.perFeature()).hasSize(1);
+ assertThat(result.perFeature().get(0).status()).isEqualTo(ApplyStatus.FAILED);
+ // Nothing was recorded, so a supported window still reads a known zero.
+ assertThat(countOver(SLIDING_1M)).isEqualByComparingTo(BigDecimal.ZERO);
+ }
+
+ @Test
+ void queryUnsupportedWindowIsFailureNotSilentZero() {
+ final FeatureResult result =
+ backend
+ .queryCount(
+ new QueryContext(NS, null),
+ List.of(new QueryTuple(SUBJECT, Aggregation.count(), UNSUPPORTED)))
+ .get(0);
+ assertThat(result.isSuccess()).isFalse();
+ assertThat(((FeatureResult.Failure) result).code()).isEqualTo(FailureCode.UNSUPPORTED_WINDOW);
+ }
+
+ @Test
+ void seedExactDistinctIsQueryable() {
+ final Feature feature =
+ new Feature("distinct", Aggregation.distinct("ip"), List.of(TUMBLING_1M));
+ final WindowBounds bucket = WindowMath.tumblingBucket(clock.instant(), 60_000);
+ backend.seed(
+ NS,
+ SUBJECT,
+ feature,
+ List.of(
+ new BucketValue(
+ bucket,
+ new SeedAggregate.ExactDistinct(List.of(member("a"), member("b"), member("a"))))));
+
+ final FeatureResult result =
+ backend
+ .queryDistinct(
+ new QueryContext(NS, null),
+ List.of(new QueryTuple(SUBJECT, Aggregation.distinct("ip"), TUMBLING_1M)))
+ .get(0);
+ assertThat(((FeatureResult.Success) result).value().value())
+ .isEqualByComparingTo(BigDecimal.valueOf(2));
+ }
+
+ @Test
+ void seedSumBucketIsQueryable() {
+ final Feature feature = new Feature("sum", Aggregation.sum(), List.of(TUMBLING_1M));
+ final WindowBounds bucket = WindowMath.tumblingBucket(clock.instant(), 60_000);
+ backend.seed(
+ NS,
+ SUBJECT,
+ feature,
+ List.of(new BucketValue(bucket, new SeedAggregate.SumValue(BigDecimal.valueOf(750)))));
+
+ final FeatureResult result =
+ backend
+ .querySum(
+ new QueryContext(NS, null),
+ List.of(new QueryTuple(SUBJECT, Aggregation.sum(), TUMBLING_1M)))
+ .get(0);
+ assertThat(((FeatureResult.Success) result).value().value())
+ .isEqualByComparingTo(BigDecimal.valueOf(750));
+ }
+
+ @Test
+ void purgeMissingScopeIsNoop() {
+ assertThatCode(() -> backend.purge(NS, SUBJECT)).doesNotThrowAnyException();
+ assertThatCode(() -> backend.purge(NS, null)).doesNotThrowAnyException();
+ }
+
+ private BigDecimal countOver(final Window window) {
+ final FeatureResult result =
+ backend
+ .queryCount(
+ new QueryContext(NS, null),
+ List.of(new QueryTuple(SUBJECT, Aggregation.count(), window)))
+ .get(0);
+ return ((FeatureResult.Success) result).value().value();
+ }
+
+ private static DistinctMember member(final String token) {
+ return new DistinctMember(token.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/MutableClockTest.java b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/MutableClockTest.java
new file mode 100644
index 0000000..bd395c0
--- /dev/null
+++ b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/MutableClockTest.java
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for the test-controllable {@link MutableClock}. */
+class MutableClockTest {
+
+ private static final Instant START = Instant.parse("2026-07-18T12:00:00Z");
+
+ @Test
+ void reportsTheStartInstantAndMillis() {
+ final MutableClock clock = new MutableClock(START);
+ assertThat(clock.instant()).isEqualTo(START);
+ assertThat(clock.millis()).isEqualTo(START.toEpochMilli());
+ assertThat(clock.getZone()).isEqualTo(ZoneOffset.UTC);
+ }
+
+ @Test
+ void advanceMovesForward() {
+ final MutableClock clock = new MutableClock(START);
+ clock.advance(Duration.ofSeconds(90));
+ assertThat(clock.instant()).isEqualTo(START.plusSeconds(90));
+ }
+
+ @Test
+ void setInstantReplacesTime() {
+ final MutableClock clock = new MutableClock(START);
+ clock.setInstant(START.minusSeconds(10));
+ assertThat(clock.instant()).isEqualTo(START.minusSeconds(10));
+ }
+
+ @Test
+ void withZoneKeepsInstantButChangesZone() {
+ final MutableClock clock = new MutableClock(START);
+ final Clock tokyo = clock.withZone(ZoneId.of("Asia/Tokyo"));
+ assertThat(tokyo.instant()).isEqualTo(START);
+ assertThat(tokyo.getZone()).isEqualTo(ZoneId.of("Asia/Tokyo"));
+ }
+
+ @Test
+ void atNowIsCloseToSystemTime() {
+ final long before = System.currentTimeMillis();
+ final MutableClock clock = MutableClock.atNow();
+ assertThat(clock.millis()).isGreaterThanOrEqualTo(before);
+ }
+}
diff --git a/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/WindowMathTest.java b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/WindowMathTest.java
new file mode 100644
index 0000000..a6d1824
--- /dev/null
+++ b/velocity-testkit/src/test/java/com/codeheadsystems/velocity/testkit/WindowMathTest.java
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.testkit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowBounds;
+import com.codeheadsystems.velocity.spi.model.WindowType;
+import java.time.Duration;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for the window-bounds arithmetic the backend relies on. */
+class WindowMathTest {
+
+ private static final Window SLIDING_5S = new Window(Duration.ofSeconds(5), WindowType.SLIDING);
+ private static final Window TUMBLING_1M = new Window(Duration.ofMinutes(1), WindowType.TUMBLING);
+
+ @Test
+ void slidingBoundsAreNowMinusDurationToNow() {
+ final Instant now = Instant.ofEpochMilli(100_000);
+ final WindowBounds bounds = WindowMath.boundsAt(SLIDING_5S, now);
+ assertThat(bounds.start()).isEqualTo(Instant.ofEpochMilli(95_000));
+ assertThat(bounds.end()).isEqualTo(now);
+ }
+
+ @Test
+ void tumblingBoundsAreTheAlignedBucket() {
+ final Instant now = Instant.ofEpochMilli(125_000);
+ final WindowBounds bounds = WindowMath.boundsAt(TUMBLING_1M, now);
+ assertThat(bounds.start()).isEqualTo(Instant.ofEpochMilli(120_000));
+ assertThat(bounds.end()).isEqualTo(Instant.ofEpochMilli(180_000));
+ }
+
+ @Test
+ void tumblingBucketAlignsToFloor() {
+ final WindowBounds bounds = WindowMath.tumblingBucket(Instant.ofEpochMilli(179_999), 60_000);
+ assertThat(bounds.start()).isEqualTo(Instant.ofEpochMilli(120_000));
+ assertThat(bounds.end()).isEqualTo(Instant.ofEpochMilli(180_000));
+ }
+
+ @Test
+ void tumblingBucketRejectsNonPositiveDuration() {
+ assertThatThrownBy(() -> WindowMath.tumblingBucket(Instant.EPOCH, 0))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void slidingContainsIsStartExclusiveEndInclusive() {
+ final Instant now = Instant.ofEpochMilli(100_000);
+ final WindowBounds bounds = WindowMath.boundsAt(SLIDING_5S, now);
+ assertThat(WindowMath.contains(SLIDING_5S, bounds, bounds.start()))
+ .isFalse(); // exclusive start
+ assertThat(WindowMath.contains(SLIDING_5S, bounds, Instant.ofEpochMilli(95_001))).isTrue();
+ assertThat(WindowMath.contains(SLIDING_5S, bounds, bounds.end())).isTrue(); // inclusive end
+ assertThat(WindowMath.contains(SLIDING_5S, bounds, Instant.ofEpochMilli(100_001))).isFalse();
+ assertThat(WindowMath.contains(SLIDING_5S, bounds, Instant.ofEpochMilli(94_999))).isFalse();
+ }
+
+ @Test
+ void tumblingContainsIsStartInclusiveEndExclusive() {
+ final Instant now = Instant.ofEpochMilli(125_000);
+ final WindowBounds bounds = WindowMath.boundsAt(TUMBLING_1M, now);
+ assertThat(WindowMath.contains(TUMBLING_1M, bounds, bounds.start()))
+ .isTrue(); // inclusive start
+ assertThat(WindowMath.contains(TUMBLING_1M, bounds, bounds.end())).isFalse(); // exclusive end
+ assertThat(WindowMath.contains(TUMBLING_1M, bounds, Instant.ofEpochMilli(179_999))).isTrue();
+ assertThat(WindowMath.contains(TUMBLING_1M, bounds, Instant.ofEpochMilli(119_999))).isFalse();
+ }
+}
+ *
+ */
+public final class WindowMath {
+
+ private WindowMath() {}
+
+ /**
+ * The concrete {@link WindowBounds} a value over {@code window} covers as of {@code now}.
+ *
+ * @param window the window (duration + type)
+ * @param now the backend-clock instant the read is as-of
+ * @return the reported bounds for the window at {@code now}
+ */
+ public static WindowBounds boundsAt(final Window window, final Instant now) {
+ Objects.requireNonNull(window, "window");
+ Objects.requireNonNull(now, "now");
+ return switch (window.type()) {
+ case SLIDING -> new WindowBounds(now.minus(window.duration()), now);
+ case TUMBLING -> tumblingBucket(now, window.duration().toMillis());
+ };
+ }
+
+ /**
+ * The aligned tumbling bucket {@code [floor(nowMs/durationMs)·durationMs, +durationMs)} covering
+ * {@code now}.
+ *
+ * @param now the instant to align
+ * @param durationMs the bucket width in milliseconds; must be strictly positive
+ * @return the bucket's bounds
+ */
+ public static WindowBounds tumblingBucket(final Instant now, final long durationMs) {
+ Objects.requireNonNull(now, "now");
+ if (durationMs <= 0) {
+ throw new IllegalArgumentException("durationMs must be positive, was " + durationMs);
+ }
+ final long nowMs = now.toEpochMilli();
+ final long startMs = Math.floorDiv(nowMs, durationMs) * durationMs;
+ return new WindowBounds(
+ Instant.ofEpochMilli(startMs), Instant.ofEpochMilli(startMs + durationMs));
+ }
+
+ /**
+ * Whether an event at {@code timestamp} falls inside {@code window} whose reported {@code bounds}
+ * were computed by {@link #boundsAt}. Sliding is {@code (start, end]}; tumbling is {@code [start,
+ * end)}.
+ *
+ * @param window the window whose membership rule to apply
+ * @param bounds the bounds produced by {@link #boundsAt} for the same read
+ * @param timestamp the event timestamp to test
+ * @return {@code true} if the event contributes to the window's value
+ */
+ public static boolean contains(
+ final Window window, final WindowBounds bounds, final Instant timestamp) {
+ Objects.requireNonNull(window, "window");
+ Objects.requireNonNull(bounds, "bounds");
+ Objects.requireNonNull(timestamp, "timestamp");
+ return switch (window.type()) {
+ case SLIDING -> timestamp.isAfter(bounds.start()) && !timestamp.isAfter(bounds.end());
+ case TUMBLING -> !timestamp.isBefore(bounds.start()) && timestamp.isBefore(bounds.end());
+ };
+ }
+}
diff --git a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/package-info.java b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/package-info.java
index e47bc79..f0f1f22 100644
--- a/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/package-info.java
+++ b/velocity-testkit/src/main/java/com/codeheadsystems/velocity/testkit/package-info.java
@@ -1,6 +1,16 @@
// SPDX-License-Identifier: BSD-3-Clause
/**
- * The velocity-testkit: in-memory velocity-spi backend, conformance TCK scenarios, and fixtures.
+ * The velocity-testkit: the in-memory reference {@code velocity-spi} backend and its time helpers.
+ *
+ *