From ebb20c58ad3bf25355119c4b690c76961fbbfcce Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Fri, 28 Aug 2026 22:39:19 +0800
Subject: [PATCH 01/23] feat(ai): add half-open time interval model
---
.../ai/temporal/model/TimeInterval.java | 137 +++++++++++++++++
.../ai/temporal/model/TimeIntervalTest.java | 139 ++++++++++++++++++
2 files changed, 276 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/TimeInterval.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/TimeIntervalTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/TimeInterval.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/TimeInterval.java
new file mode 100644
index 000000000..bc372ca14
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/TimeInterval.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An immutable half-open time interval.
+ *
+ *
The start is inclusive and the end is exclusive. A null internal end
+ * represents positive infinity.
+ */
+public final class TimeInterval {
+
+ private final Instant start;
+ private final Instant end;
+
+ public TimeInterval(Instant start, Instant end) {
+ this.start = Objects.requireNonNull(start, "start");
+ if (end != null && !start.isBefore(end)) {
+ throw new IllegalArgumentException(
+ "Interval start must be earlier than end");
+ }
+ this.end = end;
+ }
+
+ public static TimeInterval unboundedFrom(Instant start) {
+ return new TimeInterval(start, null);
+ }
+
+ public Instant getStart() {
+ return start;
+ }
+
+ public Optional getEnd() {
+ return Optional.ofNullable(end);
+ }
+
+ public boolean contains(Instant time) {
+ Objects.requireNonNull(time, "time");
+ return !time.isBefore(start) && (end == null || time.isBefore(end));
+ }
+
+ public boolean overlaps(TimeInterval other) {
+ return intersection(other).isPresent();
+ }
+
+ public Optional intersection(TimeInterval other) {
+ Objects.requireNonNull(other, "other");
+
+ Instant intersectionStart =
+ start.isAfter(other.start) ? start : other.start;
+ Instant intersectionEnd = earliestEnd(end, other.end);
+
+ if (intersectionEnd != null
+ && !intersectionStart.isBefore(intersectionEnd)) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new TimeInterval(intersectionStart, intersectionEnd));
+ }
+
+ public List subtract(TimeInterval other) {
+ Optional intersection = intersection(other);
+ if (!intersection.isPresent()) {
+ return Collections.singletonList(this);
+ }
+
+ TimeInterval overlap = intersection.get();
+ List remaining = new ArrayList<>(2);
+
+ if (start.isBefore(overlap.start)) {
+ remaining.add(new TimeInterval(start, overlap.start));
+ }
+
+ if (overlap.end != null
+ && (end == null || overlap.end.isBefore(end))) {
+ remaining.add(new TimeInterval(overlap.end, end));
+ }
+
+ return remaining;
+ }
+
+ private static Instant earliestEnd(Instant left, Instant right) {
+ if (left == null) {
+ return right;
+ }
+ if (right == null) {
+ return left;
+ }
+ return left.isBefore(right) ? left : right;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof TimeInterval)) {
+ return false;
+ }
+ TimeInterval that = (TimeInterval) object;
+ return start.equals(that.start) && Objects.equals(end, that.end);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(start, end);
+ }
+
+ @Override
+ public String toString() {
+ return "[" + start + ", " + (end == null ? "infinity" : end) + ")";
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/TimeIntervalTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/TimeIntervalTest.java
new file mode 100644
index 000000000..e0efd94de
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/TimeIntervalTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TimeIntervalTest {
+
+ @Test
+ public void testContainsUsesHalfOpenBounds() {
+ TimeInterval interval = interval(
+ "2024-01-01T00:00:00Z",
+ "2025-01-01T00:00:00Z");
+
+ Assertions.assertTrue(interval.contains(time("2024-01-01T00:00:00Z")));
+ Assertions.assertTrue(interval.contains(time("2024-06-01T00:00:00Z")));
+ Assertions.assertFalse(interval.contains(time("2025-01-01T00:00:00Z")));
+ Assertions.assertFalse(interval.contains(time("2023-12-31T23:59:59Z")));
+ }
+
+ @Test
+ public void testUnboundedInterval() {
+ TimeInterval interval =
+ TimeInterval.unboundedFrom(time("2024-01-01T00:00:00Z"));
+
+ Assertions.assertFalse(interval.getEnd().isPresent());
+ Assertions.assertTrue(interval.contains(time("2099-01-01T00:00:00Z")));
+ Assertions.assertFalse(interval.contains(time("2023-01-01T00:00:00Z")));
+ }
+
+ @Test
+ public void testIntersectionAndOverlap() {
+ TimeInterval left = interval(
+ "2024-01-01T00:00:00Z",
+ "2024-10-01T00:00:00Z");
+ TimeInterval right = interval(
+ "2024-05-01T00:00:00Z",
+ "2025-01-01T00:00:00Z");
+ TimeInterval touching = interval(
+ "2024-10-01T00:00:00Z",
+ "2025-01-01T00:00:00Z");
+
+ Assertions.assertTrue(left.overlaps(right));
+ Assertions.assertEquals(
+ interval("2024-05-01T00:00:00Z", "2024-10-01T00:00:00Z"),
+ left.intersection(right).get());
+
+ Assertions.assertFalse(left.overlaps(touching));
+ Assertions.assertFalse(left.intersection(touching).isPresent());
+ }
+
+ @Test
+ public void testSubtract() {
+ TimeInterval whole = interval(
+ "2024-01-01T00:00:00Z",
+ "2025-01-01T00:00:00Z");
+ TimeInterval middle = interval(
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z");
+
+ Assertions.assertEquals(
+ Arrays.asList(
+ interval("2024-01-01T00:00:00Z", "2024-04-01T00:00:00Z"),
+ interval("2024-09-01T00:00:00Z", "2025-01-01T00:00:00Z")),
+ whole.subtract(middle));
+
+ Assertions.assertEquals(
+ Collections.singletonList(whole),
+ whole.subtract(interval(
+ "2025-01-01T00:00:00Z",
+ "2026-01-01T00:00:00Z")));
+
+ Assertions.assertTrue(whole.subtract(whole).isEmpty());
+ }
+
+ @Test
+ public void testRejectInvalidInterval() {
+ Instant start = time("2024-01-01T00:00:00Z");
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new TimeInterval(null, start));
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new TimeInterval(start, start));
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new TimeInterval(
+ time("2025-01-01T00:00:00Z"),
+ time("2024-01-01T00:00:00Z")));
+ }
+
+ @Test
+ public void testSubtractFromUnboundedInterval() {
+ TimeInterval whole =
+ TimeInterval.unboundedFrom(time("2024-01-01T00:00:00Z"));
+ TimeInterval removed = interval(
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z");
+
+ Assertions.assertEquals(
+ Arrays.asList(
+ interval("2024-01-01T00:00:00Z", "2024-04-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(time("2024-09-01T00:00:00Z"))),
+ whole.subtract(removed));
+ }
+
+ private static TimeInterval interval(String start, String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From 9cb184d8e4e4d9c6550f17479d6fa0acfb73848c Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 13:31:39 +0800
Subject: [PATCH 02/23] feat(ai): add provenance models
---
.../geaflow/ai/temporal/model/Evidence.java | 78 ++++++++++++++++
.../geaflow/ai/temporal/model/Source.java | 70 +++++++++++++++
.../temporal/model/ProvenanceModelTest.java | 89 +++++++++++++++++++
3 files changed, 237 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Evidence.java
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Source.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/ProvenanceModelTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Evidence.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Evidence.java
new file mode 100644
index 000000000..998137fda
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Evidence.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.util.Objects;
+
+/**
+ * An immutable piece of evidence and its source.
+ */
+public final class Evidence {
+
+ private final String id;
+ private final Source source;
+ private final String content;
+
+ public Evidence(String id, Source source, String content) {
+ this.id = requireText(id, "id");
+ this.source = Objects.requireNonNull(source, "source");
+ this.content = requireText(content, "content");
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public Source getSource() {
+ return source;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ private static String requireText(String value, String fieldName) {
+ Objects.requireNonNull(value, fieldName);
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Evidence " + fieldName + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof Evidence)) {
+ return false;
+ }
+ Evidence that = (Evidence) object;
+ return id.equals(that.id)
+ && source.equals(that.source)
+ && content.equals(that.content);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, source, content);
+ }
+}
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Source.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Source.java
new file mode 100644
index 000000000..c73576f86
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/Source.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.util.Objects;
+
+/**
+ * An immutable description of an evidence source.
+ */
+public final class Source {
+
+ private final String id;
+ private final String name;
+
+ public Source(String id, String name) {
+ this.id = requireText(id, "id");
+ this.name = requireText(name, "name");
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ private static String requireText(String value, String fieldName) {
+ Objects.requireNonNull(value, fieldName);
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Source " + fieldName + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof Source)) {
+ return false;
+ }
+ Source that = (Source) object;
+ return id.equals(that.id) && name.equals(that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name);
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/ProvenanceModelTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/ProvenanceModelTest.java
new file mode 100644
index 000000000..a233690e9
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/ProvenanceModelTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class ProvenanceModelTest {
+
+ @Test
+ public void testSourceValueSemantics() {
+ Source source = new Source("source-1", "customer-database");
+ Source same = new Source("source-1", "customer-database");
+
+ Assertions.assertEquals("source-1", source.getId());
+ Assertions.assertEquals("customer-database", source.getName());
+ Assertions.assertEquals(source, same);
+ Assertions.assertEquals(source.hashCode(), same.hashCode());
+ Assertions.assertNotEquals(
+ source,
+ new Source("source-1", "archive-database"));
+ }
+
+ @Test
+ public void testEvidenceValueSemantics() {
+ Source source = new Source("source-1", "customer-database");
+ Evidence evidence = new Evidence(
+ "evidence-1",
+ source,
+ "Alice works at Acme");
+ Evidence same = new Evidence(
+ "evidence-1",
+ new Source("source-1", "customer-database"),
+ "Alice works at Acme");
+
+ Assertions.assertEquals("evidence-1", evidence.getId());
+ Assertions.assertEquals(source, evidence.getSource());
+ Assertions.assertEquals(
+ "Alice works at Acme",
+ evidence.getContent());
+ Assertions.assertEquals(evidence, same);
+ Assertions.assertEquals(evidence.hashCode(), same.hashCode());
+ Assertions.assertNotEquals(
+ evidence,
+ new Evidence("evidence-1", source, "Alice left Acme"));
+ }
+
+ @Test
+ public void testRejectInvalidProvenance() {
+ Source source = new Source("source-1", "customer-database");
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new Source(null, "customer-database"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new Source(" ", "customer-database"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new Source("source-1", " "));
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new Evidence(" ", source, "Alice works at Acme"));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new Evidence("evidence-1", null, "Alice works at Acme"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new Evidence("evidence-1", source, " "));
+ }
+}
From 1f641b2733ee0c675b4f871616e5fdd90744a6ab Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 13:43:29 +0800
Subject: [PATCH 03/23] feat(ai): add temporal memory entity model
---
.../ai/temporal/model/MemoryEntity.java | 70 +++++++++++++++++++
.../ai/temporal/model/MemoryEntityTest.java | 62 ++++++++++++++++
2 files changed, 132 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEntity.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEntityTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEntity.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEntity.java
new file mode 100644
index 000000000..87ce52a8c
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEntity.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.util.Objects;
+
+/**
+ * An immutable identity anchor for temporal memory facts.
+ */
+public final class MemoryEntity {
+
+ private final String id;
+ private final String label;
+
+ public MemoryEntity(String id, String label) {
+ this.id = requireText(id, "id");
+ this.label = requireText(label, "label");
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ private static String requireText(String value, String fieldName) {
+ Objects.requireNonNull(value, fieldName);
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Memory entity " + fieldName + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof MemoryEntity)) {
+ return false;
+ }
+ MemoryEntity that = (MemoryEntity) object;
+ return id.equals(that.id) && label.equals(that.label);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, label);
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEntityTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEntityTest.java
new file mode 100644
index 000000000..61a54fb7c
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEntityTest.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class MemoryEntityTest {
+
+ @Test
+ public void testValueSemantics() {
+ MemoryEntity entity =
+ new MemoryEntity("person:alice", "person");
+ MemoryEntity same =
+ new MemoryEntity("person:alice", "person");
+
+ Assertions.assertEquals("person:alice", entity.getId());
+ Assertions.assertEquals("person", entity.getLabel());
+ Assertions.assertEquals(entity, same);
+ Assertions.assertEquals(entity.hashCode(), same.hashCode());
+
+ Assertions.assertNotEquals(
+ entity,
+ new MemoryEntity("person:bob", "person"));
+ Assertions.assertNotEquals(
+ entity,
+ new MemoryEntity("person:alice", "company"));
+ }
+
+ @Test
+ public void testRejectInvalidEntity() {
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryEntity(null, "person"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new MemoryEntity(" ", "person"));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryEntity("person:alice", null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new MemoryEntity("person:alice", " "));
+ }
+}
From 2c4214a9acd7079f839ed28e76db25a2b121cb4b Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 16:46:11 +0800
Subject: [PATCH 04/23] feat(ai): add structured memory fact model
---
.../geaflow/ai/temporal/model/MemoryFact.java | 133 +++++++++++++++++
.../ai/temporal/model/MemoryFactTest.java | 141 ++++++++++++++++++
2 files changed, 274 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFact.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFact.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFact.java
new file mode 100644
index 000000000..23ead3e59
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFact.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An immutable structured assertion about a memory entity.
+ */
+public final class MemoryFact {
+
+ private final String id;
+ private final MemoryEntity subject;
+ private final String predicate;
+ private final String literalValue;
+ private final MemoryEntity target;
+
+ private MemoryFact(
+ String id,
+ MemoryEntity subject,
+ String predicate,
+ String literalValue,
+ MemoryEntity target) {
+ this.id = requireText(id, "id");
+ this.subject = Objects.requireNonNull(subject, "subject");
+ this.predicate = requireText(predicate, "predicate");
+ this.literalValue = literalValue;
+ this.target = target;
+ }
+
+ public static MemoryFact attribute(
+ String id,
+ MemoryEntity subject,
+ String predicate,
+ String literalValue) {
+ return new MemoryFact(
+ id,
+ subject,
+ predicate,
+ requireText(literalValue, "literal value"),
+ null);
+ }
+
+ public static MemoryFact relationship(
+ String id,
+ MemoryEntity subject,
+ String predicate,
+ MemoryEntity target) {
+ return new MemoryFact(
+ id,
+ subject,
+ predicate,
+ null,
+ Objects.requireNonNull(target, "target"));
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public MemoryEntity getSubject() {
+ return subject;
+ }
+
+ public String getPredicate() {
+ return predicate;
+ }
+
+ public Optional getLiteralValue() {
+ return Optional.ofNullable(literalValue);
+ }
+
+ public Optional getTarget() {
+ return Optional.ofNullable(target);
+ }
+
+ public boolean isRelationship() {
+ return target != null;
+ }
+
+ private static String requireText(String value, String fieldName) {
+ Objects.requireNonNull(value, fieldName);
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Memory fact " + fieldName + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof MemoryFact)) {
+ return false;
+ }
+ MemoryFact that = (MemoryFact) object;
+ return id.equals(that.id)
+ && subject.equals(that.subject)
+ && predicate.equals(that.predicate)
+ && Objects.equals(literalValue, that.literalValue)
+ && Objects.equals(target, that.target);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ id,
+ subject,
+ predicate,
+ literalValue,
+ target);
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactTest.java
new file mode 100644
index 000000000..0e170d372
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class MemoryFactTest {
+
+ @Test
+ public void testAttributeFact() {
+ MemoryEntity alice =
+ new MemoryEntity("person:alice", "person");
+ MemoryFact fact = MemoryFact.attribute(
+ "fact-name-alice",
+ alice,
+ "name",
+ "Alice");
+ MemoryFact same = MemoryFact.attribute(
+ "fact-name-alice",
+ new MemoryEntity("person:alice", "person"),
+ "name",
+ "Alice");
+
+ Assertions.assertEquals("fact-name-alice", fact.getId());
+ Assertions.assertEquals(alice, fact.getSubject());
+ Assertions.assertEquals("name", fact.getPredicate());
+ Assertions.assertFalse(fact.isRelationship());
+ Assertions.assertEquals(
+ "Alice",
+ fact.getLiteralValue().get());
+ Assertions.assertFalse(fact.getTarget().isPresent());
+ Assertions.assertEquals(fact, same);
+ Assertions.assertEquals(fact.hashCode(), same.hashCode());
+
+ Assertions.assertNotEquals(
+ fact,
+ MemoryFact.attribute(
+ "fact-name-alice",
+ alice,
+ "name",
+ "Alice Smith"));
+ }
+
+ @Test
+ public void testRelationshipFact() {
+ MemoryEntity alice =
+ new MemoryEntity("person:alice", "person");
+ MemoryEntity acme =
+ new MemoryEntity("company:acme", "company");
+ MemoryFact fact = MemoryFact.relationship(
+ "fact-alice-acme",
+ alice,
+ "worksAt",
+ acme);
+ MemoryFact same = MemoryFact.relationship(
+ "fact-alice-acme",
+ new MemoryEntity("person:alice", "person"),
+ "worksAt",
+ new MemoryEntity("company:acme", "company"));
+
+ Assertions.assertTrue(fact.isRelationship());
+ Assertions.assertFalse(fact.getLiteralValue().isPresent());
+ Assertions.assertEquals(acme, fact.getTarget().get());
+ Assertions.assertEquals(fact, same);
+ Assertions.assertEquals(fact.hashCode(), same.hashCode());
+
+ Assertions.assertNotEquals(
+ fact,
+ MemoryFact.relationship(
+ "fact-alice-acme",
+ alice,
+ "worksAt",
+ new MemoryEntity("company:other", "company")));
+ }
+
+ @Test
+ public void testRejectInvalidFact() {
+ MemoryEntity alice =
+ new MemoryEntity("person:alice", "person");
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryFact.attribute(null, alice, "name", "Alice"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> MemoryFact.attribute(" ", alice, "name", "Alice"));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryFact.attribute(
+ "fact-name-alice",
+ null,
+ "name",
+ "Alice"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> MemoryFact.attribute(
+ "fact-name-alice",
+ alice,
+ " ",
+ "Alice"));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryFact.attribute(
+ "fact-name-alice",
+ alice,
+ "name",
+ null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> MemoryFact.attribute(
+ "fact-name-alice",
+ alice,
+ "name",
+ " "));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryFact.relationship(
+ "fact-alice-acme",
+ alice,
+ "worksAt",
+ null));
+ }
+}
From 53101f9a5d2b0e408fdf9fd5b2a5f789a44bf2e8 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 16:56:38 +0800
Subject: [PATCH 05/23] feat(ai): add bitemporal fact version model
---
.../ai/temporal/model/MemoryFactVersion.java | 116 ++++++++
.../temporal/model/MemoryFactVersionTest.java | 251 ++++++++++++++++++
2 files changed, 367 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersion.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersion.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersion.java
new file mode 100644
index 000000000..abce2a969
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersion.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * An immutable bitemporal version of a memory fact.
+ */
+public final class MemoryFactVersion {
+
+ private final String id;
+ private final MemoryFact fact;
+ private final TimeInterval validTime;
+ private final TimeInterval transactionTime;
+ private final List evidence;
+
+ public MemoryFactVersion(
+ String id,
+ MemoryFact fact,
+ TimeInterval validTime,
+ TimeInterval transactionTime,
+ List evidence) {
+ this.id = requireText(id);
+ this.fact = Objects.requireNonNull(fact, "fact");
+ this.validTime = Objects.requireNonNull(validTime, "validTime");
+ this.transactionTime =
+ Objects.requireNonNull(transactionTime, "transactionTime");
+
+ List evidenceCopy =
+ new ArrayList<>(Objects.requireNonNull(evidence, "evidence"));
+ if (evidenceCopy.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Memory fact version evidence must not be empty");
+ }
+ for (Evidence item : evidenceCopy) {
+ Objects.requireNonNull(item, "evidence item");
+ }
+ this.evidence = Collections.unmodifiableList(evidenceCopy);
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public MemoryFact getFact() {
+ return fact;
+ }
+
+ public TimeInterval getValidTime() {
+ return validTime;
+ }
+
+ public TimeInterval getTransactionTime() {
+ return transactionTime;
+ }
+
+ public List getEvidence() {
+ return evidence;
+ }
+
+ private static String requireText(String value) {
+ Objects.requireNonNull(value, "id");
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Memory fact version id must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof MemoryFactVersion)) {
+ return false;
+ }
+ MemoryFactVersion that = (MemoryFactVersion) object;
+ return id.equals(that.id)
+ && fact.equals(that.fact)
+ && validTime.equals(that.validTime)
+ && transactionTime.equals(that.transactionTime)
+ && evidence.equals(that.evidence);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ id,
+ fact,
+ validTime,
+ transactionTime,
+ evidence);
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionTest.java
new file mode 100644
index 000000000..f01ea73f3
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionTest.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class MemoryFactVersionTest {
+
+ @Test
+ public void testBitemporalVersion() {
+ MemoryFact fact = fact("Alice");
+ TimeInterval validTime = TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z"));
+ TimeInterval transactionTime = interval(
+ "2024-03-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ Evidence evidence = evidence(
+ "evidence-1",
+ "Alice is the recorded name");
+
+ MemoryFactVersion version = new MemoryFactVersion(
+ "version-1",
+ fact,
+ validTime,
+ transactionTime,
+ Collections.singletonList(evidence));
+
+ Assertions.assertEquals("version-1", version.getId());
+ Assertions.assertEquals(fact, version.getFact());
+ Assertions.assertEquals(validTime, version.getValidTime());
+ Assertions.assertEquals(
+ transactionTime,
+ version.getTransactionTime());
+ Assertions.assertEquals(
+ Collections.singletonList(evidence),
+ version.getEvidence());
+ }
+
+ @Test
+ public void testValueSemantics() {
+ MemoryFactVersion version = new MemoryFactVersion(
+ "version-1",
+ fact("Alice"),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2025-01-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-03-01T00:00:00Z")),
+ Collections.singletonList(evidence(
+ "evidence-1",
+ "Alice is the recorded name")));
+
+ MemoryFactVersion same = new MemoryFactVersion(
+ "version-1",
+ fact("Alice"),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2025-01-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-03-01T00:00:00Z")),
+ Collections.singletonList(evidence(
+ "evidence-1",
+ "Alice is the recorded name")));
+
+ Assertions.assertEquals(version, same);
+ Assertions.assertEquals(version.hashCode(), same.hashCode());
+
+ Assertions.assertNotEquals(
+ version,
+ new MemoryFactVersion(
+ "version-1",
+ fact("Alice Smith"),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2025-01-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-03-01T00:00:00Z")),
+ Collections.singletonList(evidence(
+ "evidence-1",
+ "Alice is the recorded name"))));
+
+ Assertions.assertNotEquals(
+ version,
+ new MemoryFactVersion(
+ "version-1",
+ fact("Alice"),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2025-01-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-04-01T00:00:00Z")),
+ Collections.singletonList(evidence(
+ "evidence-1",
+ "Alice is the recorded name"))));
+ }
+
+ @Test
+ public void testEvidenceIsDefensivelyCopied() {
+ Evidence first = evidence(
+ "evidence-1",
+ "Alice is the recorded name");
+ List evidence = new ArrayList<>();
+ evidence.add(first);
+
+ MemoryFactVersion version = new MemoryFactVersion(
+ "version-1",
+ fact("Alice"),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ TimeInterval.unboundedFrom(
+ time("2024-03-01T00:00:00Z")),
+ evidence);
+
+ evidence.add(evidence(
+ "evidence-2",
+ "A later independent record"));
+
+ Assertions.assertEquals(
+ Collections.singletonList(first),
+ version.getEvidence());
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> version.getEvidence().clear());
+ }
+
+ @Test
+ public void testRejectInvalidVersion() {
+ MemoryFact fact = fact("Alice");
+ TimeInterval validTime = TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z"));
+ TimeInterval transactionTime = TimeInterval.unboundedFrom(
+ time("2024-03-01T00:00:00Z"));
+ List evidence = Collections.singletonList(
+ evidence("evidence-1", "Alice is the recorded name"));
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryFactVersion(
+ null,
+ fact,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new MemoryFactVersion(
+ " ",
+ fact,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryFactVersion(
+ "version-1",
+ null,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryFactVersion(
+ "version-1",
+ fact,
+ null,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryFactVersion(
+ "version-1",
+ fact,
+ validTime,
+ null,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryFactVersion(
+ "version-1",
+ fact,
+ validTime,
+ transactionTime,
+ null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new MemoryFactVersion(
+ "version-1",
+ fact,
+ validTime,
+ transactionTime,
+ Collections.emptyList()));
+
+ List evidenceWithNull = new ArrayList<>();
+ evidenceWithNull.add(null);
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new MemoryFactVersion(
+ "version-1",
+ fact,
+ validTime,
+ transactionTime,
+ evidenceWithNull));
+ }
+
+ private static MemoryFact fact(String literalValue) {
+ return MemoryFact.attribute(
+ "fact-name-alice",
+ new MemoryEntity("person:alice", "person"),
+ "name",
+ literalValue);
+ }
+
+ private static Evidence evidence(String id, String content) {
+ return new Evidence(
+ id,
+ new Source("source-1", "customer-database"),
+ content);
+ }
+
+ private static TimeInterval interval(String start, String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From 116774811aa9314b9d713933d49b1c9eeb3a37c9 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 17:30:22 +0800
Subject: [PATCH 06/23] feat(ai): add temporal memory event model
---
.../ai/temporal/model/MemoryEvent.java | 194 +++++++++++++
.../temporal/model/MemoryEventOperation.java | 30 ++
.../ai/temporal/model/MemoryEventTest.java | 272 ++++++++++++++++++
3 files changed, 496 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEvent.java
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEventOperation.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEventTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEvent.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEvent.java
new file mode 100644
index 000000000..122cee8a3
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEvent.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An immutable input event for temporal memory replay.
+ */
+public final class MemoryEvent {
+
+ private final String id;
+ private final MemoryEventOperation operation;
+ private final String factId;
+ private final MemoryFact fact;
+ private final TimeInterval validTime;
+ private final Instant transactionTime;
+ private final List evidence;
+
+ private MemoryEvent(
+ String id,
+ MemoryEventOperation operation,
+ String factId,
+ MemoryFact fact,
+ TimeInterval validTime,
+ Instant transactionTime,
+ List evidence) {
+ this.id = requireText(id, "id");
+ this.operation =
+ Objects.requireNonNull(operation, "operation");
+ this.factId = requireText(factId, "fact id");
+ this.fact = fact;
+ this.validTime =
+ Objects.requireNonNull(validTime, "validTime");
+ this.transactionTime =
+ Objects.requireNonNull(transactionTime, "transactionTime");
+ this.evidence = copyEvidence(evidence);
+ }
+
+ public static MemoryEvent add(
+ String id,
+ MemoryFact fact,
+ TimeInterval validTime,
+ Instant transactionTime,
+ List evidence) {
+ Objects.requireNonNull(fact, "fact");
+ return new MemoryEvent(
+ id,
+ MemoryEventOperation.ADD,
+ fact.getId(),
+ fact,
+ validTime,
+ transactionTime,
+ evidence);
+ }
+
+ public static MemoryEvent correct(
+ String id,
+ MemoryFact fact,
+ TimeInterval validTime,
+ Instant transactionTime,
+ List evidence) {
+ Objects.requireNonNull(fact, "fact");
+ return new MemoryEvent(
+ id,
+ MemoryEventOperation.CORRECT,
+ fact.getId(),
+ fact,
+ validTime,
+ transactionTime,
+ evidence);
+ }
+
+ public static MemoryEvent retract(
+ String id,
+ String factId,
+ TimeInterval validTime,
+ Instant transactionTime,
+ List evidence) {
+ return new MemoryEvent(
+ id,
+ MemoryEventOperation.RETRACT,
+ factId,
+ null,
+ validTime,
+ transactionTime,
+ evidence);
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public MemoryEventOperation getOperation() {
+ return operation;
+ }
+
+ public String getFactId() {
+ return factId;
+ }
+
+ public Optional getFact() {
+ return Optional.ofNullable(fact);
+ }
+
+ public TimeInterval getValidTime() {
+ return validTime;
+ }
+
+ public Instant getTransactionTime() {
+ return transactionTime;
+ }
+
+ public List getEvidence() {
+ return evidence;
+ }
+
+ private static List copyEvidence(
+ List evidence) {
+ List copy =
+ new ArrayList<>(Objects.requireNonNull(evidence, "evidence"));
+ if (copy.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Memory event evidence must not be empty");
+ }
+ for (Evidence item : copy) {
+ Objects.requireNonNull(item, "evidence item");
+ }
+ return Collections.unmodifiableList(copy);
+ }
+
+ private static String requireText(
+ String value,
+ String fieldName) {
+ Objects.requireNonNull(value, fieldName);
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Memory event " + fieldName + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (!(object instanceof MemoryEvent)) {
+ return false;
+ }
+ MemoryEvent that = (MemoryEvent) object;
+ return id.equals(that.id)
+ && operation == that.operation
+ && factId.equals(that.factId)
+ && Objects.equals(fact, that.fact)
+ && validTime.equals(that.validTime)
+ && transactionTime.equals(that.transactionTime)
+ && evidence.equals(that.evidence);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ id,
+ operation,
+ factId,
+ fact,
+ validTime,
+ transactionTime,
+ evidence);
+ }
+}
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEventOperation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEventOperation.java
new file mode 100644
index 000000000..7e5d63460
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEventOperation.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+/**
+ * Supported operations for temporal memory events.
+ */
+public enum MemoryEventOperation {
+
+ ADD,
+ CORRECT,
+ RETRACT
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEventTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEventTest.java
new file mode 100644
index 000000000..7c4c32967
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryEventTest.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.model;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class MemoryEventTest {
+
+ @Test
+ public void testAddLateEvent() {
+ MemoryFact fact = fact("Alice");
+ TimeInterval validTime = TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z"));
+ Instant transactionTime =
+ time("2024-03-01T00:00:00Z");
+ List evidence = evidenceList();
+
+ MemoryEvent event = MemoryEvent.add(
+ "event-1",
+ fact,
+ validTime,
+ transactionTime,
+ evidence);
+
+ Assertions.assertEquals("event-1", event.getId());
+ Assertions.assertEquals(
+ MemoryEventOperation.ADD,
+ event.getOperation());
+ Assertions.assertEquals("fact-name-alice", event.getFactId());
+ Assertions.assertEquals(fact, event.getFact().get());
+ Assertions.assertEquals(validTime, event.getValidTime());
+ Assertions.assertEquals(
+ transactionTime,
+ event.getTransactionTime());
+ Assertions.assertEquals(evidence, event.getEvidence());
+ Assertions.assertTrue(
+ event.getValidTime().getStart()
+ .isBefore(event.getTransactionTime()));
+ }
+
+ @Test
+ public void testCorrectEvent() {
+ MemoryFact corrected = fact("Alice Smith");
+
+ MemoryEvent event = MemoryEvent.correct(
+ "event-2",
+ corrected,
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time("2024-06-01T00:00:00Z"),
+ evidenceList());
+
+ Assertions.assertEquals(
+ MemoryEventOperation.CORRECT,
+ event.getOperation());
+ Assertions.assertEquals("fact-name-alice", event.getFactId());
+ Assertions.assertEquals(corrected, event.getFact().get());
+ }
+
+ @Test
+ public void testRetractEvent() {
+ TimeInterval validTime = TimeInterval.unboundedFrom(
+ time("2025-01-01T00:00:00Z"));
+
+ MemoryEvent event = MemoryEvent.retract(
+ "event-3",
+ "fact-name-alice",
+ validTime,
+ time("2025-02-01T00:00:00Z"),
+ evidenceList());
+
+ Assertions.assertEquals(
+ MemoryEventOperation.RETRACT,
+ event.getOperation());
+ Assertions.assertEquals("fact-name-alice", event.getFactId());
+ Assertions.assertFalse(event.getFact().isPresent());
+ Assertions.assertEquals(validTime, event.getValidTime());
+ }
+
+ @Test
+ public void testValueSemanticsAndEvidenceCopy() {
+ Evidence first = evidence(
+ "evidence-1",
+ "Alice is the recorded name");
+ List mutableEvidence = new ArrayList<>();
+ mutableEvidence.add(first);
+
+ MemoryEvent event = MemoryEvent.add(
+ "event-1",
+ fact("Alice"),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time("2024-03-01T00:00:00Z"),
+ mutableEvidence);
+
+ MemoryEvent same = MemoryEvent.add(
+ "event-1",
+ fact("Alice"),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time("2024-03-01T00:00:00Z"),
+ Collections.singletonList(evidence(
+ "evidence-1",
+ "Alice is the recorded name")));
+
+ mutableEvidence.add(evidence(
+ "evidence-2",
+ "An independent record"));
+
+ Assertions.assertEquals(
+ Collections.singletonList(first),
+ event.getEvidence());
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> event.getEvidence().clear());
+ Assertions.assertEquals(event, same);
+ Assertions.assertEquals(event.hashCode(), same.hashCode());
+
+ Assertions.assertNotEquals(
+ event,
+ MemoryEvent.add(
+ "event-1",
+ fact("Alice Smith"),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time("2024-03-01T00:00:00Z"),
+ evidenceList()));
+ }
+
+ @Test
+ public void testRejectInvalidEvent() {
+ MemoryFact fact = fact("Alice");
+ TimeInterval validTime = TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z"));
+ Instant transactionTime =
+ time("2024-03-01T00:00:00Z");
+ List evidence = evidenceList();
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.add(
+ null,
+ fact,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> MemoryEvent.add(
+ " ",
+ fact,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.add(
+ "event-1",
+ null,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.retract(
+ "event-1",
+ null,
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> MemoryEvent.retract(
+ "event-1",
+ " ",
+ validTime,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.add(
+ "event-1",
+ fact,
+ null,
+ transactionTime,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.add(
+ "event-1",
+ fact,
+ validTime,
+ null,
+ evidence));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.add(
+ "event-1",
+ fact,
+ validTime,
+ transactionTime,
+ null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> MemoryEvent.add(
+ "event-1",
+ fact,
+ validTime,
+ transactionTime,
+ Collections.emptyList()));
+
+ List evidenceWithNull = new ArrayList<>();
+ evidenceWithNull.add(null);
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> MemoryEvent.add(
+ "event-1",
+ fact,
+ validTime,
+ transactionTime,
+ evidenceWithNull));
+ }
+
+ private static MemoryFact fact(String literalValue) {
+ return MemoryFact.attribute(
+ "fact-name-alice",
+ new MemoryEntity("person:alice", "person"),
+ "name",
+ literalValue);
+ }
+
+ private static List evidenceList() {
+ return Collections.singletonList(evidence(
+ "evidence-1",
+ "Alice is the recorded name"));
+ }
+
+ private static Evidence evidence(String id, String content) {
+ return new Evidence(
+ id,
+ new Source("source-1", "customer-database"),
+ content);
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From 0a19516bdfba5745315e20eb9b2591d1d69cebd1 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 17:58:51 +0800
Subject: [PATCH 07/23] feat(ai): add deterministic replay for add events
---
.../ai/temporal/oracle/FullReplayOracle.java | 88 +++++++
.../temporal/oracle/FullReplayOracleTest.java | 249 ++++++++++++++++++
2 files changed, 337 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
new file mode 100644
index 000000000..12dfbd01f
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.oracle;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryEventOperation;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+
+/**
+ * Recomputes temporal memory versions from a complete event collection.
+ */
+public final class FullReplayOracle {
+
+ private static final Comparator EVENT_ORDER =
+ Comparator.comparing(MemoryEvent::getTransactionTime)
+ .thenComparing(MemoryEvent::getId);
+
+ public List replay(
+ List events) {
+ Objects.requireNonNull(events, "events");
+
+ Map uniqueEvents = new HashMap<>();
+ for (MemoryEvent event : events) {
+ Objects.requireNonNull(event, "event");
+
+ MemoryEvent existing = uniqueEvents.get(event.getId());
+ if (existing == null) {
+ uniqueEvents.put(event.getId(), event);
+ } else if (!existing.equals(event)) {
+ throw new IllegalArgumentException(
+ "Conflicting event id: " + event.getId());
+ }
+ }
+
+ List orderedEvents =
+ new ArrayList<>(uniqueEvents.values());
+ Collections.sort(orderedEvents, EVENT_ORDER);
+
+ List versions =
+ new ArrayList<>(orderedEvents.size());
+ for (MemoryEvent event : orderedEvents) {
+ if (event.getOperation() != MemoryEventOperation.ADD) {
+ throw new UnsupportedOperationException(
+ "Unsupported memory event operation: "
+ + event.getOperation());
+ }
+ versions.add(replayAdd(event));
+ }
+
+ return Collections.unmodifiableList(versions);
+ }
+
+ private static MemoryFactVersion replayAdd(
+ MemoryEvent event) {
+ return new MemoryFactVersion(
+ event.getId() + ":version:0",
+ event.getFact().get(),
+ event.getValidTime(),
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ event.getEvidence());
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
new file mode 100644
index 000000000..cf4ece1d6
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
@@ -0,0 +1,249 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.oracle;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class FullReplayOracleTest {
+
+ private final FullReplayOracle oracle = new FullReplayOracle();
+
+ @Test
+ public void testReplayAddEvent() {
+ MemoryEvent event = addEvent(
+ "event-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+
+ List versions =
+ oracle.replay(Collections.singletonList(event));
+
+ Assertions.assertEquals(1, versions.size());
+
+ MemoryFactVersion version = versions.get(0);
+ Assertions.assertEquals(
+ "event-1:version:0",
+ version.getId());
+ Assertions.assertEquals(
+ event.getFact().get(),
+ version.getFact());
+ Assertions.assertEquals(
+ event.getValidTime(),
+ version.getValidTime());
+ Assertions.assertEquals(
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ version.getTransactionTime());
+ Assertions.assertEquals(
+ event.getEvidence(),
+ version.getEvidence());
+ }
+
+ @Test
+ public void testReplayUsesDeterministicOrder() {
+ MemoryEvent eventB = addEvent(
+ "event-b",
+ "fact-name-bob",
+ "person:bob",
+ "Bob",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent eventA = addEvent(
+ "event-a",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent eventC = addEvent(
+ "event-c",
+ "fact-name-carol",
+ "person:carol",
+ "Carol",
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z");
+
+ List shuffled = oracle.replay(
+ Arrays.asList(eventC, eventB, eventA));
+ List ordered = oracle.replay(
+ Arrays.asList(eventA, eventB, eventC));
+
+ Assertions.assertEquals(ordered, shuffled);
+ Assertions.assertEquals(
+ "event-a:version:0",
+ shuffled.get(0).getId());
+ Assertions.assertEquals(
+ "event-b:version:0",
+ shuffled.get(1).getId());
+ Assertions.assertEquals(
+ "event-c:version:0",
+ shuffled.get(2).getId());
+ }
+
+ @Test
+ public void testDuplicateEventIsIdempotent() {
+ MemoryEvent event = addEvent(
+ "event-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent duplicate = addEvent(
+ "event-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+
+ List once =
+ oracle.replay(Collections.singletonList(event));
+ List repeated =
+ oracle.replay(Arrays.asList(event, duplicate, event));
+
+ Assertions.assertEquals(once, repeated);
+ Assertions.assertEquals(1, repeated.size());
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> repeated.clear());
+ }
+
+ @Test
+ public void testRejectConflictingEventId() {
+ MemoryEvent original = addEvent(
+ "event-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent conflict = addEvent(
+ "event-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> oracle.replay(
+ Arrays.asList(original, conflict)));
+ }
+
+ @Test
+ public void testRejectUnsupportedOperations() {
+ MemoryFact correctedFact = MemoryFact.attribute(
+ "fact-name-alice",
+ new MemoryEntity("person:alice", "person"),
+ "name",
+ "Alice Smith");
+
+ MemoryEvent correct = MemoryEvent.correct(
+ "event-correct",
+ correctedFact,
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time("2024-06-01T00:00:00Z"),
+ evidence("event-correct"));
+
+ MemoryEvent retract = MemoryEvent.retract(
+ "event-retract",
+ "fact-name-alice",
+ TimeInterval.unboundedFrom(
+ time("2025-01-01T00:00:00Z")),
+ time("2025-02-01T00:00:00Z"),
+ evidence("event-retract"));
+
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> oracle.replay(
+ Collections.singletonList(correct)));
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> oracle.replay(
+ Collections.singletonList(retract)));
+ }
+
+ @Test
+ public void testEmptyAndInvalidInput() {
+ Assertions.assertTrue(
+ oracle.replay(Collections.emptyList()).isEmpty());
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> oracle.replay(null));
+
+ List eventsWithNull = new ArrayList<>();
+ eventsWithNull.add(null);
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> oracle.replay(eventsWithNull));
+ }
+
+ private static MemoryEvent addEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String literalValue,
+ String validStart,
+ String transactionTime) {
+ MemoryFact fact = MemoryFact.attribute(
+ factId,
+ new MemoryEntity(subjectId, "person"),
+ "name",
+ literalValue);
+
+ return MemoryEvent.add(
+ eventId,
+ fact,
+ TimeInterval.unboundedFrom(time(validStart)),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static List evidence(String eventId) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + eventId,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + eventId));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From 5d0697015ffabb43b04b7c83e2890d0126cd5ce6 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 18:23:30 +0800
Subject: [PATCH 08/23] feat(ai): support correction in temporal replay
---
.../ai/temporal/oracle/FullReplayOracle.java | 136 +++++++++-
.../temporal/oracle/FullReplayOracleTest.java | 255 ++++++++++++++++--
2 files changed, 365 insertions(+), 26 deletions(-)
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
index 12dfbd01f..d38e03e9c 100644
--- a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
@@ -40,6 +40,20 @@ public final class FullReplayOracle {
Comparator.comparing(MemoryEvent::getTransactionTime)
.thenComparing(MemoryEvent::getId);
+ private static final Comparator VERSION_ORDER =
+ Comparator.comparing(
+ (MemoryFactVersion version) ->
+ version.getTransactionTime().getStart())
+ .thenComparing(
+ version -> version.getValidTime().getStart())
+ .thenComparing(MemoryFactVersion::getId);
+
+ private static final Comparator VALID_TIME_ORDER =
+ Comparator.comparing(
+ (MemoryFactVersion version) ->
+ version.getValidTime().getStart())
+ .thenComparing(MemoryFactVersion::getId);
+
public List replay(
List events) {
Objects.requireNonNull(events, "events");
@@ -61,28 +75,134 @@ public List replay(
new ArrayList<>(uniqueEvents.values());
Collections.sort(orderedEvents, EVENT_ORDER);
- List versions =
- new ArrayList<>(orderedEvents.size());
+ List versions = new ArrayList<>();
for (MemoryEvent event : orderedEvents) {
- if (event.getOperation() != MemoryEventOperation.ADD) {
+ if (event.getOperation() == MemoryEventOperation.ADD) {
+ replayAdd(event, versions);
+ } else if (event.getOperation()
+ == MemoryEventOperation.CORRECT) {
+ replayCorrect(event, versions);
+ } else {
throw new UnsupportedOperationException(
"Unsupported memory event operation: "
+ event.getOperation());
}
- versions.add(replayAdd(event));
}
+ Collections.sort(versions, VERSION_ORDER);
return Collections.unmodifiableList(versions);
}
- private static MemoryFactVersion replayAdd(
- MemoryEvent event) {
- return new MemoryFactVersion(
+ private static void replayAdd(
+ MemoryEvent event,
+ List versions) {
+ for (MemoryFactVersion version : versions) {
+ if (isCurrent(version)
+ && version.getFact().getId().equals(event.getFactId())
+ && version.getValidTime().overlaps(
+ event.getValidTime())) {
+ throw new IllegalArgumentException(
+ "Overlapping add for fact id: "
+ + event.getFactId());
+ }
+ }
+
+ versions.add(new MemoryFactVersion(
event.getId() + ":version:0",
event.getFact().get(),
event.getValidTime(),
TimeInterval.unboundedFrom(
event.getTransactionTime()),
- event.getEvidence());
+ event.getEvidence()));
+ }
+
+ private static void replayCorrect(
+ MemoryEvent event,
+ List versions) {
+ List affected = new ArrayList<>();
+
+ for (MemoryFactVersion version : versions) {
+ if (isCurrent(version)
+ && version.getFact().getId().equals(event.getFactId())
+ && version.getValidTime().overlaps(
+ event.getValidTime())) {
+ affected.add(version);
+ }
+ }
+
+ Collections.sort(affected, VALID_TIME_ORDER);
+
+ if (!isFullyCovered(event.getValidTime(), affected)) {
+ throw new IllegalArgumentException(
+ "Correction interval is not fully covered for fact id: "
+ + event.getFactId());
+ }
+
+ versions.removeAll(affected);
+
+ int fragmentIndex = 1;
+ for (MemoryFactVersion version : affected) {
+ if (version.getTransactionTime().getStart()
+ .isBefore(event.getTransactionTime())) {
+ versions.add(new MemoryFactVersion(
+ version.getId(),
+ version.getFact(),
+ version.getValidTime(),
+ new TimeInterval(
+ version.getTransactionTime().getStart(),
+ event.getTransactionTime()),
+ version.getEvidence()));
+ }
+
+ for (TimeInterval remaining :
+ version.getValidTime().subtract(
+ event.getValidTime())) {
+ versions.add(new MemoryFactVersion(
+ event.getId() + ":version:"
+ + fragmentIndex++,
+ version.getFact(),
+ remaining,
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ version.getEvidence()));
+ }
+ }
+
+ versions.add(new MemoryFactVersion(
+ event.getId() + ":version:0",
+ event.getFact().get(),
+ event.getValidTime(),
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ event.getEvidence()));
+ }
+
+ private static boolean isFullyCovered(
+ TimeInterval target,
+ List coveringVersions) {
+ List uncovered = new ArrayList<>();
+ uncovered.add(target);
+
+ for (MemoryFactVersion version : coveringVersions) {
+ List remaining = new ArrayList<>();
+
+ for (TimeInterval interval : uncovered) {
+ remaining.addAll(
+ interval.subtract(version.getValidTime()));
+ }
+
+ uncovered = remaining;
+ if (uncovered.isEmpty()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean isCurrent(
+ MemoryFactVersion version) {
+ return !version.getTransactionTime()
+ .getEnd().isPresent();
}
}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
index cf4ece1d6..f3f394a8a 100644
--- a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
@@ -166,21 +166,7 @@ public void testRejectConflictingEventId() {
}
@Test
- public void testRejectUnsupportedOperations() {
- MemoryFact correctedFact = MemoryFact.attribute(
- "fact-name-alice",
- new MemoryEntity("person:alice", "person"),
- "name",
- "Alice Smith");
-
- MemoryEvent correct = MemoryEvent.correct(
- "event-correct",
- correctedFact,
- TimeInterval.unboundedFrom(
- time("2024-01-01T00:00:00Z")),
- time("2024-06-01T00:00:00Z"),
- evidence("event-correct"));
-
+ public void testRejectUnsupportedRetract() {
MemoryEvent retract = MemoryEvent.retract(
"event-retract",
"fact-name-alice",
@@ -192,11 +178,194 @@ public void testRejectUnsupportedOperations() {
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> oracle.replay(
- Collections.singletonList(correct)));
+ Collections.singletonList(retract)));
+ }
+
+ @Test
+ public void testCorrectEventSplitsValidTimeAndClosesOldVersion() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-correct",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+
+ List versions =
+ oracle.replay(Arrays.asList(correction, add));
+
+ Assertions.assertEquals(4, versions.size());
+
+ assertVersion(
+ versions.get(0),
+ "event-add:version:0",
+ add.getFact().get(),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ interval(
+ "2024-03-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"),
+ add.getEvidence());
+ assertVersion(
+ versions.get(1),
+ "event-correct:version:1",
+ add.getFact().get(),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-06-01T00:00:00Z")),
+ add.getEvidence());
+ assertVersion(
+ versions.get(2),
+ "event-correct:version:0",
+ correction.getFact().get(),
+ interval(
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-06-01T00:00:00Z")),
+ correction.getEvidence());
+ assertVersion(
+ versions.get(3),
+ "event-correct:version:2",
+ add.getFact().get(),
+ TimeInterval.unboundedFrom(
+ time("2024-09-01T00:00:00Z")),
+ TimeInterval.unboundedFrom(
+ time("2024-06-01T00:00:00Z")),
+ add.getEvidence());
+ }
+
+ @Test
+ public void testCorrectAcrossCurrentFragments() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent firstCorrection = correctEvent(
+ "event-correct-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ MemoryEvent secondCorrection = correctEvent(
+ "event-correct-2",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Jones",
+ "2024-02-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-08-01T00:00:00Z");
+
+ List versions = oracle.replay(
+ Arrays.asList(
+ secondCorrection,
+ add,
+ firstCorrection));
+
+ List current = new ArrayList<>();
+ for (MemoryFactVersion version : versions) {
+ if (!version.getTransactionTime()
+ .getEnd().isPresent()) {
+ current.add(version);
+ }
+ }
+
+ Assertions.assertEquals(3, current.size());
+ Assertions.assertEquals(
+ "event-correct-2:version:1",
+ current.get(0).getId());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ current.get(0).getFact());
+ Assertions.assertEquals(
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2024-02-01T00:00:00Z"),
+ current.get(0).getValidTime());
+
+ Assertions.assertEquals(
+ "event-correct-2:version:0",
+ current.get(1).getId());
+ Assertions.assertEquals(
+ secondCorrection.getFact().get(),
+ current.get(1).getFact());
+ Assertions.assertEquals(
+ interval(
+ "2024-02-01T00:00:00Z",
+ "2024-10-01T00:00:00Z"),
+ current.get(1).getValidTime());
+
+ Assertions.assertEquals(
+ "event-correct-2:version:2",
+ current.get(2).getId());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ current.get(2).getFact());
+ Assertions.assertEquals(
+ TimeInterval.unboundedFrom(
+ time("2024-10-01T00:00:00Z")),
+ current.get(2).getValidTime());
+ }
+
+ @Test
+ public void testRejectCorrectionWithoutFullCoverage() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-correct",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2023-12-01T00:00:00Z",
+ "2024-02-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+
Assertions.assertThrows(
- UnsupportedOperationException.class,
+ IllegalArgumentException.class,
() -> oracle.replay(
- Collections.singletonList(retract)));
+ Arrays.asList(add, correction)));
+ }
+
+ @Test
+ public void testRejectOverlappingAddForSameFact() {
+ MemoryEvent first = addEvent(
+ "event-1",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent second = addEvent(
+ "event-2",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2024-02-01T00:00:00Z",
+ "2024-04-01T00:00:00Z");
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> oracle.replay(Arrays.asList(first, second)));
}
@Test
@@ -236,6 +405,56 @@ private static MemoryEvent addEvent(
evidence(eventId));
}
+ private static MemoryEvent correctEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String literalValue,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ MemoryFact fact = MemoryFact.attribute(
+ factId,
+ new MemoryEntity(subjectId, "person"),
+ "name",
+ literalValue);
+
+ return MemoryEvent.correct(
+ eventId,
+ fact,
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static void assertVersion(
+ MemoryFactVersion actual,
+ String expectedId,
+ MemoryFact expectedFact,
+ TimeInterval expectedValidTime,
+ TimeInterval expectedTransactionTime,
+ List expectedEvidence) {
+ Assertions.assertEquals(expectedId, actual.getId());
+ Assertions.assertEquals(
+ expectedFact,
+ actual.getFact());
+ Assertions.assertEquals(
+ expectedValidTime,
+ actual.getValidTime());
+ Assertions.assertEquals(
+ expectedTransactionTime,
+ actual.getTransactionTime());
+ Assertions.assertEquals(
+ expectedEvidence,
+ actual.getEvidence());
+ }
+
+ private static TimeInterval interval(
+ String start,
+ String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
private static List evidence(String eventId) {
return Collections.singletonList(new Evidence(
"evidence-" + eventId,
From 51152c8464f55578bdc5feeec2a0ba0dd3818682 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 18:28:33 +0800
Subject: [PATCH 09/23] feat(ai): support retraction in temporal replay
---
.../ai/temporal/oracle/FullReplayOracle.java | 31 ++--
.../temporal/oracle/FullReplayOracleTest.java | 173 +++++++++++++++++-
2 files changed, 183 insertions(+), 21 deletions(-)
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
index d38e03e9c..f0948a02d 100644
--- a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java
@@ -77,15 +77,16 @@ public List replay(
List versions = new ArrayList<>();
for (MemoryEvent event : orderedEvents) {
- if (event.getOperation() == MemoryEventOperation.ADD) {
+ MemoryEventOperation operation = event.getOperation();
+ if (operation == MemoryEventOperation.ADD) {
replayAdd(event, versions);
- } else if (event.getOperation()
- == MemoryEventOperation.CORRECT) {
- replayCorrect(event, versions);
+ } else if (operation == MemoryEventOperation.CORRECT
+ || operation == MemoryEventOperation.RETRACT) {
+ replayChange(event, versions);
} else {
throw new UnsupportedOperationException(
"Unsupported memory event operation: "
- + event.getOperation());
+ + operation);
}
}
@@ -116,7 +117,7 @@ private static void replayAdd(
event.getEvidence()));
}
- private static void replayCorrect(
+ private static void replayChange(
MemoryEvent event,
List versions) {
List affected = new ArrayList<>();
@@ -134,7 +135,7 @@ private static void replayCorrect(
if (!isFullyCovered(event.getValidTime(), affected)) {
throw new IllegalArgumentException(
- "Correction interval is not fully covered for fact id: "
+ "Event interval is not fully covered for fact id: "
+ event.getFactId());
}
@@ -168,13 +169,15 @@ private static void replayCorrect(
}
}
- versions.add(new MemoryFactVersion(
- event.getId() + ":version:0",
- event.getFact().get(),
- event.getValidTime(),
- TimeInterval.unboundedFrom(
- event.getTransactionTime()),
- event.getEvidence()));
+ if (event.getOperation() == MemoryEventOperation.CORRECT) {
+ versions.add(new MemoryFactVersion(
+ event.getId() + ":version:0",
+ event.getFact().get(),
+ event.getValidTime(),
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ event.getEvidence()));
+ }
}
private static boolean isFullyCovered(
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
index f3f394a8a..988f8b3eb 100644
--- a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java
@@ -166,19 +166,82 @@ public void testRejectConflictingEventId() {
}
@Test
- public void testRejectUnsupportedRetract() {
+ public void testRetractEventSplitsValidTimeAndClosesOldVersion() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-retract",
+ "fact-name-alice",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+
+ List versions =
+ oracle.replay(Arrays.asList(retract, add));
+
+ Assertions.assertEquals(3, versions.size());
+ assertVersion(
+ versions.get(0),
+ "event-add:version:0",
+ add.getFact().get(),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ interval(
+ "2024-03-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"),
+ add.getEvidence());
+ assertVersion(
+ versions.get(1),
+ "event-retract:version:1",
+ add.getFact().get(),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z"),
+ TimeInterval.unboundedFrom(
+ time("2024-06-01T00:00:00Z")),
+ add.getEvidence());
+ assertVersion(
+ versions.get(2),
+ "event-retract:version:2",
+ add.getFact().get(),
+ TimeInterval.unboundedFrom(
+ time("2024-09-01T00:00:00Z")),
+ TimeInterval.unboundedFrom(
+ time("2024-06-01T00:00:00Z")),
+ add.getEvidence());
+ }
+
+ @Test
+ public void testRetractWholeCurrentInterval() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
MemoryEvent retract = MemoryEvent.retract(
"event-retract",
"fact-name-alice",
TimeInterval.unboundedFrom(
- time("2025-01-01T00:00:00Z")),
- time("2025-02-01T00:00:00Z"),
+ time("2024-01-01T00:00:00Z")),
+ time("2024-06-01T00:00:00Z"),
evidence("event-retract"));
- Assertions.assertThrows(
- UnsupportedOperationException.class,
- () -> oracle.replay(
- Collections.singletonList(retract)));
+ List versions =
+ oracle.replay(Arrays.asList(add, retract));
+
+ Assertions.assertEquals(1, versions.size());
+ Assertions.assertEquals(
+ interval(
+ "2024-03-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"),
+ versions.get(0).getTransactionTime());
}
@Test
@@ -322,6 +385,66 @@ public void testCorrectAcrossCurrentFragments() {
current.get(2).getValidTime());
}
+ @Test
+ public void testRetractAcrossCurrentFragments() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-correct",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-retract",
+ "fact-name-alice",
+ "2024-02-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-08-01T00:00:00Z");
+
+ List versions = oracle.replay(
+ Arrays.asList(retract, correction, add));
+
+ List current = new ArrayList<>();
+ for (MemoryFactVersion version : versions) {
+ if (!version.getTransactionTime()
+ .getEnd().isPresent()) {
+ current.add(version);
+ }
+ }
+
+ Assertions.assertEquals(6, versions.size());
+ Assertions.assertEquals(2, current.size());
+ Assertions.assertEquals(
+ "event-retract:version:1",
+ current.get(0).getId());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ current.get(0).getFact());
+ Assertions.assertEquals(
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2024-02-01T00:00:00Z"),
+ current.get(0).getValidTime());
+ Assertions.assertEquals(
+ "event-retract:version:2",
+ current.get(1).getId());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ current.get(1).getFact());
+ Assertions.assertEquals(
+ TimeInterval.unboundedFrom(
+ time("2024-10-01T00:00:00Z")),
+ current.get(1).getValidTime());
+ }
+
@Test
public void testRejectCorrectionWithoutFullCoverage() {
MemoryEvent add = addEvent(
@@ -346,6 +469,28 @@ public void testRejectCorrectionWithoutFullCoverage() {
Arrays.asList(add, correction)));
}
+ @Test
+ public void testRejectRetractionWithoutFullCoverage() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-retract",
+ "fact-name-alice",
+ "2023-12-01T00:00:00Z",
+ "2024-02-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> oracle.replay(
+ Arrays.asList(add, retract)));
+ }
+
@Test
public void testRejectOverlappingAddForSameFact() {
MemoryEvent first = addEvent(
@@ -427,6 +572,20 @@ private static MemoryEvent correctEvent(
evidence(eventId));
}
+ private static MemoryEvent retractEvent(
+ String eventId,
+ String factId,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.retract(
+ eventId,
+ factId,
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
private static void assertVersion(
MemoryFactVersion actual,
String expectedId,
From f5952d7311b59d3ef3f914edfff6a6e879b4938d Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 30 Aug 2026 18:38:28 +0800
Subject: [PATCH 10/23] feat(ai): add bitemporal fact query
---
.../ai/temporal/query/BitemporalQuery.java | 62 ++++
.../temporal/query/BitemporalQueryTest.java | 303 ++++++++++++++++++
2 files changed, 365 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/BitemporalQuery.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/BitemporalQueryTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/BitemporalQuery.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/BitemporalQuery.java
new file mode 100644
index 000000000..02f7832e0
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/BitemporalQuery.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.query;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+
+/**
+ * Selects memory fact versions visible at two temporal points.
+ */
+public final class BitemporalQuery {
+
+ private static final Comparator RESULT_ORDER =
+ Comparator.comparing(
+ (MemoryFactVersion version) ->
+ version.getFact().getId())
+ .thenComparing(MemoryFactVersion::getId);
+
+ public List query(
+ List versions,
+ Instant validAt,
+ Instant transactionAt) {
+ Objects.requireNonNull(versions, "versions");
+ Objects.requireNonNull(validAt, "validAt");
+ Objects.requireNonNull(transactionAt, "transactionAt");
+
+ List matches = new ArrayList<>();
+ for (MemoryFactVersion version : versions) {
+ Objects.requireNonNull(version, "version");
+ if (version.getValidTime().contains(validAt)
+ && version.getTransactionTime().contains(
+ transactionAt)) {
+ matches.add(version);
+ }
+ }
+
+ Collections.sort(matches, RESULT_ORDER);
+ return Collections.unmodifiableList(matches);
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/BitemporalQueryTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/BitemporalQueryTest.java
new file mode 100644
index 000000000..180b18d7c
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/BitemporalQueryTest.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.query;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class BitemporalQueryTest {
+
+ private final BitemporalQuery query = new BitemporalQuery();
+ private final FullReplayOracle oracle = new FullReplayOracle();
+
+ @Test
+ public void testQueryBeforeAndAfterCorrection() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-correct",
+ "fact-name-alice",
+ "person:alice",
+ "Alice Smith",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ List versions =
+ oracle.replay(Arrays.asList(correction, add));
+
+ List beforeCorrection = query.query(
+ versions,
+ time("2024-05-01T00:00:00Z"),
+ time("2024-05-01T00:00:00Z"));
+ List afterCorrection = query.query(
+ versions,
+ time("2024-05-01T00:00:00Z"),
+ time("2024-07-01T00:00:00Z"));
+
+ Assertions.assertEquals(1, beforeCorrection.size());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ beforeCorrection.get(0).getFact());
+ Assertions.assertEquals(1, afterCorrection.size());
+ Assertions.assertEquals(
+ correction.getFact().get(),
+ afterCorrection.get(0).getFact());
+ }
+
+ @Test
+ public void testQueryBeforeAndAfterRetraction() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-name-alice",
+ "person:alice",
+ "Alice",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-retract",
+ "fact-name-alice",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ List versions =
+ oracle.replay(Arrays.asList(retract, add));
+
+ List beforeRetraction = query.query(
+ versions,
+ time("2024-05-01T00:00:00Z"),
+ time("2024-05-01T00:00:00Z"));
+ List afterRetraction = query.query(
+ versions,
+ time("2024-05-01T00:00:00Z"),
+ time("2024-07-01T00:00:00Z"));
+ List outsideRetraction = query.query(
+ versions,
+ time("2024-10-01T00:00:00Z"),
+ time("2024-07-01T00:00:00Z"));
+
+ Assertions.assertEquals(1, beforeRetraction.size());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ beforeRetraction.get(0).getFact());
+ Assertions.assertTrue(afterRetraction.isEmpty());
+ Assertions.assertEquals(1, outsideRetraction.size());
+ Assertions.assertEquals(
+ add.getFact().get(),
+ outsideRetraction.get(0).getFact());
+ }
+
+ @Test
+ public void testHalfOpenBoundaries() {
+ MemoryFactVersion version = new MemoryFactVersion(
+ "version-1",
+ fact("fact-name-alice", "person:alice", "Alice"),
+ interval(
+ "2024-01-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"),
+ interval(
+ "2024-03-01T00:00:00Z",
+ "2024-09-01T00:00:00Z"),
+ evidence("version-1"));
+ List versions =
+ Collections.singletonList(version);
+
+ Assertions.assertEquals(
+ 1,
+ query.query(
+ versions,
+ time("2024-01-01T00:00:00Z"),
+ time("2024-03-01T00:00:00Z")).size());
+ Assertions.assertTrue(
+ query.query(
+ versions,
+ time("2024-06-01T00:00:00Z"),
+ time("2024-03-01T00:00:00Z")).isEmpty());
+ Assertions.assertTrue(
+ query.query(
+ versions,
+ time("2024-05-01T00:00:00Z"),
+ time("2024-09-01T00:00:00Z")).isEmpty());
+ }
+
+ @Test
+ public void testResultIsDeterministicAndImmutable() {
+ MemoryFactVersion bob = currentVersion(
+ "version-bob",
+ fact("fact-name-bob", "person:bob", "Bob"));
+ MemoryFactVersion alice = currentVersion(
+ "version-alice",
+ fact("fact-name-alice", "person:alice", "Alice"));
+
+ List result = query.query(
+ Arrays.asList(bob, alice),
+ time("2024-05-01T00:00:00Z"),
+ time("2024-05-01T00:00:00Z"));
+
+ Assertions.assertEquals(2, result.size());
+ Assertions.assertEquals(
+ "fact-name-alice",
+ result.get(0).getFact().getId());
+ Assertions.assertEquals(
+ "fact-name-bob",
+ result.get(1).getFact().getId());
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> result.clear());
+ }
+
+ @Test
+ public void testEmptyAndInvalidInput() {
+ Instant queryTime = time("2024-05-01T00:00:00Z");
+
+ Assertions.assertTrue(
+ query.query(
+ Collections.emptyList(),
+ queryTime,
+ queryTime).isEmpty());
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> query.query(null, queryTime, queryTime));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> query.query(
+ Collections.emptyList(),
+ null,
+ queryTime));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> query.query(
+ Collections.emptyList(),
+ queryTime,
+ null));
+
+ List versionsWithNull = new ArrayList<>();
+ versionsWithNull.add(null);
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> query.query(
+ versionsWithNull,
+ queryTime,
+ queryTime));
+ }
+
+ private static MemoryEvent addEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String literalValue,
+ String validStart,
+ String transactionTime) {
+ return MemoryEvent.add(
+ eventId,
+ fact(factId, subjectId, literalValue),
+ TimeInterval.unboundedFrom(time(validStart)),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent correctEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String literalValue,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.correct(
+ eventId,
+ fact(factId, subjectId, literalValue),
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent retractEvent(
+ String eventId,
+ String factId,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.retract(
+ eventId,
+ factId,
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryFactVersion currentVersion(
+ String versionId,
+ MemoryFact fact) {
+ return new MemoryFactVersion(
+ versionId,
+ fact,
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ TimeInterval.unboundedFrom(
+ time("2024-03-01T00:00:00Z")),
+ evidence(versionId));
+ }
+
+ private static MemoryFact fact(
+ String factId,
+ String subjectId,
+ String literalValue) {
+ return MemoryFact.attribute(
+ factId,
+ new MemoryEntity(subjectId, "person"),
+ "name",
+ literalValue);
+ }
+
+ private static TimeInterval interval(
+ String start,
+ String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
+ private static List evidence(String id) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + id,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + id));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From 515ec36bd12723e896e14e9144a0c28ce9a2f9a8 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Mon, 31 Aug 2026 20:40:56 +0800
Subject: [PATCH 11/23] feat(ai): add incremental temporal integration
---
.../IncrementalTemporalIntegrator.java | 256 +++++++++++++
.../IncrementalTemporalIntegratorTest.java | 347 ++++++++++++++++++
2 files changed, 603 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java
new file mode 100644
index 000000000..e7e788193
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java
@@ -0,0 +1,256 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryEventOperation;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+
+/**
+ * Incrementally integrates temporal memory events by fact id.
+ */
+public final class IncrementalTemporalIntegrator {
+
+ private static final Comparator EVENT_ORDER =
+ Comparator.comparing(MemoryEvent::getTransactionTime)
+ .thenComparing(MemoryEvent::getId);
+
+ private static final Comparator VERSION_ORDER =
+ Comparator.comparing(
+ (MemoryFactVersion version) ->
+ version.getTransactionTime().getStart())
+ .thenComparing(
+ version -> version.getValidTime().getStart())
+ .thenComparing(MemoryFactVersion::getId);
+
+ private static final Comparator VALID_TIME_ORDER =
+ Comparator.comparing(
+ (MemoryFactVersion version) ->
+ version.getValidTime().getStart())
+ .thenComparing(MemoryFactVersion::getId);
+
+ private final Map eventsById = new HashMap<>();
+ private final Map> eventsByFactId =
+ new HashMap<>();
+ private final Map> versionsByFactId =
+ new HashMap<>();
+
+ public void apply(MemoryEvent event) {
+ Objects.requireNonNull(event, "event");
+
+ MemoryEvent existing = eventsById.get(event.getId());
+ if (existing != null) {
+ if (!existing.equals(event)) {
+ throw new IllegalArgumentException(
+ "Conflicting event id: " + event.getId());
+ }
+ return;
+ }
+
+ String factId = event.getFactId();
+ List existingEvents = eventsByFactId.get(factId);
+ List updatedEvents = existingEvents == null
+ ? new ArrayList<>() : new ArrayList<>(existingEvents);
+ boolean appended = existingEvents == null
+ || EVENT_ORDER.compare(
+ existingEvents.get(existingEvents.size() - 1),
+ event) < 0;
+
+ updatedEvents.add(event);
+ Collections.sort(updatedEvents, EVENT_ORDER);
+
+ List updatedVersions;
+ if (appended) {
+ List existingVersions =
+ versionsByFactId.get(factId);
+ updatedVersions = existingVersions == null
+ ? new ArrayList<>()
+ : new ArrayList<>(existingVersions);
+ applyOrderedEvent(event, updatedVersions);
+ } else {
+ updatedVersions = replayFact(updatedEvents);
+ }
+
+ Collections.sort(updatedVersions, VERSION_ORDER);
+ eventsById.put(event.getId(), event);
+ eventsByFactId.put(
+ factId,
+ Collections.unmodifiableList(updatedEvents));
+ versionsByFactId.put(
+ factId,
+ Collections.unmodifiableList(updatedVersions));
+ }
+
+ public List snapshot() {
+ List snapshot = new ArrayList<>();
+ for (List versions :
+ versionsByFactId.values()) {
+ snapshot.addAll(versions);
+ }
+
+ Collections.sort(snapshot, VERSION_ORDER);
+ return Collections.unmodifiableList(snapshot);
+ }
+
+ private static List replayFact(
+ List events) {
+ List versions = new ArrayList<>();
+ for (MemoryEvent event : events) {
+ applyOrderedEvent(event, versions);
+ }
+ return versions;
+ }
+
+ private static void applyOrderedEvent(
+ MemoryEvent event,
+ List versions) {
+ MemoryEventOperation operation = event.getOperation();
+ if (operation == MemoryEventOperation.ADD) {
+ applyAdd(event, versions);
+ } else if (operation == MemoryEventOperation.CORRECT
+ || operation == MemoryEventOperation.RETRACT) {
+ applyChange(event, versions);
+ } else {
+ throw new UnsupportedOperationException(
+ "Unsupported memory event operation: "
+ + operation);
+ }
+ }
+
+ private static void applyAdd(
+ MemoryEvent event,
+ List versions) {
+ for (MemoryFactVersion version : versions) {
+ if (isCurrent(version)
+ && version.getFact().getId().equals(event.getFactId())
+ && version.getValidTime().overlaps(
+ event.getValidTime())) {
+ throw new IllegalArgumentException(
+ "Overlapping add for fact id: "
+ + event.getFactId());
+ }
+ }
+
+ versions.add(new MemoryFactVersion(
+ event.getId() + ":version:0",
+ event.getFact().get(),
+ event.getValidTime(),
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ event.getEvidence()));
+ }
+
+ private static void applyChange(
+ MemoryEvent event,
+ List versions) {
+ List affected = new ArrayList<>();
+ for (MemoryFactVersion version : versions) {
+ if (isCurrent(version)
+ && version.getFact().getId().equals(event.getFactId())
+ && version.getValidTime().overlaps(
+ event.getValidTime())) {
+ affected.add(version);
+ }
+ }
+
+ Collections.sort(affected, VALID_TIME_ORDER);
+ if (!isFullyCovered(event.getValidTime(), affected)) {
+ throw new IllegalArgumentException(
+ "Event interval is not fully covered for fact id: "
+ + event.getFactId());
+ }
+
+ versions.removeAll(affected);
+
+ int fragmentIndex = 1;
+ for (MemoryFactVersion version : affected) {
+ if (version.getTransactionTime().getStart()
+ .isBefore(event.getTransactionTime())) {
+ versions.add(new MemoryFactVersion(
+ version.getId(),
+ version.getFact(),
+ version.getValidTime(),
+ new TimeInterval(
+ version.getTransactionTime().getStart(),
+ event.getTransactionTime()),
+ version.getEvidence()));
+ }
+
+ for (TimeInterval remaining :
+ version.getValidTime().subtract(
+ event.getValidTime())) {
+ versions.add(new MemoryFactVersion(
+ event.getId() + ":version:"
+ + fragmentIndex++,
+ version.getFact(),
+ remaining,
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ version.getEvidence()));
+ }
+ }
+
+ if (event.getOperation() == MemoryEventOperation.CORRECT) {
+ versions.add(new MemoryFactVersion(
+ event.getId() + ":version:0",
+ event.getFact().get(),
+ event.getValidTime(),
+ TimeInterval.unboundedFrom(
+ event.getTransactionTime()),
+ event.getEvidence()));
+ }
+ }
+
+ private static boolean isFullyCovered(
+ TimeInterval target,
+ List coveringVersions) {
+ List uncovered = new ArrayList<>();
+ uncovered.add(target);
+
+ for (MemoryFactVersion version : coveringVersions) {
+ List remaining = new ArrayList<>();
+ for (TimeInterval interval : uncovered) {
+ remaining.addAll(
+ interval.subtract(version.getValidTime()));
+ }
+
+ uncovered = remaining;
+ if (uncovered.isEmpty()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean isCurrent(
+ MemoryFactVersion version) {
+ return !version.getTransactionTime()
+ .getEnd().isPresent();
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java
new file mode 100644
index 000000000..ac1fa5f61
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle;
+import org.apache.geaflow.ai.temporal.query.BitemporalQuery;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class IncrementalTemporalIntegratorTest {
+
+ private final IncrementalTemporalIntegrator integrator =
+ new IncrementalTemporalIntegrator();
+ private final FullReplayOracle oracle = new FullReplayOracle();
+ private final BitemporalQuery query = new BitemporalQuery();
+
+ @Test
+ public void testOrderedEventsMatchFullReplayAfterEachApply() {
+ List events = Arrays.asList(
+ addEvent(
+ "event-add",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z"),
+ correctEvent(
+ "event-correct",
+ "fact-alice-city",
+ "person:alice",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"),
+ retractEvent(
+ "event-retract",
+ "fact-alice-city",
+ "2024-08-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-11-01T00:00:00Z"));
+
+ assertMatchesAfterEachApply(events);
+ }
+
+ @Test
+ public void testLateEventMatchesFullReplayAndKeepsOtherFact() {
+ MemoryEvent aliceAdd = addEvent(
+ "event-alice-add",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent bobAdd = addEvent(
+ "event-bob-add",
+ "fact-bob-city",
+ "person:bob",
+ "Paris",
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-alice-correct",
+ "fact-alice-city",
+ "person:alice",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-alice-retract",
+ "fact-alice-city",
+ "2024-08-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-11-01T00:00:00Z");
+ MemoryEvent lateCorrection = correctEvent(
+ "event-alice-late",
+ "fact-alice-city",
+ "person:alice",
+ "Tianjin",
+ "2024-02-01T00:00:00Z",
+ "2024-03-01T00:00:00Z",
+ "2024-05-15T00:00:00Z");
+
+ List arrivalOrder = Arrays.asList(
+ aliceAdd,
+ bobAdd,
+ correction,
+ retract,
+ lateCorrection);
+ assertMatchesAfterEachApply(arrivalOrder);
+
+ List visible = query.query(
+ integrator.snapshot(),
+ time("2024-02-15T00:00:00Z"),
+ time("2024-12-01T00:00:00Z"));
+
+ Assertions.assertEquals(
+ lateCorrection.getFact().get(),
+ findVersion(visible, "fact-alice-city").getFact());
+ Assertions.assertEquals(
+ bobAdd.getFact().get(),
+ findVersion(visible, "fact-bob-city").getFact());
+ }
+
+ @Test
+ public void testDuplicateAndConflictingEventIdAreAtomic() {
+ MemoryEvent event = addEvent(
+ "event-add",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent duplicate = addEvent(
+ "event-add",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent conflict = addEvent(
+ "event-add",
+ "fact-alice-city",
+ "person:alice",
+ "Shanghai",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+
+ integrator.apply(event);
+ List expected = integrator.snapshot();
+
+ integrator.apply(duplicate);
+ Assertions.assertEquals(expected, integrator.snapshot());
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> integrator.apply(conflict));
+ Assertions.assertEquals(expected, integrator.snapshot());
+ }
+
+ @Test
+ public void testInvalidEventDoesNotChangeState() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent invalid = correctEvent(
+ "event-change",
+ "fact-alice-city",
+ "person:alice",
+ "Shanghai",
+ "2023-12-01T00:00:00Z",
+ "2024-02-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ MemoryEvent retry = correctEvent(
+ "event-change",
+ "fact-alice-city",
+ "person:alice",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+
+ integrator.apply(add);
+ List before = integrator.snapshot();
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> integrator.apply(invalid));
+ Assertions.assertEquals(before, integrator.snapshot());
+
+ integrator.apply(retry);
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(add, retry)),
+ integrator.snapshot());
+ }
+
+ @Test
+ public void testSnapshotIsDeterministicAndImmutable() {
+ MemoryEvent bob = addEvent(
+ "event-b",
+ "fact-bob-city",
+ "person:bob",
+ "Paris",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent alice = addEvent(
+ "event-a",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+
+ integrator.apply(bob);
+ integrator.apply(alice);
+ List snapshot = integrator.snapshot();
+
+ Assertions.assertEquals(2, snapshot.size());
+ Assertions.assertEquals(
+ "event-a:version:0",
+ snapshot.get(0).getId());
+ Assertions.assertEquals(
+ "event-b:version:0",
+ snapshot.get(1).getId());
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> snapshot.clear());
+ }
+
+ @Test
+ public void testEmptyAndNullInput() {
+ Assertions.assertTrue(integrator.snapshot().isEmpty());
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> integrator.apply(null));
+ Assertions.assertTrue(integrator.snapshot().isEmpty());
+ }
+
+ private void assertMatchesAfterEachApply(
+ List arrivalOrder) {
+ List received = new ArrayList<>();
+ for (MemoryEvent event : arrivalOrder) {
+ integrator.apply(event);
+ received.add(event);
+ Assertions.assertEquals(
+ oracle.replay(received),
+ integrator.snapshot());
+ }
+ }
+
+ private static MemoryFactVersion findVersion(
+ List versions,
+ String factId) {
+ for (MemoryFactVersion version : versions) {
+ if (version.getFact().getId().equals(factId)) {
+ return version;
+ }
+ }
+ throw new AssertionError(
+ "Missing fact version: " + factId);
+ }
+
+ private static MemoryEvent addEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String literalValue,
+ String validStart,
+ String transactionTime) {
+ return MemoryEvent.add(
+ eventId,
+ fact(factId, subjectId, literalValue),
+ TimeInterval.unboundedFrom(time(validStart)),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent correctEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String literalValue,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.correct(
+ eventId,
+ fact(factId, subjectId, literalValue),
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent retractEvent(
+ String eventId,
+ String factId,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.retract(
+ eventId,
+ factId,
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryFact fact(
+ String factId,
+ String subjectId,
+ String literalValue) {
+ return MemoryFact.attribute(
+ factId,
+ new MemoryEntity(subjectId, "person"),
+ "city",
+ literalValue);
+ }
+
+ private static TimeInterval interval(
+ String start,
+ String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
+ private static List evidence(String eventId) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + eventId,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + eventId));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From d5df0e8c5188149f15e88dbde34b799c57d63f91 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Wed, 2 Sep 2026 11:41:57 +0800
Subject: [PATCH 12/23] feat: integrate temporal events with GeaFlow
---
geaflow-ai/pom.xml | 12 +
.../IncrementalTemporalIntegrator.java | 7 +
.../TemporalEventAggregateFunction.java | 73 ++++
.../IncrementalTemporalIntegratorTest.java | 38 ++
.../TemporalEventAggregateFunctionTest.java | 276 +++++++++++++++
.../TemporalEventPipelineTest.java | 331 ++++++++++++++++++
.../TemporalGeaFlowSerializationTest.java | 210 +++++++++++
7 files changed, 947 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunction.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunctionTest.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalGeaFlowSerializationTest.java
diff --git a/geaflow-ai/pom.xml b/geaflow-ai/pom.xml
index 006215b74..78373f2c0 100644
--- a/geaflow-ai/pom.xml
+++ b/geaflow-ai/pom.xml
@@ -123,6 +123,18 @@
+
+ org.apache.geaflow
+ geaflow-pipeline
+ ${project.version}
+ test
+
+
+ org.apache.geaflow
+ geaflow-on-local
+ ${project.version}
+ test
+ org.junit.jupiterjunit-jupiter
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java
index e7e788193..8f6040fcc 100644
--- a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java
@@ -117,6 +117,13 @@ public List snapshot() {
return Collections.unmodifiableList(snapshot);
}
+ List eventSnapshot() {
+ List snapshot =
+ new ArrayList<>(eventsById.values());
+ Collections.sort(snapshot, EVENT_ORDER);
+ return Collections.unmodifiableList(snapshot);
+ }
+
private static List replayFact(
List events) {
List versions = new ArrayList<>();
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunction.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunction.java
new file mode 100644
index 000000000..dc37bda5d
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunction.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.util.List;
+import java.util.Objects;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.api.function.base.AggregateFunction;
+
+/**
+ * Adapts temporal event integration to GeaFlow keyed aggregation.
+ */
+public final class TemporalEventAggregateFunction implements
+ AggregateFunction> {
+
+ @Override
+ public IncrementalTemporalIntegrator createAccumulator() {
+ return new IncrementalTemporalIntegrator();
+ }
+
+ @Override
+ public void add(
+ MemoryEvent value,
+ IncrementalTemporalIntegrator accumulator) {
+ Objects.requireNonNull(accumulator, "accumulator")
+ .apply(value);
+ }
+
+ @Override
+ public List getResult(
+ IncrementalTemporalIntegrator accumulator) {
+ return Objects.requireNonNull(
+ accumulator,
+ "accumulator").snapshot();
+ }
+
+ @Override
+ public IncrementalTemporalIntegrator merge(
+ IncrementalTemporalIntegrator left,
+ IncrementalTemporalIntegrator right) {
+ Objects.requireNonNull(left, "left");
+ Objects.requireNonNull(right, "right");
+
+ IncrementalTemporalIntegrator merged =
+ new IncrementalTemporalIntegrator();
+ for (MemoryEvent event : left.eventSnapshot()) {
+ merged.apply(event);
+ }
+ for (MemoryEvent event : right.eventSnapshot()) {
+ merged.apply(event);
+ }
+ return merged;
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java
index ac1fa5f61..44da81cb1 100644
--- a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java
@@ -239,6 +239,44 @@ public void testSnapshotIsDeterministicAndImmutable() {
() -> snapshot.clear());
}
+ @Test
+ public void testEventSnapshotIsDeterministicAndImmutable() {
+ MemoryEvent tieLater = addEvent(
+ "event-b",
+ "fact-bob-city",
+ "person:bob",
+ "Paris",
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z");
+ MemoryEvent early = addEvent(
+ "event-c",
+ "fact-carol-city",
+ "person:carol",
+ "Rome",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent tieEarlier = addEvent(
+ "event-a",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z");
+
+ integrator.apply(tieLater);
+ integrator.apply(early);
+ integrator.apply(tieEarlier);
+ integrator.apply(tieLater);
+ List snapshot = integrator.eventSnapshot();
+
+ Assertions.assertEquals(
+ Arrays.asList(early, tieEarlier, tieLater),
+ snapshot);
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> snapshot.clear());
+ }
+
@Test
public void testEmptyAndNullInput() {
Assertions.assertTrue(integrator.snapshot().isEmpty());
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunctionTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunctionTest.java
new file mode 100644
index 000000000..97c90cfff
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventAggregateFunctionTest.java
@@ -0,0 +1,276 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TemporalEventAggregateFunctionTest {
+
+ private final TemporalEventAggregateFunction function =
+ new TemporalEventAggregateFunction();
+ private final FullReplayOracle oracle = new FullReplayOracle();
+
+ @Test
+ public void testCreateAddAndGetResultMatchFullReplay() {
+ IncrementalTemporalIntegrator accumulator =
+ function.createAccumulator();
+ IncrementalTemporalIntegrator other =
+ function.createAccumulator();
+ List events = Arrays.asList(
+ addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z"),
+ correctEvent(
+ "event-correct",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"),
+ retractEvent(
+ "event-retract",
+ "2024-08-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-11-01T00:00:00Z"),
+ correctEvent(
+ "event-late",
+ "Tianjin",
+ "2024-02-01T00:00:00Z",
+ "2024-03-01T00:00:00Z",
+ "2024-05-01T00:00:00Z"));
+
+ Assertions.assertNotSame(accumulator, other);
+ Assertions.assertTrue(function.getResult(other).isEmpty());
+
+ List received = new ArrayList<>();
+ for (MemoryEvent event : events) {
+ function.add(event, accumulator);
+ received.add(event);
+ Assertions.assertEquals(
+ oracle.replay(received),
+ function.getResult(accumulator));
+ }
+
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> function.getResult(accumulator).clear());
+ }
+
+ @Test
+ public void testDuplicateAndConflictRemainAtomic() {
+ IncrementalTemporalIntegrator accumulator =
+ function.createAccumulator();
+ MemoryEvent event = addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent duplicate = addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent conflict = addEvent(
+ "event-add",
+ "Shanghai",
+ "2024-03-01T00:00:00Z");
+
+ function.add(event, accumulator);
+ List expected =
+ function.getResult(accumulator);
+
+ function.add(duplicate, accumulator);
+ Assertions.assertEquals(
+ expected,
+ function.getResult(accumulator));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> function.add(conflict, accumulator));
+ Assertions.assertEquals(
+ expected,
+ function.getResult(accumulator));
+ }
+
+ @Test
+ public void testMergeReplaysEventsWithoutMutatingInputs() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-correct",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-retract",
+ "2024-08-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-11-01T00:00:00Z");
+ MemoryEvent lateCorrection = correctEvent(
+ "event-late",
+ "Tianjin",
+ "2024-02-01T00:00:00Z",
+ "2024-03-01T00:00:00Z",
+ "2024-05-01T00:00:00Z");
+ IncrementalTemporalIntegrator left =
+ function.createAccumulator();
+ IncrementalTemporalIntegrator right =
+ function.createAccumulator();
+ addAll(left, add, correction, retract);
+ addAll(right, add, lateCorrection);
+ List leftBefore =
+ function.getResult(left);
+ List rightBefore =
+ function.getResult(right);
+
+ IncrementalTemporalIntegrator merged =
+ function.merge(left, right);
+ IncrementalTemporalIntegrator mergedAgain =
+ function.merge(right, left);
+
+ Assertions.assertNotSame(left, merged);
+ Assertions.assertNotSame(right, merged);
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(
+ add,
+ correction,
+ retract,
+ lateCorrection)),
+ function.getResult(merged));
+ Assertions.assertEquals(
+ function.getResult(merged),
+ function.getResult(mergedAgain));
+ Assertions.assertEquals(leftBefore, function.getResult(left));
+ Assertions.assertEquals(rightBefore, function.getResult(right));
+ }
+
+ @Test
+ public void testNullArgumentsRejected() {
+ IncrementalTemporalIntegrator accumulator =
+ function.createAccumulator();
+ MemoryEvent event = addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z");
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> function.add(null, accumulator));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> function.add(event, null));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> function.getResult(null));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> function.merge(null, accumulator));
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> function.merge(accumulator, null));
+ }
+
+ private void addAll(
+ IncrementalTemporalIntegrator accumulator,
+ MemoryEvent... events) {
+ for (MemoryEvent event : events) {
+ function.add(event, accumulator);
+ }
+ }
+
+ private static MemoryEvent addEvent(
+ String eventId,
+ String value,
+ String transactionTime) {
+ return MemoryEvent.add(
+ eventId,
+ fact(value),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent correctEvent(
+ String eventId,
+ String value,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.correct(
+ eventId,
+ fact(value),
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent retractEvent(
+ String eventId,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.retract(
+ eventId,
+ "fact-alice-city",
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryFact fact(String value) {
+ return MemoryFact.attribute(
+ "fact-alice-city",
+ new MemoryEntity("person:alice", "person"),
+ "city",
+ value);
+ }
+
+ private static TimeInterval interval(
+ String start,
+ String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
+ private static List evidence(String eventId) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + eventId,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + eventId));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java
new file mode 100644
index 000000000..21b6b80a7
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java
@@ -0,0 +1,331 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle;
+import org.apache.geaflow.api.function.base.KeySelector;
+import org.apache.geaflow.api.function.base.MapFunction;
+import org.apache.geaflow.api.function.internal.CollectionSource;
+import org.apache.geaflow.api.function.io.SinkFunction;
+import org.apache.geaflow.api.pdata.stream.window.PWindowSource;
+import org.apache.geaflow.api.window.impl.SizeTumblingWindow;
+import org.apache.geaflow.cluster.system.ClusterMetaStore;
+import org.apache.geaflow.env.Environment;
+import org.apache.geaflow.env.EnvironmentFactory;
+import org.apache.geaflow.pipeline.IPipelineResult;
+import org.apache.geaflow.pipeline.Pipeline;
+import org.apache.geaflow.pipeline.PipelineFactory;
+import org.apache.geaflow.pipeline.task.IPipelineTaskContext;
+import org.apache.geaflow.pipeline.task.PipelineTask;
+import org.apache.geaflow.runtime.core.scheduler.resource.ScheduledWorkerManagerFactory;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class TemporalEventPipelineTest {
+
+ private static final int WINDOW_SIZE = 2;
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ public void testKeyedIncrementalAggregationAcrossWindows()
+ throws Exception {
+ List events = pipelineEvents();
+ Path output = tempDirectory.resolve("temporal-results.txt");
+ Environment environment = null;
+
+ try {
+ environment = EnvironmentFactory.onLocalEnvironment();
+ Pipeline pipeline =
+ PipelineFactory.buildPipeline(environment);
+ pipeline.submit(new TemporalPipelineTask(
+ events,
+ output.toString()));
+
+ IPipelineResult> result = pipeline.execute();
+ result.get();
+
+ Assertions.assertTrue(result.isSuccess());
+ List actual = Files.readAllLines(
+ output,
+ StandardCharsets.UTF_8);
+ Collections.sort(actual);
+ Assertions.assertEquals(5, actual.size());
+ Assertions.assertEquals(
+ expectedWindowResults(events),
+ actual);
+ } finally {
+ if (environment != null) {
+ environment.shutdown();
+ }
+ ClusterMetaStore.close();
+ ScheduledWorkerManagerFactory.clear();
+ }
+ }
+
+ private static List pipelineEvents() {
+ MemoryEvent bobAdd = addEvent(
+ "event-bob-add",
+ "fact-bob-city",
+ "person:bob",
+ "Paris",
+ "2024-01-01T00:00:00Z",
+ "2024-04-01T00:00:00Z");
+ List events = new ArrayList<>();
+ events.add(addEvent(
+ "event-alice-add",
+ "fact-alice-city",
+ "person:alice",
+ "Beijing",
+ "2024-01-01T00:00:00Z",
+ "2024-03-01T00:00:00Z"));
+ events.add(bobAdd);
+ events.add(correctEvent(
+ "event-alice-correct",
+ "fact-alice-city",
+ "person:alice",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z"));
+ events.add(bobAdd);
+ events.add(retractEvent(
+ "event-alice-retract",
+ "fact-alice-city",
+ "2024-08-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-11-01T00:00:00Z"));
+ events.add(correctEvent(
+ "event-alice-late",
+ "fact-alice-city",
+ "person:alice",
+ "Tianjin",
+ "2024-02-01T00:00:00Z",
+ "2024-03-01T00:00:00Z",
+ "2024-05-01T00:00:00Z"));
+ return events;
+ }
+
+ private static List expectedWindowResults(
+ List events) {
+ FullReplayOracle oracle = new FullReplayOracle();
+ Map> receivedByFact =
+ new HashMap<>();
+ List expected = new ArrayList<>();
+
+ for (int start = 0;
+ start < events.size();
+ start += WINDOW_SIZE) {
+ Set touchedFactIds = new LinkedHashSet<>();
+ int end = Math.min(start + WINDOW_SIZE, events.size());
+ for (int index = start; index < end; index++) {
+ MemoryEvent event = events.get(index);
+ receivedByFact.computeIfAbsent(
+ event.getFactId(),
+ ignored -> new ArrayList<>()).add(event);
+ touchedFactIds.add(event.getFactId());
+ }
+ for (String factId : touchedFactIds) {
+ expected.add(formatSnapshot(
+ oracle.replay(receivedByFact.get(factId))));
+ }
+ }
+
+ Collections.sort(expected);
+ return expected;
+ }
+
+ private static String formatSnapshot(
+ List versions) {
+ StringBuilder builder = new StringBuilder();
+ for (MemoryFactVersion version : versions) {
+ if (builder.length() > 0) {
+ builder.append(';');
+ }
+ builder.append(version.getId())
+ .append('|')
+ .append(version.getFact().getId())
+ .append('|')
+ .append(version.getFact().getLiteralValue().get())
+ .append('|')
+ .append(version.getValidTime())
+ .append('|')
+ .append(version.getTransactionTime());
+ }
+ return builder.toString();
+ }
+
+ private static MemoryEvent addEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String value,
+ String validStart,
+ String transactionTime) {
+ return MemoryEvent.add(
+ eventId,
+ fact(factId, subjectId, value),
+ TimeInterval.unboundedFrom(time(validStart)),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent correctEvent(
+ String eventId,
+ String factId,
+ String subjectId,
+ String value,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.correct(
+ eventId,
+ fact(factId, subjectId, value),
+ new TimeInterval(time(validStart), time(validEnd)),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent retractEvent(
+ String eventId,
+ String factId,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.retract(
+ eventId,
+ factId,
+ new TimeInterval(time(validStart), time(validEnd)),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryFact fact(
+ String factId,
+ String subjectId,
+ String value) {
+ return MemoryFact.attribute(
+ factId,
+ new MemoryEntity(subjectId, "person"),
+ "city",
+ value);
+ }
+
+ private static List evidence(String eventId) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + eventId,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + eventId));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+
+ private static final class TemporalPipelineTask
+ implements PipelineTask {
+
+ private final List events;
+ private final String outputPath;
+
+ private TemporalPipelineTask(
+ List events,
+ String outputPath) {
+ this.events = new ArrayList<>(events);
+ this.outputPath = outputPath;
+ }
+
+ @Override
+ public void execute(
+ IPipelineTaskContext pipelineTaskContext) {
+ PWindowSource source =
+ pipelineTaskContext.buildSource(
+ new CollectionSource<>(events),
+ SizeTumblingWindow.of(WINDOW_SIZE));
+ source.withParallelism(1)
+ .keyBy(new FactIdSelector())
+ .aggregate(new TemporalEventAggregateFunction())
+ .withParallelism(2)
+ .map(new SnapshotFormatter())
+ .sink(new LineFileSink(outputPath))
+ .withParallelism(1);
+ }
+ }
+
+ private static final class FactIdSelector implements
+ KeySelector {
+
+ @Override
+ public String getKey(MemoryEvent event) {
+ return event.getFactId();
+ }
+ }
+
+ private static final class SnapshotFormatter implements
+ MapFunction, String> {
+
+ @Override
+ public String map(List versions) {
+ return formatSnapshot(versions);
+ }
+ }
+
+ private static final class LineFileSink implements
+ SinkFunction {
+
+ private final String outputPath;
+
+ private LineFileSink(String outputPath) {
+ this.outputPath = outputPath;
+ }
+
+ @Override
+ public void write(String value) throws Exception {
+ Files.write(
+ Paths.get(outputPath),
+ Collections.singletonList(value),
+ StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.APPEND);
+ }
+ }
+}
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalGeaFlowSerializationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalGeaFlowSerializationTest.java
new file mode 100644
index 000000000..5e6014987
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalGeaFlowSerializationTest.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.MemoryFactVersion;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle;
+import org.apache.geaflow.common.serialize.ISerializer;
+import org.apache.geaflow.common.serialize.SerializerFactory;
+import org.apache.geaflow.state.serializer.DefaultKVSerializer;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TemporalGeaFlowSerializationTest {
+
+ private final TemporalEventAggregateFunction function =
+ new TemporalEventAggregateFunction();
+ private final FullReplayOracle oracle = new FullReplayOracle();
+
+ @Test
+ public void testMemoryEventRoundTripsThroughShuffleSerializer() {
+ MemoryEvent event = correctEvent(
+ "event-correct",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ ISerializer serializer =
+ SerializerFactory.getKryoSerializer();
+
+ MemoryEvent restored = (MemoryEvent) serializer.deserialize(
+ serializer.serialize(event));
+
+ Assertions.assertEquals(event, restored);
+ }
+
+ @Test
+ public void testAccumulatorRoundTripsThroughKeyValueStateSerializer() {
+ MemoryEvent add = addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z");
+ MemoryEvent correction = correctEvent(
+ "event-correct",
+ "Shanghai",
+ "2024-04-01T00:00:00Z",
+ "2024-09-01T00:00:00Z",
+ "2024-06-01T00:00:00Z");
+ MemoryEvent retract = retractEvent(
+ "event-retract",
+ "2024-08-01T00:00:00Z",
+ "2024-10-01T00:00:00Z",
+ "2024-11-01T00:00:00Z");
+ MemoryEvent lateCorrection = correctEvent(
+ "event-late",
+ "Tianjin",
+ "2024-02-01T00:00:00Z",
+ "2024-03-01T00:00:00Z",
+ "2024-05-01T00:00:00Z");
+ IncrementalTemporalIntegrator accumulator =
+ function.createAccumulator();
+ function.add(add, accumulator);
+ function.add(correction, accumulator);
+ function.add(retract, accumulator);
+ DefaultKVSerializer
+ serializer = new DefaultKVSerializer<>(String.class, null);
+
+ Assertions.assertEquals(
+ "fact-alice-city",
+ serializer.deserializeKey(
+ serializer.serializeKey("fact-alice-city")));
+ IncrementalTemporalIntegrator restored =
+ serializer.deserializeValue(
+ serializer.serializeValue(accumulator));
+
+ Assertions.assertNotNull(restored);
+ Assertions.assertEquals(
+ accumulator.eventSnapshot(),
+ restored.eventSnapshot());
+ Assertions.assertEquals(
+ function.getResult(accumulator),
+ function.getResult(restored));
+
+ function.add(lateCorrection, restored);
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(
+ add,
+ correction,
+ retract,
+ lateCorrection)),
+ function.getResult(restored));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testResultSnapshotKeepsImmutabilityAfterRoundTrip() {
+ IncrementalTemporalIntegrator accumulator =
+ function.createAccumulator();
+ function.add(
+ addEvent(
+ "event-add",
+ "Beijing",
+ "2024-03-01T00:00:00Z"),
+ accumulator);
+ List snapshot =
+ function.getResult(accumulator);
+ ISerializer serializer =
+ SerializerFactory.getKryoSerializer();
+
+ List restored =
+ (List) serializer.deserialize(
+ serializer.serialize(snapshot));
+
+ Assertions.assertEquals(snapshot, restored);
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> restored.clear());
+ }
+
+ private static MemoryEvent addEvent(
+ String eventId,
+ String value,
+ String transactionTime) {
+ return MemoryEvent.add(
+ eventId,
+ fact(value),
+ TimeInterval.unboundedFrom(
+ time("2024-01-01T00:00:00Z")),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent correctEvent(
+ String eventId,
+ String value,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.correct(
+ eventId,
+ fact(value),
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryEvent retractEvent(
+ String eventId,
+ String validStart,
+ String validEnd,
+ String transactionTime) {
+ return MemoryEvent.retract(
+ eventId,
+ "fact-alice-city",
+ interval(validStart, validEnd),
+ time(transactionTime),
+ evidence(eventId));
+ }
+
+ private static MemoryFact fact(String value) {
+ return MemoryFact.attribute(
+ "fact-alice-city",
+ new MemoryEntity("person:alice", "person"),
+ "city",
+ value);
+ }
+
+ private static TimeInterval interval(
+ String start,
+ String end) {
+ return new TimeInterval(time(start), time(end));
+ }
+
+ private static List evidence(String eventId) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + eventId,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + eventId));
+ }
+
+ private static Instant time(String value) {
+ return Instant.parse(value);
+ }
+}
From 3aef79e5fbeebda5c6361f3870df764b51e11030 Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sat, 5 Sep 2026 14:35:41 +0800
Subject: [PATCH 13/23] test: verify temporal state recovery
---
.../TemporalEventPipelineTest.java | 60 +-
.../TemporalStateRecoveryTest.java | 636 ++++++++++++++++++
2 files changed, 695 insertions(+), 1 deletion(-)
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalStateRecoveryTest.java
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java
index 21b6b80a7..09b36da35 100644
--- a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java
@@ -32,6 +32,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Stream;
import org.apache.geaflow.ai.temporal.model.Evidence;
import org.apache.geaflow.ai.temporal.model.MemoryEntity;
import org.apache.geaflow.ai.temporal.model.MemoryEvent;
@@ -47,16 +48,22 @@
import org.apache.geaflow.api.pdata.stream.window.PWindowSource;
import org.apache.geaflow.api.window.impl.SizeTumblingWindow;
import org.apache.geaflow.cluster.system.ClusterMetaStore;
+import org.apache.geaflow.common.config.keys.ExecutionConfigKeys;
+import org.apache.geaflow.common.config.keys.FrameworkConfigKeys;
import org.apache.geaflow.env.Environment;
import org.apache.geaflow.env.EnvironmentFactory;
+import org.apache.geaflow.file.FileConfigKeys;
import org.apache.geaflow.pipeline.IPipelineResult;
import org.apache.geaflow.pipeline.Pipeline;
import org.apache.geaflow.pipeline.PipelineFactory;
import org.apache.geaflow.pipeline.task.IPipelineTaskContext;
import org.apache.geaflow.pipeline.task.PipelineTask;
import org.apache.geaflow.runtime.core.scheduler.resource.ScheduledWorkerManagerFactory;
+import org.apache.geaflow.state.StoreType;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
public class TemporalEventPipelineTest {
@@ -69,12 +76,39 @@ public class TemporalEventPipelineTest {
@Test
public void testKeyedIncrementalAggregationAcrossWindows()
throws Exception {
+ assertPipelineResults(
+ Collections.emptyMap(),
+ "temporal-results.txt");
+ }
+
+ @Test
+ @DisabledOnOs(
+ value = OS.WINDOWS,
+ disabledReason = "GeaFlow LOCAL persistence requires Hadoop winutils.exe")
+ public void testKeyedAggregationCreatesRocksdbCheckpoints()
+ throws Exception {
+ Path checkpointRoot = tempDirectory.resolve("checkpoints");
+ assertPipelineResults(
+ checkpointConfiguration(checkpointRoot),
+ "temporal-checkpoint-results.txt");
+
+ Assertions.assertTrue(Files.exists(checkpointRoot));
+ try (Stream paths = Files.walk(checkpointRoot)) {
+ Assertions.assertTrue(paths.anyMatch(path ->
+ "_commit".equals(path.getFileName().toString())));
+ }
+ }
+
+ private void assertPipelineResults(
+ Map config,
+ String outputFileName) throws Exception {
List events = pipelineEvents();
- Path output = tempDirectory.resolve("temporal-results.txt");
+ Path output = tempDirectory.resolve(outputFileName);
Environment environment = null;
try {
environment = EnvironmentFactory.onLocalEnvironment();
+ environment.getEnvironmentContext().withConfig(config);
Pipeline pipeline =
PipelineFactory.buildPipeline(environment);
pipeline.submit(new TemporalPipelineTask(
@@ -102,6 +136,30 @@ public void testKeyedIncrementalAggregationAcrossWindows()
}
}
+ private Map checkpointConfiguration(
+ Path checkpointRoot) {
+ Map config = new HashMap<>();
+ config.put(
+ FrameworkConfigKeys.SYSTEM_STATE_BACKEND_TYPE.getKey(),
+ StoreType.ROCKSDB.name());
+ config.put(
+ FrameworkConfigKeys.BATCH_NUMBER_PER_CHECKPOINT.getKey(),
+ "1");
+ config.put(
+ ExecutionConfigKeys.JOB_APP_NAME.getKey(),
+ "TemporalEventPipelineCheckpointTest");
+ config.put(
+ ExecutionConfigKeys.JOB_WORK_PATH.getKey(),
+ tempDirectory.resolve("work").toString());
+ config.put(
+ FileConfigKeys.PERSISTENT_TYPE.getKey(),
+ "LOCAL");
+ config.put(
+ FileConfigKeys.ROOT.getKey(),
+ checkpointRoot.toString());
+ return config;
+ }
+
private static List pipelineEvents() {
MemoryEvent bobAdd = addEvent(
"event-bob-add",
diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalStateRecoveryTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalStateRecoveryTest.java
new file mode 100644
index 000000000..b1cdab05c
--- /dev/null
+++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalStateRecoveryTest.java
@@ -0,0 +1,636 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.integration;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.geaflow.ai.temporal.model.Evidence;
+import org.apache.geaflow.ai.temporal.model.MemoryEntity;
+import org.apache.geaflow.ai.temporal.model.MemoryEvent;
+import org.apache.geaflow.ai.temporal.model.MemoryFact;
+import org.apache.geaflow.ai.temporal.model.Source;
+import org.apache.geaflow.ai.temporal.model.TimeInterval;
+import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle;
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.common.config.keys.ExecutionConfigKeys;
+import org.apache.geaflow.file.FileConfigKeys;
+import org.apache.geaflow.state.KeyValueState;
+import org.apache.geaflow.state.StateFactory;
+import org.apache.geaflow.state.StoreType;
+import org.apache.geaflow.state.descriptor.KeyValueStateDescriptor;
+import org.apache.geaflow.utils.keygroup.DefaultKeyGroupAssigner;
+import org.apache.geaflow.utils.keygroup.KeyGroup;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+@DisabledOnOs(
+ value = OS.WINDOWS,
+ disabledReason = "GeaFlow LOCAL persistence requires Hadoop winutils.exe")
+public class TemporalStateRecoveryTest {
+
+ private static final String FACT_ID = "fact-alice-city";
+ private static final String BOB_FACT_ID = "fact-bob-city";
+ private static final long CHECKPOINT_ID = 1L;
+
+ private final FullReplayOracle oracle = new FullReplayOracle();
+
+ @TempDir
+ Path tempDirectory;
+
+ @Test
+ public void testAccumulatorRecoversFromRocksdbCheckpoint() {
+ MemoryEvent add = addEvent();
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, accumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState recoveredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ recoveredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ recoveredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ recoveredState.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ Assertions.assertEquals(
+ Collections.singletonList(add),
+ recovered.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(add)),
+ recovered.snapshot());
+ } finally {
+ closeAndDrop(recoveredState);
+ }
+ }
+
+ @Test
+ public void testRecoverDiscardsUncheckpointedChanges() {
+ MemoryEvent add = addEvent();
+ MemoryEvent correction = correctEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState state =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+ state.manage().operate().setCheckpointId(CHECKPOINT_ID);
+ state.put(FACT_ID, accumulator);
+ state.manage().operate().finish();
+ state.manage().operate().archive();
+
+ state.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID + 1);
+ IncrementalTemporalIntegrator uncheckpointed =
+ state.get(FACT_ID);
+ uncheckpointed.apply(correction);
+ state.put(FACT_ID, uncheckpointed);
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(add, correction)),
+ state.get(FACT_ID).snapshot());
+
+ state.manage().operate().setCheckpointId(CHECKPOINT_ID);
+ state.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ state.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ Assertions.assertEquals(
+ Collections.singletonList(add),
+ recovered.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(add)),
+ recovered.snapshot());
+ } finally {
+ closeAndDrop(state);
+ }
+ }
+
+ @Test
+ public void testRecoveredAccumulatorHandlesLateCorrection() {
+ MemoryEvent add = addEvent();
+ MemoryEvent correction = correctEvent();
+ MemoryEvent lateCorrection = lateCorrectionEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+ accumulator.apply(correction);
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, accumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState recoveredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ recoveredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ recoveredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ recoveredState.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ recovered.apply(lateCorrection);
+ recoveredState.put(FACT_ID, recovered);
+
+ IncrementalTemporalIntegrator updated =
+ recoveredState.get(FACT_ID);
+ Assertions.assertEquals(
+ Arrays.asList(add, lateCorrection, correction),
+ updated.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(
+ add,
+ correction,
+ lateCorrection)),
+ updated.snapshot());
+ } finally {
+ closeAndDrop(recoveredState);
+ }
+ }
+
+ @Test
+ public void testContinuedUpdatesSurviveNextCheckpoint() {
+ MemoryEvent add = addEvent();
+ MemoryEvent correction = correctEvent();
+ MemoryEvent lateCorrection = lateCorrectionEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+ accumulator.apply(correction);
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, accumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState continuedState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ continuedState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ continuedState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ continuedState.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ recovered.apply(lateCorrection);
+ continuedState.put(FACT_ID, recovered);
+ continuedState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID + 1);
+ continuedState.manage().operate().finish();
+ continuedState.manage().operate().archive();
+ } finally {
+ closeAndDrop(continuedState);
+ }
+
+ KeyValueState restoredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ restoredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID + 1);
+ restoredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator restored =
+ restoredState.get(FACT_ID);
+ Assertions.assertNotNull(restored);
+ Assertions.assertEquals(
+ Arrays.asList(add, lateCorrection, correction),
+ restored.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(
+ add,
+ correction,
+ lateCorrection)),
+ restored.snapshot());
+ } finally {
+ closeAndDrop(restoredState);
+ }
+ }
+
+ @Test
+ public void testConflictingEventAfterRecoveryIsAtomic() {
+ MemoryEvent add = addEvent();
+ MemoryEvent conflictingAdd = conflictingAddEvent();
+ MemoryEvent correction = correctEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, accumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState recoveredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ recoveredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ recoveredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ recoveredState.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> recovered.apply(conflictingAdd));
+ Assertions.assertEquals(
+ Collections.singletonList(add),
+ recovered.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(add)),
+ recovered.snapshot());
+
+ recovered.apply(correction);
+ recoveredState.put(FACT_ID, recovered);
+ IncrementalTemporalIntegrator updated =
+ recoveredState.get(FACT_ID);
+ Assertions.assertEquals(
+ Arrays.asList(add, correction),
+ updated.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(add, correction)),
+ updated.snapshot());
+ } finally {
+ closeAndDrop(recoveredState);
+ }
+ }
+
+ @Test
+ public void testMultipleFactIdsRecoverIndependently() {
+ MemoryEvent aliceAdd = addEvent();
+ MemoryEvent aliceCorrection = correctEvent();
+ MemoryEvent bobAdd = bobAddEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator aliceAccumulator =
+ new IncrementalTemporalIntegrator();
+ aliceAccumulator.apply(aliceAdd);
+ IncrementalTemporalIntegrator bobAccumulator =
+ new IncrementalTemporalIntegrator();
+ bobAccumulator.apply(bobAdd);
+
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, aliceAccumulator);
+ originalState.put(BOB_FACT_ID, bobAccumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState recoveredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ recoveredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ recoveredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recoveredAlice =
+ recoveredState.get(FACT_ID);
+ IncrementalTemporalIntegrator recoveredBob =
+ recoveredState.get(BOB_FACT_ID);
+ Assertions.assertNotNull(recoveredAlice);
+ Assertions.assertNotNull(recoveredBob);
+ Assertions.assertEquals(
+ Collections.singletonList(aliceAdd),
+ recoveredAlice.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(aliceAdd)),
+ recoveredAlice.snapshot());
+ Assertions.assertEquals(
+ Collections.singletonList(bobAdd),
+ recoveredBob.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(bobAdd)),
+ recoveredBob.snapshot());
+
+ recoveredAlice.apply(aliceCorrection);
+ recoveredState.put(FACT_ID, recoveredAlice);
+
+ IncrementalTemporalIntegrator updatedAlice =
+ recoveredState.get(FACT_ID);
+ IncrementalTemporalIntegrator unchangedBob =
+ recoveredState.get(BOB_FACT_ID);
+ Assertions.assertEquals(
+ Arrays.asList(aliceAdd, aliceCorrection),
+ updatedAlice.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(
+ aliceAdd,
+ aliceCorrection)),
+ updatedAlice.snapshot());
+ Assertions.assertEquals(
+ Collections.singletonList(bobAdd),
+ unchangedBob.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(bobAdd)),
+ unchangedBob.snapshot());
+ } finally {
+ closeAndDrop(recoveredState);
+ }
+ }
+
+ @Test
+ public void testDuplicateEventAfterRecoveryIsIdempotent() {
+ MemoryEvent add = addEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, accumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState recoveredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ recoveredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ recoveredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ recoveredState.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ recovered.apply(add);
+ recoveredState.put(FACT_ID, recovered);
+
+ IncrementalTemporalIntegrator updated =
+ recoveredState.get(FACT_ID);
+ Assertions.assertEquals(
+ Collections.singletonList(add),
+ updated.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Collections.singletonList(add)),
+ updated.snapshot());
+ } finally {
+ closeAndDrop(recoveredState);
+ }
+ }
+
+ @Test
+ public void testRecoveredAccumulatorHandlesRetraction() {
+ MemoryEvent add = addEvent();
+ MemoryEvent correction = correctEvent();
+ MemoryEvent retraction = retractEvent();
+ Configuration configuration = stateConfiguration();
+ KeyValueStateDescriptor
+ descriptor = stateDescriptor();
+ KeyValueState originalState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+
+ try {
+ IncrementalTemporalIntegrator accumulator =
+ new IncrementalTemporalIntegrator();
+ accumulator.apply(add);
+ accumulator.apply(correction);
+ originalState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ originalState.put(FACT_ID, accumulator);
+ originalState.manage().operate().finish();
+ originalState.manage().operate().archive();
+ } finally {
+ closeAndDrop(originalState);
+ }
+
+ KeyValueState recoveredState =
+ StateFactory.buildKeyValueState(descriptor, configuration);
+ try {
+ recoveredState.manage().operate()
+ .setCheckpointId(CHECKPOINT_ID);
+ recoveredState.manage().operate().recover();
+
+ IncrementalTemporalIntegrator recovered =
+ recoveredState.get(FACT_ID);
+ Assertions.assertNotNull(recovered);
+ recovered.apply(retraction);
+ recoveredState.put(FACT_ID, recovered);
+
+ IncrementalTemporalIntegrator updated =
+ recoveredState.get(FACT_ID);
+ Assertions.assertEquals(
+ Arrays.asList(add, correction, retraction),
+ updated.eventSnapshot());
+ Assertions.assertEquals(
+ oracle.replay(Arrays.asList(
+ add,
+ correction,
+ retraction)),
+ updated.snapshot());
+ } finally {
+ closeAndDrop(recoveredState);
+ }
+ }
+
+ private Configuration stateConfiguration() {
+ Map config = new HashMap<>();
+ config.put(
+ ExecutionConfigKeys.JOB_APP_NAME.getKey(),
+ "TemporalStateRecoveryTest");
+ config.put(
+ ExecutionConfigKeys.JOB_WORK_PATH.getKey(),
+ tempDirectory.resolve("work").toString());
+ config.put(
+ FileConfigKeys.PERSISTENT_TYPE.getKey(),
+ "LOCAL");
+ config.put(
+ FileConfigKeys.ROOT.getKey(),
+ tempDirectory.resolve("checkpoints").toString());
+ return new Configuration(config);
+ }
+
+ private static KeyValueStateDescriptor stateDescriptor() {
+ KeyValueStateDescriptor
+ descriptor = KeyValueStateDescriptor.build(
+ "temporal-recovery",
+ StoreType.ROCKSDB.name());
+ descriptor.withKeyGroup(new KeyGroup(0, 0))
+ .withKeyGroupAssigner(new DefaultKeyGroupAssigner(1));
+ return descriptor;
+ }
+
+ private static void closeAndDrop(
+ KeyValueState state) {
+ state.manage().operate().close();
+ state.manage().operate().drop();
+ }
+
+ private static MemoryEvent addEvent() {
+ return addEvent("Beijing");
+ }
+
+ private static MemoryEvent conflictingAddEvent() {
+ return addEvent("Shenzhen");
+ }
+
+ private static MemoryEvent bobAddEvent() {
+ return MemoryEvent.add(
+ "event-bob-add",
+ fact(BOB_FACT_ID, "person:bob", "Paris"),
+ TimeInterval.unboundedFrom(
+ Instant.parse("2024-01-01T00:00:00Z")),
+ Instant.parse("2024-04-01T00:00:00Z"),
+ evidence("event-bob-add"));
+ }
+
+ private static MemoryEvent addEvent(String value) {
+ return MemoryEvent.add(
+ "event-alice-add",
+ fact(value),
+ TimeInterval.unboundedFrom(
+ Instant.parse("2024-01-01T00:00:00Z")),
+ Instant.parse("2024-03-01T00:00:00Z"),
+ evidence("event-alice-add"));
+ }
+
+ private static MemoryEvent correctEvent() {
+ return MemoryEvent.correct(
+ "event-alice-correct",
+ fact("Shanghai"),
+ new TimeInterval(
+ Instant.parse("2024-04-01T00:00:00Z"),
+ Instant.parse("2024-09-01T00:00:00Z")),
+ Instant.parse("2024-06-01T00:00:00Z"),
+ evidence("event-alice-correct"));
+ }
+
+ private static MemoryEvent lateCorrectionEvent() {
+ return MemoryEvent.correct(
+ "event-alice-late",
+ fact("Tianjin"),
+ new TimeInterval(
+ Instant.parse("2024-02-01T00:00:00Z"),
+ Instant.parse("2024-03-01T00:00:00Z")),
+ Instant.parse("2024-05-01T00:00:00Z"),
+ evidence("event-alice-late"));
+ }
+
+ private static MemoryEvent retractEvent() {
+ return MemoryEvent.retract(
+ "event-alice-retract",
+ FACT_ID,
+ new TimeInterval(
+ Instant.parse("2024-08-01T00:00:00Z"),
+ Instant.parse("2024-10-01T00:00:00Z")),
+ Instant.parse("2024-11-01T00:00:00Z"),
+ evidence("event-alice-retract"));
+ }
+
+ private static MemoryFact fact(String value) {
+ return fact(FACT_ID, "person:alice", value);
+ }
+
+ private static MemoryFact fact(
+ String factId,
+ String subjectId,
+ String value) {
+ return MemoryFact.attribute(
+ factId,
+ new MemoryEntity(subjectId, "person"),
+ "city",
+ value);
+ }
+
+ private static List evidence(String eventId) {
+ return Collections.singletonList(new Evidence(
+ "evidence-" + eventId,
+ new Source("source-1", "customer-database"),
+ "Evidence for " + eventId));
+ }
+}
From 3e97e8859d1e1d0f12651d0262bff2e5b636f66b Mon Sep 17 00:00:00 2001
From: Gardenia-zx <3469959715@qq.com>
Date: Sun, 6 Sep 2026 14:25:03 +0800
Subject: [PATCH 14/23] test: verify temporal UDGA local mode
---
geaflow-ai/pom.xml | 11 +
.../ai/temporal/udga/TemporalUdgaProbe.java | 151 ++++++++++++
.../udga/TemporalUdgaFeasibilityTest.java | 230 ++++++++++++++++++
.../temporal/temporal_udga_feasibility.sql | 87 +++++++
4 files changed, 479 insertions(+)
create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaProbe.java
create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaFeasibilityTest.java
create mode 100644 geaflow-ai/src/test/resources/temporal/temporal_udga_feasibility.sql
diff --git a/geaflow-ai/pom.xml b/geaflow-ai/pom.xml
index 78373f2c0..9d450f890 100644
--- a/geaflow-ai/pom.xml
+++ b/geaflow-ai/pom.xml
@@ -121,6 +121,11 @@
geaflow-api${project.version}
+
+ org.apache.geaflow
+ geaflow-dsl-common
+ ${project.version}
+
@@ -135,6 +140,12 @@
${project.version}test
+
+ org.apache.geaflow
+ geaflow-dsl-runtime
+ ${project.version}
+ test
+ org.junit.jupiterjunit-jupiter
diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaProbe.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaProbe.java
new file mode 100644
index 000000000..35472c60d
--- /dev/null
+++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaProbe.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.geaflow.ai.temporal.udga;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import org.apache.geaflow.common.type.primitive.BooleanType;
+import org.apache.geaflow.common.type.primitive.IntegerType;
+import org.apache.geaflow.dsl.common.algo.AlgorithmRuntimeContext;
+import org.apache.geaflow.dsl.common.algo.AlgorithmUserFunction;
+import org.apache.geaflow.dsl.common.algo.IncrementalAlgorithmUserFunction;
+import org.apache.geaflow.dsl.common.data.Row;
+import org.apache.geaflow.dsl.common.data.RowEdge;
+import org.apache.geaflow.dsl.common.data.RowVertex;
+import org.apache.geaflow.dsl.common.data.impl.ObjectRow;
+import org.apache.geaflow.dsl.common.types.GraphSchema;
+import org.apache.geaflow.dsl.common.types.StructType;
+import org.apache.geaflow.dsl.common.types.TableField;
+import org.apache.geaflow.model.graph.edge.EdgeDirection;
+
+public class TemporalUdgaProbe implements
+ AlgorithmUserFunction