diff --git a/.gitignore b/.gitignore index 1c46983c5..3ce7f1130 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ geaflow-console/app/bootstrap/src/main/resources/public # vs code .vscode + +# Python bytecode generated by temporal benchmark scripts +geaflow-ai/bin/__pycache__/ diff --git a/geaflow-ai/bin/requirements-cross-encoder.txt b/geaflow-ai/bin/requirements-cross-encoder.txt new file mode 100644 index 000000000..e8ead4f3c --- /dev/null +++ b/geaflow-ai/bin/requirements-cross-encoder.txt @@ -0,0 +1,20 @@ +# 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. + +# setup-temporal-cross-encoder.ps1 installs torch 2.8.0+cpu or 2.8.0+cu128 from its matching wheel index. +transformers==4.57.1 +numpy==2.3.5 diff --git a/geaflow-ai/bin/score-temporal-cross-encoder.py b/geaflow-ai/bin/score-temporal-cross-encoder.py new file mode 100644 index 000000000..0477fe4ed --- /dev/null +++ b/geaflow-ai/bin/score-temporal-cross-encoder.py @@ -0,0 +1,412 @@ +# 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. + +"""Score exported query/evidence pairs with the pinned MiniLM cross-encoder.""" + +import argparse +import hashlib +import importlib.metadata +import json +import math +import os +from pathlib import Path +import platform +import re +import sys +import time + + +MODEL_ID = "cross-encoder/ms-marco-MiniLM-L6-v2" +MODEL_REVISION = "233902d25c440f23af6f7d6e94d2946bac0bee0a" +DEPENDENCIES = {"torch": "2.8.0", "transformers": "4.57.1", "numpy": "2.3.5"} +QUERY_FIELDS = {"schemaVersion", "queryId", "queryText", "poolSha256", "candidates"} +CANDIDATE_FIELDS = {"evidenceId", "text"} + + +def _object_without_duplicates(pairs): + result = {} + for name, value in pairs: + if name in result: + raise ValueError("Duplicate JSON field: " + name) + result[name] = value + return result + + +def _reject_constant(value): + raise ValueError("Non-finite JSON number: " + value) + + +def _require_fields(value, expected, name): + if not isinstance(value, dict) or set(value) != expected: + raise ValueError(name + " must contain exactly: " + ", ".join(sorted(expected))) + + +def _require_text(value, name): + if not isinstance(value, str) or not value.strip(): + raise ValueError(name + " must be a nonempty string") + + +def file_sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def iter_queries(path, max_candidates=100): + """Validate one query at a time without retaining all evidence texts.""" + if type(max_candidates) is not int or not 100 <= max_candidates <= 1000: + raise ValueError("max-candidates must be between 100 and 1000") + query_ids = set() + with Path(path).open("r", encoding="utf-8", newline="") as source: + for line_number, line in enumerate(source, 1): + try: + row = json.loads(line, object_pairs_hook=_object_without_duplicates, + parse_constant=_reject_constant) + _require_fields(row, QUERY_FIELDS, "Query") + if type(row["schemaVersion"]) is not int or row["schemaVersion"] != 1: + raise ValueError("schemaVersion must be integer 1") + _require_text(row["queryId"], "queryId") + _require_text(row["queryText"], "queryText") + if row["queryId"] in query_ids: + raise ValueError("Duplicate queryId: " + row["queryId"]) + fingerprint = row["poolSha256"] + if not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", fingerprint): + raise ValueError("poolSha256 must contain 64 hexadecimal characters") + candidates = row["candidates"] + if not isinstance(candidates, list) or len(candidates) > max_candidates: + raise ValueError("candidates must be a list containing at most " + + str(max_candidates) + " entries") + evidence_ids = set() + for candidate in candidates: + _require_fields(candidate, CANDIDATE_FIELDS, "Candidate") + _require_text(candidate["evidenceId"], "evidenceId") + _require_text(candidate["text"], "candidate text") + if candidate["evidenceId"] in evidence_ids: + raise ValueError("Duplicate evidenceId: " + candidate["evidenceId"]) + evidence_ids.add(candidate["evidenceId"]) + query_ids.add(row["queryId"]) + yield row + except (ValueError, TypeError) as error: + raise ValueError("Input line " + str(line_number) + ": " + str(error)) from error + if not query_ids: + raise ValueError("Input must contain at least one query") + + +def scan_queries(path, max_candidates=100): + query_count = pair_count = 0 + for row in iter_queries(path, max_candidates): + query_count += 1 + pair_count += len(row["candidates"]) + return query_count, pair_count, file_sha256(path) + + +def read_queries(path, max_candidates=100): + """Compatibility helper; the scoring CLI uses the streaming iterator instead.""" + return list(iter_queries(path, max_candidates)), file_sha256(path) + + +def peak_rss_bytes(): + """OS process-lifetime high-water mark, including model load and Python allocations.""" + if os.name == "nt": + import ctypes + from ctypes import wintypes + + class MemoryCounters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("page_faults", wintypes.DWORD)] + [ + (name, ctypes.c_size_t) for name in ("peak_working_set", "working_set", + "peak_paged", "paged", "peak_nonpaged", + "nonpaged", "pagefile", "peak_pagefile")] + + counters = MemoryCounters() + counters.cb = ctypes.sizeof(counters) + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.GetCurrentProcess.restype = wintypes.HANDLE + psapi = ctypes.WinDLL("psapi", use_last_error=True) + psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(MemoryCounters), + wintypes.DWORD] + if not psapi.GetProcessMemoryInfo(kernel.GetCurrentProcess(), ctypes.byref(counters), counters.cb): + raise ctypes.WinError(ctypes.get_last_error()) + return counters.peak_working_set + import resource + + value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return value if sys.platform == "darwin" else value * 1024 + + +def check_environment(device="cpu"): + if device not in ("cpu", "cuda"): + raise ValueError("device must be cpu or cuda") + versions = {} + failures = [] + if not (3, 11) <= sys.version_info[:2] <= (3, 13): + failures.append("Python 3.11 through 3.13 is required") + for package, expected in DEPENDENCIES.items(): + try: + actual = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + failures.append(package + "==" + expected + " is missing") + continue + versions[package] = actual + if actual.split("+", 1)[0] != expected: + failures.append(package + " must be " + expected + "; found " + actual) + if failures: + instructions = ("Run geaflow-ai/bin/setup-temporal-cross-encoder.ps1 -Device " + device) + raise RuntimeError("; ".join(failures) + "\n" + instructions) + if device == "cuda": + os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8" + import torch + + if not torch.cuda.is_available() or torch.cuda.device_count() < 1 or not torch.version.cuda: + raise RuntimeError("CUDA is unavailable. Check the NVIDIA driver and run " + "geaflow-ai/bin/setup-temporal-cross-encoder.ps1 -Device cuda; " + "CPU fallback is disabled.") + properties = torch.cuda.get_device_properties(0) + versions.update({"cudaVersion": torch.version.cuda, "deviceName": properties.name, + "deviceMemoryBytes": properties.total_memory}) + return versions + + +class TransformerScorer: + def __init__(self, cache_dir, batch_size, max_length, threads, local_files_only, device="cpu"): + if device not in ("cpu", "cuda"): + raise ValueError("device must be cpu or cuda") + if device == "cuda": + os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8" + # Keep imports lazy so protocol validation and unit tests need no model dependencies. + import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + torch.manual_seed(0) + torch.set_num_threads(threads) + torch.set_num_interop_threads(1) + torch.use_deterministic_algorithms(True) + if device == "cuda": + torch.backends.cuda.matmul.allow_tf32 = False + torch.backends.cudnn.allow_tf32 = False + torch.backends.cudnn.benchmark = False + options = {"revision": MODEL_REVISION, "cache_dir": str(cache_dir), + "local_files_only": local_files_only, "trust_remote_code": False} + self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, **options) + self.model = AutoModelForSequenceClassification.from_pretrained( + MODEL_ID, **options, use_safetensors=True, attn_implementation="eager", + dtype=torch.float32) + if self.model.config.num_labels != 1: + raise ValueError("The pinned model must have exactly one output label") + self.model.to(device=device, dtype=torch.float32) + self.model.eval() + self.torch = torch + self.batch_size = batch_size + self.max_length = max_length + self.device = device + + def synchronize(self): + if self.device == "cuda": + self.torch.cuda.synchronize() + + def memory_metrics(self): + if self.device == "cuda": + return {"cudaPeakAllocatedBytes": self.torch.cuda.max_memory_allocated(), + "cudaPeakReservedBytes": self.torch.cuda.max_memory_reserved(), + "cudaPeakScope": "process_lifetime_including_model_load"} + return {} + + def __call__(self, query_text, evidence_texts): + scores = [] + with self.torch.inference_mode(): + for start in range(0, len(evidence_texts), self.batch_size): + texts = evidence_texts[start:start + self.batch_size] + tokens = self.tokenizer([query_text] * len(texts), texts, padding=True, + truncation=True, max_length=self.max_length, + return_tensors="pt") + tokens = {name: tensor.to(device=self.device) for name, tensor in tokens.items()} + logits = self.model(**tokens).logits + if tuple(logits.shape) != (len(texts), 1): + raise ValueError("Unexpected model output shape: " + str(tuple(logits.shape))) + scores.extend(float(value) for value in logits[:, 0].cpu().tolist()) + return scores + + +def validate_output_paths(output, metadata): + if output.resolve() == metadata.resolve(): + raise ValueError("Scores and metadata must have different paths") + for path in (output, metadata): + if path.exists(): + raise FileExistsError("Refusing to overwrite: " + str(path)) + + +def write_scores(rows, input_sha256, output, metadata, scorer, batch_size=16, + max_length=512, threads=1, load_nanos=0, versions=None, progress=None, device="cpu", + query_count=None, max_candidates=100, input_path=None): + if device not in ("cpu", "cuda"): + raise ValueError("device must be cpu or cuda") + if type(max_candidates) is not int or not 100 <= max_candidates <= 1000: + raise ValueError("max-candidates must be between 100 and 1000") + versions = versions or {} + cuda_details = {} + if device == "cuda": + for key in ("cudaVersion", "deviceName"): + _require_text(versions.get(key), key) + cuda_details[key] = versions[key] + memory = versions.get("deviceMemoryBytes") + if type(memory) is not int or memory <= 0: + raise ValueError("deviceMemoryBytes must be a positive integer") + cuda_details["deviceMemoryBytes"] = memory + output, metadata = Path(output), Path(metadata) + validate_output_paths(output, metadata) + output.parent.mkdir(parents=True, exist_ok=True) + metadata.parent.mkdir(parents=True, exist_ok=True) + if query_count is None: + try: + query_count = len(rows) + except TypeError: + pass + inference_nanos = 0 + pair_count = 0 + processed_queries = 0 + scores_digest = hashlib.sha256() + wall_started = time.monotonic_ns() + with output.open("xb") as scores_file: + for index, row in enumerate(rows, 1): + candidates = row["candidates"] + if len(candidates) > max_candidates: + raise ValueError("Candidate pool exceeds max-candidates") + if candidates and device == "cuda": + scorer.synchronize() + started = time.perf_counter_ns() if candidates else 0 + values = list(scorer(row["queryText"], [item["text"] for item in candidates])) if candidates else [] + if candidates and device == "cuda": + scorer.synchronize() + elapsed = time.perf_counter_ns() - started if candidates else 0 + if len(values) != len(candidates): + raise ValueError("Scorer returned the wrong number of scores for " + row["queryId"]) + if any(type(value) not in (int, float) or not math.isfinite(value) for value in values): + raise ValueError("Scorer returned a non-finite or nonnumeric score for " + row["queryId"]) + result = {"schemaVersion": 1, "queryId": row["queryId"], + "poolSha256": row["poolSha256"], "elapsedNanos": elapsed, + "scores": [{"evidenceId": candidate["evidenceId"], "logit": float(value)} + for candidate, value in zip(candidates, values)]} + line = (json.dumps(result, ensure_ascii=False, separators=(",", ":"), + allow_nan=False) + "\n").encode("utf-8") + scores_file.write(line) + scores_digest.update(line) + inference_nanos += elapsed + pair_count += len(candidates) + processed_queries = index + if progress is not None and (index == 1 or index % 10 == 0 or index == query_count): + print("Scored " + str(index) + "/" + str(query_count or "?") + " queries; " + + str(pair_count) + " pairs", + file=progress, flush=True) + if not processed_queries or (query_count is not None and processed_queries != query_count): + raise ValueError("Input query count changed while scoring") + if input_path is not None and file_sha256(input_path) != input_sha256: + raise ValueError("Input changed after validation") + memory = {} + try: + memory["peakRssBytes"] = peak_rss_bytes() + except (OSError, ImportError) as error: + memory.update({"peakRssBytes": None, "peakRssError": str(error)}) + memory["peakRssScope"] = "process_lifetime_including_model_load" + memory["peakRssMethod"] = "GetProcessMemoryInfo.PeakWorkingSetSize" if os.name == "nt" else "getrusage.ru_maxrss" + if hasattr(scorer, "memory_metrics"): + measured = scorer.memory_metrics() + if isinstance(measured, dict): + memory.update(measured) + result = {"schemaVersion": 1, "modelId": MODEL_ID, "modelRevision": MODEL_REVISION, + "device": device, "batchSize": batch_size, "maxLength": max_length, + "threads": threads, "inputSha256": input_sha256, + "scoresSha256": scores_digest.hexdigest(), + "queryCount": processed_queries, "pairCount": pair_count, "loadNanos": load_nanos, + "inferenceNanos": inference_nanos, "pythonVersion": platform.python_version(), + "torchVersion": versions.get("torch", "unknown"), + "transformersVersion": versions.get("transformers", "unknown"), + "scoreKind": "raw_logit", "maxCandidates": max_candidates, + "timingScope": "complete_pool", + "queryElapsedNanosScope": "scores.jsonl elapsedNanos measures every candidate: tokenizer+forward+device synchronization", + "loadNanosScope": "tokenizer and model load plus selected-device transfer; excludes input validation", + "scoringWallNanos": time.monotonic_ns() - wall_started, + "scoringWallScope": "stream reading, inference, score writing, final input hash; excludes model load and preflight"} + result.update(cuda_details) + result.update(memory) + # Metadata is the completion marker: leave it absent if scoring fails. + with metadata.open("x", encoding="utf-8", newline="\n") as metadata_file: + metadata_file.write(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n") + return result + + +def _positive_integer(text): + value = int(text) + if value <= 0: + raise argparse.ArgumentTypeError("Value must be positive") + return value + + +def _candidate_limit(text): + value = int(text) + if not 100 <= value <= 1000: + raise argparse.ArgumentTypeError("max-candidates must be between 100 and 1000") + return value + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--metadata", type=Path) + parser.add_argument("--cache-dir", type=Path) + parser.add_argument("--batch-size", type=_positive_integer, default=16) + parser.add_argument("--max-length", type=_positive_integer, default=512) + parser.add_argument("--max-candidates", type=_candidate_limit, default=100) + parser.add_argument("--threads", type=_positive_integer, default=1) + parser.add_argument("--device", choices=("cpu", "cuda"), default="cpu") + parser.add_argument("--local-files-only", action="store_true") + parser.add_argument("--check-environment", action="store_true") + args = parser.parse_args(argv) + try: + if args.check_environment: + print(json.dumps(check_environment(args.device), sort_keys=True)) + return 0 + if any(value is None for value in (args.input, args.output, args.metadata, args.cache_dir)): + parser.error("--input, --output, --metadata and --cache-dir are required for scoring") + if args.max_length > 512: + raise ValueError("The pinned MiniLM model supports max-length at most 512") + validate_output_paths(args.output, args.metadata) + query_count, _, input_sha256 = scan_queries(args.input, args.max_candidates) + versions = check_environment(args.device) + print("Loading pinned " + MODEL_ID + " on " + args.device.upper() + "...", + file=sys.stderr, flush=True) + started = time.perf_counter_ns() + scorer = TransformerScorer(args.cache_dir, args.batch_size, args.max_length, + args.threads, args.local_files_only, device=args.device) + if args.device == "cuda": + scorer.synchronize() + load_nanos = time.perf_counter_ns() - started + write_scores(iter_queries(args.input, args.max_candidates), input_sha256, + args.output, args.metadata, scorer, + args.batch_size, args.max_length, args.threads, load_nanos, + versions, sys.stderr, device=args.device, query_count=query_count, + max_candidates=args.max_candidates, input_path=args.input) + print("Completed local scoring: " + str(args.metadata), file=sys.stderr, flush=True) + return 0 + except Exception as error: + print("[ERROR] " + str(error), file=sys.stderr, flush=True) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/geaflow-ai/bin/test_temporal_cross_encoder.py b/geaflow-ai/bin/test_temporal_cross_encoder.py new file mode 100644 index 000000000..fd492ad37 --- /dev/null +++ b/geaflow-ai/bin/test_temporal_cross_encoder.py @@ -0,0 +1,437 @@ +# 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. + +"""Protocol tests use only the standard library and never load model weights.""" + +import hashlib +from contextlib import nullcontext +import importlib.util +import io +import json +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + + +MODULE_PATH = Path(__file__).with_name("score-temporal-cross-encoder.py") +SPEC = importlib.util.spec_from_file_location("temporal_cross_encoder", MODULE_PATH) +SCORER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SCORER) + + +class CrossEncoderProtocolTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.input = self.root / "input.jsonl" + self.output = self.root / "scores.jsonl" + self.metadata = self.root / "metadata.json" + + @staticmethod + def query(query_id="q1"): + return {"schemaVersion": 1, "queryId": query_id, "queryText": "地球 is warming?", + "poolSha256": "a" * 64, + "candidates": [{"evidenceId": "e2", "text": "Second evidence"}, + {"evidenceId": "e1", "text": "First evidence"}]} + + def write_input(self, rows): + self.input.write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), + encoding="utf-8") + + def test_rejects_unexpected_fields_types_and_candidate_limits(self): + invalid = [] + row = self.query() + row["gold"] = ["e1"] + invalid.append(row) + row = self.query() + row["candidates"][0]["label"] = "SUPPORTS" + invalid.append(row) + row = self.query() + row["schemaVersion"] = True + invalid.append(row) + row = self.query() + row["poolSha256"] = "not-a-fingerprint" + invalid.append(row) + row = self.query() + row["queryText"] = " " + invalid.append(row) + row = self.query() + row["candidates"] = [{"evidenceId": str(index), "text": "text"} for index in range(101)] + invalid.append(row) + for row in invalid: + with self.subTest(row=row): + self.write_input([row]) + with self.assertRaises(ValueError): + SCORER.read_queries(self.input) + + def test_rejects_duplicate_query_evidence_and_json_fields(self): + self.write_input([self.query(), self.query()]) + with self.assertRaisesRegex(ValueError, "Duplicate queryId"): + SCORER.read_queries(self.input) + row = self.query() + row["candidates"][1]["evidenceId"] = "e2" + self.write_input([row]) + with self.assertRaisesRegex(ValueError, "Duplicate evidenceId"): + SCORER.read_queries(self.input) + self.input.write_text('{"schemaVersion":1,"schemaVersion":1}\n', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "Duplicate JSON field"): + SCORER.read_queries(self.input) + + def test_scores_only_text_preserves_order_hashes_and_progress(self): + self.write_input([self.query("q" + str(index)) for index in range(11)]) + rows, fingerprint = SCORER.read_queries(self.input) + calls = [] + + def fake(query_text, evidence_texts): + calls.append((query_text, evidence_texts)) + return [-2.5, 3.25] + + progress = io.StringIO() + result = SCORER.write_scores(rows, fingerprint, self.output, self.metadata, fake, + versions={"torch": "fake", "transformers": "fake"}, + progress=progress) + output = [json.loads(line) for line in self.output.read_text(encoding="utf-8").splitlines()] + self.assertEqual(calls[0], ("地球 is warming?", ["Second evidence", "First evidence"])) + self.assertEqual(output[0]["scores"], [{"evidenceId": "e2", "logit": -2.5}, + {"evidenceId": "e1", "logit": 3.25}]) + self.assertEqual(output[0]["poolSha256"], "a" * 64) + self.assertGreaterEqual(output[0]["elapsedNanos"], 0) + self.assertEqual(result["inputSha256"], hashlib.sha256(self.input.read_bytes()).hexdigest()) + self.assertEqual(result["scoresSha256"], hashlib.sha256(self.output.read_bytes()).hexdigest()) + self.assertEqual(result["pairCount"], 22) + self.assertEqual(result["queryCount"], 11) + self.assertEqual(result["modelId"], SCORER.MODEL_ID) + self.assertEqual(result["modelRevision"], SCORER.MODEL_REVISION) + self.assertEqual(json.loads(self.metadata.read_text(encoding="utf-8")), result) + self.assertIn("Scored 1/11", progress.getvalue()) + self.assertIn("Scored 10/11", progress.getvalue()) + self.assertIn("Scored 11/11", progress.getvalue()) + + def test_empty_candidate_query_is_preserved_without_inference(self): + row = self.query() + row["candidates"] = [] + self.write_input([row]) + rows, fingerprint = SCORER.read_queries(self.input) + + def forbidden(*unused): + self.fail("An empty candidate pool must not call the scorer") + + result = SCORER.write_scores(rows, fingerprint, self.output, self.metadata, forbidden) + self.assertEqual(result["pairCount"], 0) + output = json.loads(self.output.read_text(encoding="utf-8")) + self.assertEqual(output["scores"], []) + self.assertEqual(output["elapsedNanos"], 0) + + def test_default_limit_is_100_and_larger_pools_require_explicit_bound(self): + row = self.query() + row["candidates"] = [{"evidenceId": "e" + str(index), "text": "evidence " + str(index)} + for index in range(500)] + self.write_input([row]) + with self.assertRaisesRegex(ValueError, "at most 100 entries"): + SCORER.read_queries(self.input) + count, pairs, digest = SCORER.scan_queries(self.input, 500) + self.assertEqual((count, pairs), (1, 500)) + self.assertEqual(digest, SCORER.file_sha256(self.input)) + for limit in (99, 1001, True): + with self.subTest(limit=limit), self.assertRaisesRegex(ValueError, "100 and 1000"): + list(SCORER.iter_queries(self.input, limit)) + row["candidates"].append({"evidenceId": "e500", "text": "one too many"}) + self.write_input([row]) + with self.assertRaisesRegex(ValueError, "at most 500 entries"): + list(SCORER.iter_queries(self.input, 500)) + + def test_streaming_iterator_does_not_preload_later_queries(self): + self.write_input([self.query()]) + with self.input.open("a", encoding="utf-8") as source: + source.write('{"not":"a query"}\n') + rows = SCORER.iter_queries(self.input) + self.assertEqual(next(rows)["queryId"], "q1") + with self.assertRaisesRegex(ValueError, "Input line 2"): + next(rows) + + def test_main_streams_500_pools_and_hashes_without_whole_file_read(self): + first, second = self.query("q1"), self.query("q2") + for row in (first, second): + row["candidates"] = [{"evidenceId": "e" + str(index), "text": "证据 " + str(index)} + for index in range(500)] + self.write_input([first, second]) + calls = [] + + def fake(query, texts): + calls.append(len(texts)) + return [index / 10 for index in range(len(texts))] + + with patch.object(SCORER, "check_environment", return_value={}), \ + patch.object(SCORER, "TransformerScorer", return_value=fake), \ + patch.object(SCORER, "read_queries", side_effect=AssertionError("preloaded input")), \ + patch.object(Path, "read_bytes", side_effect=AssertionError("whole-file read")), \ + patch("sys.stderr", new=io.StringIO()) as progress: + result = SCORER.main(["--input", str(self.input), "--output", str(self.output), + "--metadata", str(self.metadata), "--cache-dir", str(self.root), + "--max-candidates", "500"]) + self.assertEqual(result, 0) + self.assertEqual(calls, [500, 500]) + self.assertIn("Scored 2/2 queries", progress.getvalue()) + metadata = json.loads(self.metadata.read_text(encoding="utf-8")) + self.assertEqual(metadata["maxCandidates"], 500) + self.assertEqual(metadata["queryCount"], 2) + self.assertEqual(metadata["pairCount"], 1000) + self.assertEqual(metadata["inputSha256"], SCORER.file_sha256(self.input)) + self.assertEqual(metadata["scoresSha256"], SCORER.file_sha256(self.output)) + self.assertEqual(metadata["timingScope"], "complete_pool") + self.assertEqual(metadata["peakRssScope"], "process_lifetime_including_model_load") + self.assertGreater(metadata["peakRssBytes"], 0) + self.assertGreaterEqual(metadata["scoringWallNanos"], metadata["inferenceNanos"]) + self.assertIn("excludes model load", metadata["scoringWallScope"]) + + def test_generator_without_length_and_count_mismatch_completion(self): + rows = (self.query("q" + str(index)) for index in range(2)) + result = SCORER.write_scores(rows, "a" * 64, self.output, self.metadata, + lambda query, texts: [-1, 1]) + self.assertEqual(result["queryCount"], 2) + self.assertEqual(result["pairCount"], 4) + self.output.unlink() + self.metadata.unlink() + with self.assertRaisesRegex(ValueError, "query count changed"): + SCORER.write_scores(iter([self.query()]), "a" * 64, self.output, self.metadata, + lambda query, texts: [-1, 1], query_count=2) + self.assertFalse(self.metadata.exists()) + + def test_input_change_after_validation_does_not_publish_completion(self): + row = self.query() + self.write_input([row]) + digest = SCORER.file_sha256(self.input) + + def tamper(query, texts): + changed = self.query() + changed["queryText"] = "changed while scoring" + self.write_input([changed]) + return [-1, 1] + + with self.assertRaisesRegex(ValueError, "Input changed after validation"): + SCORER.write_scores(iter([row]), digest, self.output, self.metadata, tamper, + input_path=self.input) + self.assertTrue(self.output.exists()) + self.assertFalse(self.metadata.exists()) + + @unittest.skipUnless(SCORER.os.name == "nt", "Windows memory API") + def test_windows_peak_rss_uses_pointer_sized_process_handle(self): + import ctypes + from ctypes import wintypes + + wide_handle = (1 << 48) + 17 + kernel, psapi = Mock(), Mock() + kernel.GetCurrentProcess.return_value = wide_handle + + def memory(handle, counters, size): + self.assertEqual(handle, wide_handle) + self.assertEqual(size, ctypes.sizeof(counters._obj)) + counters._obj.peak_working_set = 123456 + return 1 + + psapi.GetProcessMemoryInfo.side_effect = memory + with patch.object(ctypes, "WinDLL", side_effect=[kernel, psapi]): + self.assertEqual(SCORER.peak_rss_bytes(), 123456) + self.assertIs(kernel.GetCurrentProcess.restype, wintypes.HANDLE) + self.assertIs(psapi.GetProcessMemoryInfo.argtypes[0], wintypes.HANDLE) + + def test_invalid_scores_leave_no_completion_metadata(self): + for values in ([float("nan"), 0], [float("inf"), 0], [True, 0], ["1", 0], [1]): + with self.subTest(values=values): + self.output.unlink(missing_ok=True) + with self.assertRaises(ValueError): + SCORER.write_scores([self.query()], "a" * 64, self.output, self.metadata, + lambda query, texts: values) + self.assertFalse(self.metadata.exists()) + + def test_model_failure_leaves_no_completion_metadata(self): + def fail(query, texts): + raise RuntimeError("fake inference failure") + + with self.assertRaisesRegex(RuntimeError, "fake inference failure"): + SCORER.write_scores([self.query()], "a" * 64, self.output, self.metadata, fail) + self.assertFalse(self.metadata.exists()) + + def test_refuses_overwriting_either_output(self): + for existing in (self.output, self.metadata): + with self.subTest(existing=existing): + existing.write_text("keep this file", encoding="utf-8") + with self.assertRaises(FileExistsError): + SCORER.write_scores([self.query()], "a" * 64, self.output, self.metadata, + lambda query, texts: [0, 0]) + self.assertEqual(existing.read_text(encoding="utf-8"), "keep this file") + existing.unlink() + with self.assertRaises(ValueError): + SCORER.write_scores([self.query()], "a" * 64, self.output, self.output, + lambda query, texts: [0, 0]) + + def test_check_environment_does_not_load_model(self): + with patch.object(SCORER, "check_environment", return_value={"torch": "2.8.0+cpu"}), \ + patch.object(SCORER, "TransformerScorer", side_effect=AssertionError("loaded model")), \ + patch("sys.stdout", new=io.StringIO()): + self.assertEqual(SCORER.main(["--check-environment"]), 0) + + def test_cpu_environment_check_does_not_import_torch(self): + with patch.object(SCORER.importlib.metadata, "version", + side_effect=lambda name: SCORER.DEPENDENCIES[name]), \ + patch.dict(sys.modules, {"torch": None}): + self.assertEqual(SCORER.check_environment(), SCORER.DEPENDENCIES) + + def test_cuda_environment_requires_available_gpu_without_fallback(self): + for available, count, cuda_version in ((False, 0, None), (True, 0, "12.8"), + (True, 1, None)): + with self.subTest(available=available, count=count, cuda_version=cuda_version): + torch = SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: available, + device_count=lambda: count), + version=SimpleNamespace(cuda=cuda_version)) + with patch.object(SCORER.importlib.metadata, "version", + side_effect=lambda name: SCORER.DEPENDENCIES[name]), \ + patch.dict(sys.modules, {"torch": torch}), \ + patch.dict(SCORER.os.environ): + with self.assertRaisesRegex(RuntimeError, "-Device cuda"): + SCORER.check_environment("cuda") + + def test_cuda_environment_reports_actual_device_without_loading_model(self): + properties = SimpleNamespace(name="Fake GPU", total_memory=8589934592) + torch = SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True, + device_count=lambda: 1, + get_device_properties=lambda index: properties), + version=SimpleNamespace(cuda="12.8")) + with patch.object(SCORER.importlib.metadata, "version", + side_effect=lambda name: SCORER.DEPENDENCIES[name]), \ + patch.dict(sys.modules, {"torch": torch}), patch.dict(SCORER.os.environ), \ + patch.object(SCORER, "TransformerScorer", side_effect=AssertionError("loaded model")): + result = SCORER.check_environment("cuda") + self.assertEqual(result["cudaVersion"], "12.8") + self.assertEqual(result["deviceName"], "Fake GPU") + self.assertEqual(result["deviceMemoryBytes"], 8589934592) + self.assertEqual(SCORER.os.environ["CUBLAS_WORKSPACE_CONFIG"], ":4096:8") + + def test_cuda_scoring_synchronizes_timer_and_records_device(self): + events = [] + scorer = Mock(side_effect=lambda query, texts: events.append("score") or [-1, 1]) + scorer.synchronize.side_effect = lambda: events.append("sync") + ticks = iter((10, 30)) + + def clock(): + events.append("clock") + return next(ticks) + + versions = {"torch": "2.8.0+cu128", "transformers": "4.57.1", "cudaVersion": "12.8", + "deviceName": "Fake GPU", "deviceMemoryBytes": 8589934592} + with patch.object(SCORER.time, "perf_counter_ns", side_effect=clock): + result = SCORER.write_scores([self.query()], "a" * 64, self.output, self.metadata, + scorer, versions=versions, device="cuda") + self.assertEqual(events, ["sync", "clock", "score", "sync", "clock"]) + self.assertEqual(result["device"], "cuda") + self.assertEqual(result["cudaVersion"], "12.8") + self.assertEqual(result["deviceName"], "Fake GPU") + self.assertEqual(result["deviceMemoryBytes"], 8589934592) + self.assertEqual(result["inferenceNanos"], 20) + + def test_cuda_memory_telemetry_reports_allocated_reserved_and_scope(self): + scorer = Mock(return_value=[-1, 1]) + scorer.memory_metrics.return_value = {"cudaPeakAllocatedBytes": 1234, + "cudaPeakReservedBytes": 2048, + "cudaPeakScope": "process_lifetime_including_model_load"} + versions = {"cudaVersion": "12.8", "deviceName": "Fake GPU", + "deviceMemoryBytes": 8589934592} + with patch.object(SCORER, "peak_rss_bytes", return_value=4096): + result = SCORER.write_scores(iter([self.query()]), "a" * 64, + self.output, self.metadata, scorer, + versions=versions, device="cuda") + self.assertEqual(result["peakRssBytes"], 4096) + self.assertEqual(result["cudaPeakAllocatedBytes"], 1234) + self.assertEqual(result["cudaPeakReservedBytes"], 2048) + self.assertEqual(result["cudaPeakScope"], "process_lifetime_including_model_load") + self.assertEqual(result["timingScope"], "complete_pool") + self.assertIn("every candidate", result["queryElapsedNanosScope"]) + + def test_cuda_scoring_requires_device_metadata_before_output(self): + with self.assertRaisesRegex(ValueError, "cudaVersion"): + SCORER.write_scores([self.query()], "a" * 64, self.output, self.metadata, + Mock(), device="cuda") + self.assertFalse(self.output.exists()) + self.assertFalse(self.metadata.exists()) + + def test_transformer_moves_model_and_tokens_to_selected_device(self): + tensor = Mock() + tensor.to.return_value = tensor + logits = Mock() + logits.shape = (2, 1) + logits.__getitem__ = Mock(return_value=SimpleNamespace( + cpu=lambda: SimpleNamespace(tolist=lambda: [-1.5, 2.0]))) + model = Mock() + model.config.num_labels = 1 + model.return_value.logits = logits + tokenizer = Mock(return_value={"input_ids": tensor, "attention_mask": tensor}) + torch = SimpleNamespace(manual_seed=Mock(), set_num_threads=Mock(), + set_num_interop_threads=Mock(), use_deterministic_algorithms=Mock(), + float32="float32", inference_mode=nullcontext, + cuda=SimpleNamespace(synchronize=Mock(), + max_memory_allocated=Mock(return_value=8192), + max_memory_reserved=Mock(return_value=16384)), + backends=SimpleNamespace(cuda=SimpleNamespace(matmul=SimpleNamespace()), + cudnn=SimpleNamespace())) + transformers = SimpleNamespace( + AutoTokenizer=SimpleNamespace(from_pretrained=Mock(return_value=tokenizer)), + AutoModelForSequenceClassification=SimpleNamespace(from_pretrained=Mock(return_value=model))) + with patch.dict(sys.modules, {"torch": torch, "transformers": transformers}), \ + patch.dict(SCORER.os.environ): + scorer = SCORER.TransformerScorer(self.root, 16, 512, 1, True, device="cuda") + self.assertEqual(scorer("query", ["first", "second"]), [-1.5, 2.0]) + scorer.synchronize() + self.assertEqual(scorer.memory_metrics(), { + "cudaPeakAllocatedBytes": 8192, "cudaPeakReservedBytes": 16384, + "cudaPeakScope": "process_lifetime_including_model_load"}) + model.to.assert_called_once_with(device="cuda", dtype="float32") + self.assertEqual(tensor.to.call_count, 2) + tensor.to.assert_called_with(device="cuda") + torch.cuda.synchronize.assert_called_once_with() + self.assertFalse(torch.backends.cuda.matmul.allow_tf32) + self.assertFalse(torch.backends.cudnn.allow_tf32) + expected = {"revision": SCORER.MODEL_REVISION, "cache_dir": str(self.root), + "local_files_only": True, "trust_remote_code": False} + transformers.AutoTokenizer.from_pretrained.assert_called_once_with(SCORER.MODEL_ID, + **expected) + transformers.AutoModelForSequenceClassification.from_pretrained.assert_called_once_with( + SCORER.MODEL_ID, **expected, use_safetensors=True, attn_implementation="eager", + dtype="float32") + + def test_main_passes_device_and_waits_for_model_load(self): + self.write_input([self.query()]) + scorer = Mock(return_value=[-1, 1]) + versions = {"cudaVersion": "12.8", "deviceName": "Fake GPU", + "deviceMemoryBytes": 8589934592} + with patch.object(SCORER, "check_environment", return_value=versions) as check, \ + patch.object(SCORER, "TransformerScorer", return_value=scorer) as create, \ + patch("sys.stderr", new=io.StringIO()): + result = SCORER.main(["--input", str(self.input), "--output", str(self.output), + "--metadata", str(self.metadata), "--cache-dir", str(self.root), + "--device", "cuda"]) + self.assertEqual(result, 0) + check.assert_called_once_with("cuda") + create.assert_called_once_with(self.root, 16, 512, 1, False, device="cuda") + self.assertEqual(scorer.synchronize.call_count, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/geaflow-ai/pom.xml b/geaflow-ai/pom.xml index 006215b74..e05b5c0eb 100644 --- a/geaflow-ai/pom.xml +++ b/geaflow-ai/pom.xml @@ -84,6 +84,13 @@ ${lucene.version} + + + org.apache.lucene + lucene-analyzers-common + ${lucene.version} + + org.apache.lucene @@ -121,8 +128,31 @@ geaflow-api ${project.version} + + org.apache.geaflow + geaflow-dsl-common + ${project.version} + + + org.apache.geaflow + geaflow-pipeline + ${project.version} + test + + + org.apache.geaflow + geaflow-on-local + ${project.version} + test + + + org.apache.geaflow + geaflow-dsl-runtime + ${project.version} + test + org.junit.jupiter junit-jupiter @@ -146,6 +176,17 @@ + + org.apache.rat + apache-rat-plugin + + + + src/main/resources/temporal/example-events.jsonl + src/main/resources/temporal/example-queries.jsonl + + + maven-assembly-plugin @@ -154,9 +195,9 @@ org.apache.geaflow.ai.client.GeaFlowMemoryClientCLI - - jar-with-dependencies - + + src/assembly/jar-with-dependencies.xml + diff --git a/geaflow-ai/src/assembly/jar-with-dependencies.xml b/geaflow-ai/src/assembly/jar-with-dependencies.xml new file mode 100644 index 000000000..665e3d177 --- /dev/null +++ b/geaflow-ai/src/assembly/jar-with-dependencies.xml @@ -0,0 +1,42 @@ + + + + jar-with-dependencies + + jar + + false + + + + metaInf-services + + + + + / + true + true + runtime + + + diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/adapter/MemoryGraphAdapter.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/adapter/MemoryGraphAdapter.java new file mode 100644 index 000000000..4b4786ddc --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/adapter/MemoryGraphAdapter.java @@ -0,0 +1,1253 @@ +/* + * 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.adapter; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import org.apache.geaflow.ai.graph.io.Edge; +import org.apache.geaflow.ai.graph.io.EdgeGroup; +import org.apache.geaflow.ai.graph.io.EdgeSchema; +import org.apache.geaflow.ai.graph.io.EntityGroup; +import org.apache.geaflow.ai.graph.io.GraphSchema; +import org.apache.geaflow.ai.graph.io.MemoryGraph; +import org.apache.geaflow.ai.graph.io.Vertex; +import org.apache.geaflow.ai.graph.io.VertexGroup; +import org.apache.geaflow.ai.graph.io.VertexSchema; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEntity; +import org.apache.geaflow.ai.temporal.model.MemoryEvent; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; + +/** + * Projects canonical temporal snapshots to the existing in-memory graph model. + */ +public final class MemoryGraphAdapter { + + private static final String ENTITY = "entity"; + private static final String FACT_VERSION = "fact_version"; + private static final String MEMORY_EVENT = "memory_event"; + private static final String EVIDENCE = "evidence"; + private static final String SOURCE = "source"; + + private static final String SUBJECT = "subject"; + private static final String OBJECT = "object"; + private static final String GENERATES = "generates"; + private static final String SUPPORTED_BY = "supported_by"; + private static final String FROM_SOURCE = "from_source"; + private static final String SUPERSEDES = "supersedes"; + private static final String DUPLICATE_OF = "duplicate_of"; + private static final String CONFLICTS_WITH = "conflicts_with"; + + private static final String ENTITY_PREFIX = "entity:"; + private static final String VERSION_PREFIX = "version:"; + private static final String EVENT_PREFIX = "event:"; + private static final String EVIDENCE_PREFIX = "evidence:"; + private static final String SOURCE_PREFIX = "source:"; + private static final String EMPTY = ""; + + private static final List ENTITY_FIELDS = fields("label"); + private static final List VERSION_FIELDS = fields( + "factId", + "predicate", + "scope", + "valueKind", + "literalValue", + "status", + "validStart", + "validEnd", + "transactionStart", + "transactionEnd"); + private static final List EVENT_FIELDS = fields( + "operation", + "factId", + "subjectId", + "predicate", + "scope", + "valueKind", + "value", + "validStart", + "validEnd", + "recordedAt", + "targetVersionId", + "payloadHash"); + private static final List EVIDENCE_FIELDS = fields("content"); + private static final List SOURCE_FIELDS = fields("name"); + private static final List NO_FIELDS = Collections.emptyList(); + private static final List COUNT_FIELDS = fields( + "occurrenceCount"); + private static final List RELATION_FIELDS = fields( + "relationId", "occurrenceCount"); + + private static final List VERTEX_LABELS = fields( + ENTITY, FACT_VERSION, MEMORY_EVENT, EVIDENCE, SOURCE); + private static final List EDGE_LABELS = fields( + SUBJECT, + OBJECT, + GENERATES, + SUPPORTED_BY, + FROM_SOURCE, + SUPERSEDES, + DUPLICATE_OF, + CONFLICTS_WITH); + + private static final Comparator VERTEX_ORDER = + Comparator.comparing(Vertex::getId); + private static final Comparator EDGE_ORDER = + Comparator.comparing(Edge::getSrcId) + .thenComparing(Edge::getDstId) + .thenComparing(edge -> edge.getValues().toString()); + + public MemoryGraph toGraph(CanonicalSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + + Map memoryEntities = new TreeMap<>(); + Map evidenceById = new TreeMap<>(); + Map sources = new TreeMap<>(); + Map versionVertices = new TreeMap<>(); + Map eventVertices = new TreeMap<>(); + Map> edges = emptyEdgeLists(); + Map supported = new TreeMap<>(); + Map> relationEdges = + relationEdgeMaps(); + + for (NormalizedMemoryEvent event : snapshot.getEvents()) { + collectEventEntities(event, memoryEntities); + collectEvidence(event.getEvidence(), evidenceById, sources); + String eventGraphId = eventId(event.getEventId()); + putUniqueVertex( + eventVertices, + new Vertex(MEMORY_EVENT, eventGraphId, eventValues(event))); + for (Evidence evidence : event.getEvidence()) { + addCountedEdge( + supported, + eventGraphId, + evidenceId(evidence.getId())); + } + } + + for (Map.Entry> entry + : snapshot.getState().getVersionsByFactKey().entrySet()) { + FactKey key = entry.getKey(); + for (MemoryFactVersion version : entry.getValue()) { + MemoryFact fact = version.getFact(); + collectEntity(memoryEntities, fact.getSubject()); + if (fact.isRelationship()) { + collectEntity(memoryEntities, fact.getTarget().get()); + } + collectEvidence(version.getEvidence(), evidenceById, sources); + + String versionGraphId = versionId(version.getId()); + putUniqueVertex( + versionVertices, + new Vertex( + FACT_VERSION, + versionGraphId, + versionValues(version, key))); + edges.get(SUBJECT).add(new Edge( + SUBJECT, + versionGraphId, + entityId(fact.getSubject().getId()), + NO_FIELDS)); + if (fact.isRelationship()) { + edges.get(OBJECT).add(new Edge( + OBJECT, + versionGraphId, + entityId(fact.getTarget().get().getId()), + NO_FIELDS)); + } + String generatingEventId = + snapshot.getGeneratingEventIds().get(version.getId()); + edges.get(GENERATES).add(new Edge( + GENERATES, + eventId(generatingEventId), + versionGraphId, + NO_FIELDS)); + for (Evidence evidence : version.getEvidence()) { + addCountedEdge( + supported, + versionGraphId, + evidenceId(evidence.getId())); + } + } + } + + for (VersionRelation relation + : snapshot.getState().getRelations()) { + String label = relationLabel(relation.getType()); + addCountedEdge( + relationEdges.get(label), + versionId(relation.getFromVersionId()), + versionId(relation.getToVersionId())); + } + + List entityVertices = new ArrayList<>(); + for (MemoryEntity entity : memoryEntities.values()) { + entityVertices.add(new Vertex( + ENTITY, + entityId(entity.getId()), + fields(entity.getLabel()))); + } + List evidenceVertices = new ArrayList<>(); + for (Evidence evidence : evidenceById.values()) { + evidenceVertices.add(new Vertex( + EVIDENCE, + evidenceId(evidence.getId()), + fields(evidence.getContent()))); + edges.get(FROM_SOURCE).add(new Edge( + FROM_SOURCE, + evidenceId(evidence.getId()), + sourceId(evidence.getSource().getId()), + NO_FIELDS)); + } + List sourceVertices = new ArrayList<>(); + for (Source source : sources.values()) { + sourceVertices.add(new Vertex( + SOURCE, + sourceId(source.getId()), + fields(source.getName()))); + } + + edges.put( + SUPPORTED_BY, + countedEdges(SUPPORTED_BY, supported, false)); + for (String label : Arrays.asList( + SUPERSEDES, DUPLICATE_OF, CONFLICTS_WITH)) { + edges.put( + label, + countedEdges(label, relationEdges.get(label), true)); + } + + Map> vertices = new LinkedHashMap<>(); + vertices.put(ENTITY, entityVertices); + vertices.put( + FACT_VERSION, + new ArrayList<>(versionVertices.values())); + vertices.put( + MEMORY_EVENT, + new ArrayList<>(eventVertices.values())); + vertices.put(EVIDENCE, evidenceVertices); + vertices.put(SOURCE, sourceVertices); + return createGraph(vertices, edges); + } + + public CanonicalSnapshot fromGraph(MemoryGraph graph) { + Objects.requireNonNull(graph, "graph"); + validateGraphShape(graph); + + Map entityVertices = + vertexIndex(graph, ENTITY, ENTITY_PREFIX, ENTITY_FIELDS); + Map versionVertices = + vertexIndex( + graph, + FACT_VERSION, + VERSION_PREFIX, + VERSION_FIELDS); + Map eventVertices = + vertexIndex( + graph, + MEMORY_EVENT, + EVENT_PREFIX, + EVENT_FIELDS); + Map evidenceVertices = + vertexIndex( + graph, + EVIDENCE, + EVIDENCE_PREFIX, + EVIDENCE_FIELDS); + Map sourceVertices = + vertexIndex(graph, SOURCE, SOURCE_PREFIX, SOURCE_FIELDS); + Map> edges = edgeIndex(graph); + + Map memoryEntities = + readEntities(entityVertices); + Map sources = readSources(sourceVertices); + Map evidence = readEvidence( + evidenceVertices, + sources, + edges.get(FROM_SOURCE)); + Map> evidenceByOwner = + readSupportedEvidence( + edges.get(SUPPORTED_BY), + eventVertices, + versionVertices, + evidence); + + Map events = readEvents( + eventVertices, + memoryEntities, + evidenceByOwner); + Map> versions = readVersions( + versionVertices, + memoryEntities, + evidenceByOwner, + edges.get(SUBJECT), + edges.get(OBJECT)); + validateNoOrphanVertices( + memoryEntities, + evidence, + sources, + events, + versions); + Map generating = readGeneratingEvents( + edges.get(GENERATES), + events, + versionVertices); + List relations = readRelations( + edges, + versionVertices); + + return new CanonicalSnapshot( + new TemporalState(versions, relations), + new ArrayList<>(events.values()), + generating); + } + + private static MemoryGraph createGraph( + Map> vertices, + Map> edges) { + GraphSchema schema = new GraphSchema(); + Map vertexSchemas = new LinkedHashMap<>(); + addVertexSchema(schema, vertexSchemas, ENTITY, ENTITY_FIELDS); + addVertexSchema( + schema, vertexSchemas, FACT_VERSION, VERSION_FIELDS); + addVertexSchema( + schema, vertexSchemas, MEMORY_EVENT, EVENT_FIELDS); + addVertexSchema(schema, vertexSchemas, EVIDENCE, EVIDENCE_FIELDS); + addVertexSchema(schema, vertexSchemas, SOURCE, SOURCE_FIELDS); + + Map edgeSchemas = new LinkedHashMap<>(); + addEdgeSchema(schema, edgeSchemas, SUBJECT, NO_FIELDS); + addEdgeSchema(schema, edgeSchemas, OBJECT, NO_FIELDS); + addEdgeSchema(schema, edgeSchemas, GENERATES, NO_FIELDS); + addEdgeSchema(schema, edgeSchemas, SUPPORTED_BY, COUNT_FIELDS); + addEdgeSchema(schema, edgeSchemas, FROM_SOURCE, NO_FIELDS); + addEdgeSchema(schema, edgeSchemas, SUPERSEDES, RELATION_FIELDS); + addEdgeSchema(schema, edgeSchemas, DUPLICATE_OF, RELATION_FIELDS); + addEdgeSchema( + schema, edgeSchemas, CONFLICTS_WITH, RELATION_FIELDS); + + Map groups = new LinkedHashMap<>(); + for (String label : VERTEX_LABELS) { + List rows = vertices.get(label); + Collections.sort(rows, VERTEX_ORDER); + groups.put( + label, + new VertexGroup(vertexSchemas.get(label), rows)); + } + for (String label : EDGE_LABELS) { + List rows = edges.get(label); + Collections.sort(rows, EDGE_ORDER); + groups.put( + label, + new EdgeGroup(edgeSchemas.get(label), rows)); + } + return new MemoryGraph(schema, groups); + } + + private static void addVertexSchema( + GraphSchema graphSchema, + Map schemas, + String label, + List fields) { + VertexSchema schema = new VertexSchema(label, "id", fields); + graphSchema.addVertex(schema); + schemas.put(label, schema); + } + + private static void addEdgeSchema( + GraphSchema graphSchema, + Map schemas, + String label, + List fields) { + EdgeSchema schema = new EdgeSchema( + label, "srcId", "dstId", fields); + graphSchema.addEdge(schema); + schemas.put(label, schema); + } + + private static Map> emptyEdgeLists() { + Map> edges = new LinkedHashMap<>(); + for (String label : EDGE_LABELS) { + edges.put(label, new ArrayList<>()); + } + return edges; + } + + private static Map> + relationEdgeMaps() { + Map> relations = + new LinkedHashMap<>(); + relations.put(SUPERSEDES, new TreeMap<>()); + relations.put(DUPLICATE_OF, new TreeMap<>()); + relations.put(CONFLICTS_WITH, new TreeMap<>()); + return relations; + } + + private static List countedEdges( + String label, + Map counted, + boolean relation) { + List edges = new ArrayList<>(); + for (CountedEdge item : counted.values()) { + List values = relation + ? fields( + relationId(label, item.sourceId, item.targetId), + Integer.toString(item.count)) + : fields(Integer.toString(item.count)); + edges.add(new Edge( + label, item.sourceId, item.targetId, values)); + } + return edges; + } + + private static void addCountedEdge( + Map edges, + String sourceId, + String targetId) { + String key = tuple(sourceId, targetId); + CountedEdge current = edges.get(key); + if (current == null) { + edges.put(key, new CountedEdge(sourceId, targetId)); + } else { + current.count++; + } + } + + private static List eventValues(NormalizedMemoryEvent event) { + String kind = EMPTY; + String value = EMPTY; + if (event.getFactValue().isPresent()) { + kind = event.getFactValue().get().getKind().name(); + value = event.getFactValue().get().getValue(); + } + return fields( + event.getOperation().name(), + event.getFactId(), + event.getFactKey().getSubjectId(), + event.getFactKey().getPredicate(), + event.getFactKey().getScope(), + kind, + value, + instant(event.getValidTime().getStart()), + optionalInstant(event.getValidTime()), + instant(event.getRecordedAt()), + event.getTargetVersionId().orElse(EMPTY), + event.getPayloadHash()); + } + + private static List versionValues( + MemoryFactVersion version, + FactKey key) { + MemoryFact fact = version.getFact(); + String kind = fact.isRelationship() + ? FactValue.Kind.ENTITY_REF.name() + : FactValue.Kind.LITERAL.name(); + String literalValue = fact.getLiteralValue().orElse(EMPTY); + return fields( + fact.getId(), + fact.getPredicate(), + key.getScope(), + kind, + literalValue, + version.getStatus().name(), + instant(version.getValidTime().getStart()), + optionalInstant(version.getValidTime()), + instant(version.getTransactionTime().getStart()), + optionalInstant(version.getTransactionTime())); + } + + private static void collectEventEntities( + NormalizedMemoryEvent event, + Map entities) { + if (!event.getEvent().getFact().isPresent()) { + return; + } + MemoryFact fact = event.getEvent().getFact().get(); + collectEntity(entities, fact.getSubject()); + if (fact.isRelationship()) { + collectEntity(entities, fact.getTarget().get()); + } + } + + private static void collectEntity( + Map entities, + MemoryEntity entity) { + putUnique(entities, entity.getId(), entity, "entity"); + } + + private static void collectEvidence( + List evidence, + Map evidenceById, + Map sources) { + for (Evidence item : evidence) { + putUnique(evidenceById, item.getId(), item, "evidence"); + putUnique( + sources, + item.getSource().getId(), + item.getSource(), + "source"); + } + } + + private static void putUnique( + Map values, + String id, + T value, + String type) { + T previous = values.get(id); + if (previous != null && !previous.equals(value)) { + throw new IllegalArgumentException( + "Conflicting " + type + " id: " + id); + } + values.put(id, value); + } + + private static void putUniqueVertex( + Map vertices, + Vertex vertex) { + Vertex previous = vertices.put(vertex.getId(), vertex); + if (previous != null + && !previous.getValues().equals(vertex.getValues())) { + throw new IllegalArgumentException( + "Conflicting vertex id: " + vertex.getId()); + } + } + + private static void validateGraphShape(MemoryGraph graph) { + GraphSchema schema = Objects.requireNonNull( + graph.getGraphSchema(), "graphSchema"); + require( + schema.getVertexSchemaList().size() == VERTEX_LABELS.size(), + "Unexpected vertex schema count"); + require( + schema.getEdgeSchemaList().size() == EDGE_LABELS.size(), + "Unexpected edge schema count"); + + for (int index = 0; index < VERTEX_LABELS.size(); index++) { + String label = VERTEX_LABELS.get(index); + VertexSchema actual = schema.getVertexSchemaList().get(index); + require(label.equals(actual.getLabel()), + "Unexpected vertex schema: " + actual.getLabel()); + require("id".equals(actual.getIdField()), + "Unexpected vertex id field: " + label); + require(vertexFields(label).equals(actual.getFields()), + "Unexpected vertex fields: " + label); + } + for (int index = 0; index < EDGE_LABELS.size(); index++) { + String label = EDGE_LABELS.get(index); + EdgeSchema actual = schema.getEdgeSchemaList().get(index); + require(label.equals(actual.getLabel()), + "Unexpected edge schema: " + actual.getLabel()); + require("srcId".equals(actual.getSrcIdField()), + "Unexpected edge source field: " + label); + require("dstId".equals(actual.getDstIdField()), + "Unexpected edge target field: " + label); + require(edgeFields(label).equals(actual.getFields()), + "Unexpected edge fields: " + label); + } + + Map groups = Objects.requireNonNull( + graph.entities, "graph.entities"); + List expectedGroups = new ArrayList<>(VERTEX_LABELS); + expectedGroups.addAll(EDGE_LABELS); + require( + expectedGroups.equals(new ArrayList<>(groups.keySet())), + "Unexpected graph entity groups"); + for (String label : VERTEX_LABELS) { + require(groups.get(label) instanceof VertexGroup, + "Expected vertex group: " + label); + } + for (String label : EDGE_LABELS) { + require(groups.get(label) instanceof EdgeGroup, + "Expected edge group: " + label); + } + } + + private static Map vertexIndex( + MemoryGraph graph, + String label, + String prefix, + List expectedFields) { + Map result = new TreeMap<>(); + for (Vertex vertex + : ((VertexGroup) graph.entities.get(label)).getVertices()) { + require(vertex != null, "Null vertex in group: " + label); + require(label.equals(vertex.getLabel()), + "Vertex label does not match group: " + vertex.getId()); + rawId(vertex.getId(), prefix); + require(vertex.getValues() != null, + "Null vertex values: " + vertex.getId()); + require(vertex.getValues().size() == expectedFields.size(), + "Unexpected vertex value count: " + vertex.getId()); + for (String fieldValue : vertex.getValues()) { + require(fieldValue != null, + "Null vertex field: " + vertex.getId()); + } + require(result.put(vertex.getId(), vertex) == null, + "Duplicate vertex id: " + vertex.getId()); + } + return result; + } + + private static Map> edgeIndex(MemoryGraph graph) { + Map> result = new LinkedHashMap<>(); + for (String label : EDGE_LABELS) { + List rows = new ArrayList<>(); + Set identities = new HashSet<>(); + for (Edge edge + : ((EdgeGroup) graph.entities.get(label)).getOutEdges()) { + require(edge != null, "Null edge in group: " + label); + require(label.equals(edge.getLabel()), + "Edge label does not match group: " + label); + require(edge.getSrcId() != null && edge.getDstId() != null, + "Null edge endpoint: " + label); + require(edge.getValues() != null, + "Null edge values: " + label); + require(edge.getValues().size() == edgeFields(label).size(), + "Unexpected edge value count: " + label); + for (String fieldValue : edge.getValues()) { + require(fieldValue != null, + "Null edge field: " + label); + } + require(identities.add(edge), + "Duplicate edge identity: " + edge); + rows.add(edge); + } + Collections.sort(rows, EDGE_ORDER); + result.put(label, rows); + } + return result; + } + + private static Map readEntities( + Map vertices) { + Map result = new TreeMap<>(); + for (Vertex vertex : vertices.values()) { + String rawId = rawId(vertex.getId(), ENTITY_PREFIX); + result.put( + vertex.getId(), + new MemoryEntity( + rawId, + value(vertex, ENTITY_FIELDS, "label"))); + } + return result; + } + + private static Map readSources( + Map vertices) { + Map result = new TreeMap<>(); + for (Vertex vertex : vertices.values()) { + String rawId = rawId(vertex.getId(), SOURCE_PREFIX); + result.put( + vertex.getId(), + new Source( + rawId, + value(vertex, SOURCE_FIELDS, "name"))); + } + return result; + } + + private static Map readEvidence( + Map vertices, + Map sources, + List fromSourceEdges) { + Map sourceByEvidence = new HashMap<>(); + for (Edge edge : fromSourceEdges) { + require(vertices.containsKey(edge.getSrcId()), + "Unknown evidence in from_source edge"); + require(sources.containsKey(edge.getDstId()), + "Unknown source in from_source edge"); + require(sourceByEvidence.put( + edge.getSrcId(), edge.getDstId()) == null, + "Evidence has multiple sources: " + edge.getSrcId()); + } + require(sourceByEvidence.keySet().equals(vertices.keySet()), + "Every evidence must have exactly one source"); + + Map result = new TreeMap<>(); + for (Vertex vertex : vertices.values()) { + String rawId = rawId(vertex.getId(), EVIDENCE_PREFIX); + result.put( + vertex.getId(), + new Evidence( + rawId, + sources.get(sourceByEvidence.get(vertex.getId())), + value(vertex, EVIDENCE_FIELDS, "content"))); + } + return result; + } + + private static Map> readSupportedEvidence( + List supportedEdges, + Map eventVertices, + Map versionVertices, + Map evidence) { + Map> result = new HashMap<>(); + for (Edge edge : supportedEdges) { + require( + eventVertices.containsKey(edge.getSrcId()) + || versionVertices.containsKey(edge.getSrcId()), + "Unknown supported_by owner: " + edge.getSrcId()); + Evidence item = evidence.get(edge.getDstId()); + require(item != null, + "Unknown supported_by evidence: " + edge.getDstId()); + int count = positiveCount(edge.getValues().get(0)); + List ownerEvidence = result.computeIfAbsent( + edge.getSrcId(), ignored -> new ArrayList<>()); + for (int occurrence = 0; occurrence < count; occurrence++) { + ownerEvidence.add(item); + } + } + return result; + } + + private static Map readEvents( + Map vertices, + Map entities, + Map> evidenceByOwner) { + Map result = new TreeMap<>(); + EventNormalizer normalizer = new EventNormalizer(); + for (Vertex vertex : vertices.values()) { + String rawEventId = rawId(vertex.getId(), EVENT_PREFIX); + MemoryEventOperation operation = enumValue( + MemoryEventOperation.class, + value(vertex, EVENT_FIELDS, "operation"), + "event operation"); + String factId = value(vertex, EVENT_FIELDS, "factId"); + String subjectId = value(vertex, EVENT_FIELDS, "subjectId"); + String predicate = value(vertex, EVENT_FIELDS, "predicate"); + final FactKey key = new FactKey( + subjectId, + predicate, + value(vertex, EVENT_FIELDS, "scope")); + TimeInterval validTime = interval( + value(vertex, EVENT_FIELDS, "validStart"), + value(vertex, EVENT_FIELDS, "validEnd")); + Instant recordedAt = parseInstant( + value(vertex, EVENT_FIELDS, "recordedAt")); + List eventEvidence = evidenceByOwner.get( + vertex.getId()); + require(eventEvidence != null && !eventEvidence.isEmpty(), + "Memory event must have evidence: " + rawEventId); + + MemoryEvent event; + if (operation == MemoryEventOperation.RETRACT) { + require(value(vertex, EVENT_FIELDS, "valueKind").isEmpty(), + "Retract event must not have a value kind"); + require(value(vertex, EVENT_FIELDS, "value").isEmpty(), + "Retract event must not have a value"); + event = MemoryEvent.retract( + rawEventId, + factId, + validTime, + recordedAt, + eventEvidence); + } else { + MemoryFact fact = eventFact( + vertex, + factId, + subjectId, + predicate, + entities); + if (operation == MemoryEventOperation.ADD) { + event = MemoryEvent.add( + rawEventId, + fact, + validTime, + recordedAt, + eventEvidence); + } else { + require(operation == MemoryEventOperation.CORRECT, + "Unsupported memory event operation"); + event = MemoryEvent.correct( + rawEventId, + fact, + validTime, + recordedAt, + eventEvidence); + } + } + + String targetVersionId = value(vertex, EVENT_FIELDS, "targetVersionId"); + if (!targetVersionId.isEmpty()) { + event = event.targeting(targetVersionId); + } + NormalizedMemoryEvent normalized = normalizer.normalize(event, key); + require(eventId(normalized.getEventId()).equals(vertex.getId()), + "Event id is not canonical: " + rawEventId); + require( + normalized.getPayloadHash().equals( + value(vertex, EVENT_FIELDS, "payloadHash")), + "Event payload hash does not match: " + rawEventId); + result.put(vertex.getId(), normalized); + } + return result; + } + + private static MemoryFact eventFact( + Vertex vertex, + String factId, + String subjectId, + String predicate, + Map entities) { + MemoryEntity subject = entities.get(entityId(subjectId)); + require(subject != null, + "Unknown event subject: " + subjectId); + FactValue.Kind kind = enumValue( + FactValue.Kind.class, + value(vertex, EVENT_FIELDS, "valueKind"), + "event value kind"); + String factValue = value(vertex, EVENT_FIELDS, "value"); + if (kind == FactValue.Kind.LITERAL) { + return MemoryFact.attribute( + factId, subject, predicate, factValue); + } + MemoryEntity target = entities.get(entityId(factValue)); + require(target != null, + "Unknown event object: " + factValue); + return MemoryFact.relationship( + factId, subject, predicate, target); + } + + private static Map> readVersions( + Map vertices, + Map entities, + Map> evidenceByOwner, + List subjectEdges, + List objectEdges) { + Map subjects = uniqueTargets( + subjectEdges, + vertices, + entities, + "subject"); + require(subjects.keySet().equals(vertices.keySet()), + "Every fact version must have exactly one subject"); + Map objects = uniqueTargets( + objectEdges, + vertices, + entities, + "object"); + + Map> result = new TreeMap<>(); + for (Vertex vertex : vertices.values()) { + String rawVersionId = rawId(vertex.getId(), VERSION_PREFIX); + MemoryEntity subject = entities.get(subjects.get(vertex.getId())); + String predicate = value(vertex, VERSION_FIELDS, "predicate"); + FactKey key = new FactKey( + subject.getId(), + predicate, + value(vertex, VERSION_FIELDS, "scope")); + + FactValue.Kind kind = enumValue( + FactValue.Kind.class, + value(vertex, VERSION_FIELDS, "valueKind"), + "version value kind"); + String factId = value(vertex, VERSION_FIELDS, "factId"); + String literalValue = value( + vertex, VERSION_FIELDS, "literalValue"); + MemoryFact fact; + if (kind == FactValue.Kind.LITERAL) { + require(!objects.containsKey(vertex.getId()), + "Literal version must not have an object"); + fact = MemoryFact.attribute( + factId, subject, predicate, literalValue); + } else { + require(literalValue.isEmpty(), + "Entity-reference version must not have a literal value"); + String objectId = objects.get(vertex.getId()); + require(objectId != null, + "Entity-reference version must have an object"); + fact = MemoryFact.relationship( + factId, subject, predicate, entities.get(objectId)); + } + + List versionEvidence = evidenceByOwner.get( + vertex.getId()); + require(versionEvidence != null && !versionEvidence.isEmpty(), + "Fact version must have evidence: " + rawVersionId); + MemoryFactVersion version = new MemoryFactVersion( + rawVersionId, + fact, + enumValue( + MemoryFactVersionStatus.class, + value(vertex, VERSION_FIELDS, "status"), + "version status"), + interval( + value(vertex, VERSION_FIELDS, "validStart"), + value(vertex, VERSION_FIELDS, "validEnd")), + interval( + value(vertex, VERSION_FIELDS, "transactionStart"), + value(vertex, VERSION_FIELDS, "transactionEnd")), + versionEvidence); + result.computeIfAbsent( + key, ignored -> new ArrayList<>()).add(version); + } + return result; + } + + private static Map uniqueTargets( + List edges, + Map sourceVertices, + Map targets, + String relationName) { + Map result = new HashMap<>(); + for (Edge edge : edges) { + require(sourceVertices.containsKey(edge.getSrcId()), + "Unknown " + relationName + " source"); + require(targets.containsKey(edge.getDstId()), + "Unknown " + relationName + " target"); + require(result.put(edge.getSrcId(), edge.getDstId()) == null, + "Multiple " + relationName + " targets"); + } + return result; + } + + private static void validateNoOrphanVertices( + Map entities, + Map evidence, + Map sources, + Map events, + Map> versions) { + Map referencedEntities = new TreeMap<>(); + Map referencedEvidence = new TreeMap<>(); + Map referencedSources = new TreeMap<>(); + for (NormalizedMemoryEvent event : events.values()) { + collectEventEntities(event, referencedEntities); + collectEvidence( + event.getEvidence(), + referencedEvidence, + referencedSources); + } + for (List factVersions : versions.values()) { + for (MemoryFactVersion version : factVersions) { + MemoryFact fact = version.getFact(); + collectEntity(referencedEntities, fact.getSubject()); + if (fact.isRelationship()) { + collectEntity( + referencedEntities, + fact.getTarget().get()); + } + collectEvidence( + version.getEvidence(), + referencedEvidence, + referencedSources); + } + } + requireAllReferenced( + entities, referencedEntities, ENTITY_PREFIX, "entity"); + requireAllReferenced( + evidence, referencedEvidence, EVIDENCE_PREFIX, "evidence"); + requireAllReferenced( + sources, referencedSources, SOURCE_PREFIX, "source"); + } + + private static void requireAllReferenced( + Map graphValues, + Map referencedValues, + String prefix, + String type) { + for (String graphId : graphValues.keySet()) { + require(referencedValues.containsKey(rawId(graphId, prefix)), + "Orphan " + type + " vertex: " + graphId); + } + } + + private static Map readGeneratingEvents( + List generateEdges, + Map events, + Map versions) { + Map byVersion = new HashMap<>(); + for (Edge edge : generateEdges) { + require(events.containsKey(edge.getSrcId()), + "Unknown generating event: " + edge.getSrcId()); + require(versions.containsKey(edge.getDstId()), + "Unknown generated version: " + edge.getDstId()); + require(byVersion.put( + edge.getDstId(), edge.getSrcId()) == null, + "Version has multiple generating events: " + + edge.getDstId()); + } + require(byVersion.keySet().equals(versions.keySet()), + "Every fact version must have one generating event"); + + Map result = new TreeMap<>(); + for (Map.Entry entry : byVersion.entrySet()) { + result.put( + rawId(entry.getKey(), VERSION_PREFIX), + rawId(entry.getValue(), EVENT_PREFIX)); + } + return result; + } + + private static List readRelations( + Map> edges, + Map versions) { + List result = new ArrayList<>(); + for (String label : Arrays.asList( + SUPERSEDES, DUPLICATE_OF, CONFLICTS_WITH)) { + for (Edge edge : edges.get(label)) { + require(versions.containsKey(edge.getSrcId()), + "Unknown relation source: " + edge.getSrcId()); + require(versions.containsKey(edge.getDstId()), + "Unknown relation target: " + edge.getDstId()); + require( + relationId(label, edge.getSrcId(), edge.getDstId()) + .equals(edge.getValues().get(0)), + "Version relation id does not match endpoints"); + int count = positiveCount(edge.getValues().get(1)); + for (int occurrence = 0; occurrence < count; occurrence++) { + VersionRelation relation = new VersionRelation( + relationType(label), + rawId(edge.getSrcId(), VERSION_PREFIX), + rawId(edge.getDstId(), VERSION_PREFIX)); + require( + versionId(relation.getFromVersionId()).equals( + edge.getSrcId()) + && versionId(relation.getToVersionId()).equals( + edge.getDstId()), + "Version relation endpoints are not canonical"); + result.add(relation); + } + } + } + return result; + } + + private static List vertexFields(String label) { + if (ENTITY.equals(label)) { + return ENTITY_FIELDS; + } + if (FACT_VERSION.equals(label)) { + return VERSION_FIELDS; + } + if (MEMORY_EVENT.equals(label)) { + return EVENT_FIELDS; + } + if (EVIDENCE.equals(label)) { + return EVIDENCE_FIELDS; + } + if (SOURCE.equals(label)) { + return SOURCE_FIELDS; + } + throw new IllegalArgumentException( + "Unknown vertex label: " + label); + } + + private static List edgeFields(String label) { + if (SUPPORTED_BY.equals(label)) { + return COUNT_FIELDS; + } + if (SUPERSEDES.equals(label) + || DUPLICATE_OF.equals(label) + || CONFLICTS_WITH.equals(label)) { + return RELATION_FIELDS; + } + if (SUBJECT.equals(label) + || OBJECT.equals(label) + || GENERATES.equals(label) + || FROM_SOURCE.equals(label)) { + return NO_FIELDS; + } + throw new IllegalArgumentException( + "Unknown edge label: " + label); + } + + private static String value( + Vertex vertex, + List fields, + String field) { + int index = fields.indexOf(field); + if (index < 0) { + throw new IllegalArgumentException("Unknown field: " + field); + } + return vertex.getValues().get(index); + } + + private static TimeInterval interval(String start, String end) { + Instant parsedStart = parseInstant(start); + return end.isEmpty() + ? TimeInterval.unboundedFrom(parsedStart) + : new TimeInterval(parsedStart, parseInstant(end)); + } + + private static Instant parseInstant(String value) { + try { + return Instant.parse(value); + } catch (DateTimeParseException exception) { + throw new IllegalArgumentException( + "Invalid instant: " + value, + exception); + } + } + + private static int positiveCount(String value) { + try { + int count = Integer.parseInt(value); + require(count > 0, "Occurrence count must be positive"); + return count; + } catch (NumberFormatException exception) { + throw new IllegalArgumentException( + "Invalid occurrence count: " + value, + exception); + } + } + + private static > T enumValue( + Class type, + String value, + String fieldName) { + try { + return Enum.valueOf(type, value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "Invalid " + fieldName + ": " + value, + exception); + } + } + + private static String relationLabel(VersionRelationType type) { + if (type == VersionRelationType.SUPERSEDES) { + return SUPERSEDES; + } + if (type == VersionRelationType.DUPLICATE_OF) { + return DUPLICATE_OF; + } + if (type == VersionRelationType.CONFLICTS_WITH) { + return CONFLICTS_WITH; + } + throw new IllegalArgumentException( + "Unsupported version relation type: " + type); + } + + private static VersionRelationType relationType(String label) { + if (SUPERSEDES.equals(label)) { + return VersionRelationType.SUPERSEDES; + } + if (DUPLICATE_OF.equals(label)) { + return VersionRelationType.DUPLICATE_OF; + } + if (CONFLICTS_WITH.equals(label)) { + return VersionRelationType.CONFLICTS_WITH; + } + throw new IllegalArgumentException( + "Unsupported version relation label: " + label); + } + + private static String relationId( + String label, + String sourceId, + String targetId) { + return "relation:" + tuple(label, sourceId, targetId); + } + + private static String tuple(String... values) { + StringBuilder result = new StringBuilder(); + for (String value : values) { + result.append(value.length()).append(':').append(value); + } + return result.toString(); + } + + private static String instant(Instant instant) { + return instant.toString(); + } + + private static String optionalInstant(TimeInterval interval) { + return interval.getEnd().isPresent() + ? instant(interval.getEnd().get()) : EMPTY; + } + + private static String entityId(String rawId) { + return ENTITY_PREFIX + rawId; + } + + private static String versionId(String rawId) { + return VERSION_PREFIX + rawId; + } + + private static String eventId(String rawId) { + return EVENT_PREFIX + rawId; + } + + private static String evidenceId(String rawId) { + return EVIDENCE_PREFIX + rawId; + } + + private static String sourceId(String rawId) { + return SOURCE_PREFIX + rawId; + } + + private static String rawId(String graphId, String prefix) { + require(graphId != null && graphId.startsWith(prefix), + "Graph id has the wrong prefix: " + graphId); + String rawId = graphId.substring(prefix.length()); + require(!rawId.trim().isEmpty(), + "Graph id has an empty raw id: " + graphId); + return rawId; + } + + private static List fields(String... values) { + return Collections.unmodifiableList(Arrays.asList(values)); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } + + private static final class CountedEdge { + + private final String sourceId; + private final String targetId; + private int count; + + private CountedEdge(String sourceId, String targetId) { + this.sourceId = sourceId; + this.targetId = targetId; + this.count = 1; + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/CandidateResult.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/CandidateResult.java new file mode 100644 index 000000000..86f725837 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/CandidateResult.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.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.candidate.EvidenceCandidate; +import org.apache.geaflow.ai.temporal.candidate.FactCandidate; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; + +public final class CandidateResult { + + private final QueryMode mode; + private final FactKey factKey; + private final String text; + private final Instant validAt; + private final Instant systemAt; + private final int topK; + private final List facts; + private final List evidence; + private final Map generatingEventIds; + private final RetrievalProfile profile; + + CandidateResult(QueryMode mode, FactKey factKey, String text, Instant validAt, + Instant systemAt, int topK, List facts, + List evidence, Map generatingEventIds, + RetrievalProfile profile) { + this.mode = mode; + this.factKey = factKey; + this.text = text; + this.validAt = validAt; + this.systemAt = systemAt; + this.topK = topK; + this.facts = Collections.unmodifiableList(new ArrayList<>(facts)); + this.evidence = Collections.unmodifiableList(new ArrayList<>(evidence)); + this.generatingEventIds = Collections.unmodifiableMap(new TreeMap<>(generatingEventIds)); + this.profile = profile; + } + + public List getFacts() { + return facts; + } + + public List getEvidence() { + return evidence; + } + + public JsonObject toJson() { + TemporalJsonCodec codec = new TemporalJsonCodec(); + JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + result.addProperty("mode", mode.name()); + result.add("factKey", codec.factKeyToJson(factKey)); + result.addProperty("validAt", validAt.toString()); + result.addProperty("systemAt", systemAt == null ? null : systemAt.toString()); + result.addProperty("text", text); + result.addProperty("topK", topK); + result.addProperty("retrievalProfile", profile.name()); + JsonArray encodedFacts = new JsonArray(); + for (FactCandidate candidate : facts) { + JsonObject value = new JsonObject(); + value.addProperty("rank", candidate.getRank()); + value.addProperty("mandatory", candidate.isMandatory()); + JsonObject scores = new JsonObject(); + for (Map.Entry score : candidate.getScores().entrySet()) { + scores.addProperty(score.getKey(), score.getValue()); + } + value.add("scores", scores); + if (profile.isCandidateStrategy()) { + value.addProperty("score", candidate.getScore()); + JsonObject ranks = new JsonObject(); + for (Map.Entry rank : candidate.getChannelRanks().entrySet()) { + ranks.addProperty(rank.getKey(), rank.getValue()); + } + value.add("channelRanks", ranks); + } + value.add("version", codec.versionToJson(candidate.getFactKey(), + candidate.getVersion(), generatingEventIds.get(candidate.getVersion().getId()))); + encodedFacts.add(value); + } + result.add("facts", encodedFacts); + JsonArray encodedEvidence = new JsonArray(); + for (EvidenceCandidate candidate : evidence) { + JsonObject value = new JsonObject(); + value.addProperty("rank", candidate.getRank()); + JsonObject scores = new JsonObject(); + for (Map.Entry score : candidate.getChannelScores().entrySet()) { + scores.addProperty(score.getKey(), score.getValue()); + } + value.add("scores", scores); + if (profile.usesLexicalFusion() || profile.isCandidateStrategy()) { + value.addProperty("score", candidate.getScore()); + JsonObject ranks = new JsonObject(); + for (Map.Entry rank : candidate.getChannelRanks().entrySet()) { + ranks.addProperty(rank.getKey(), rank.getValue()); + } + value.add("channelRanks", ranks); + } + value.add("evidence", codec.evidenceToJson(candidate.getEvidence())); + JsonArray versionIds = new JsonArray(); + for (String id : candidate.getVersionIds()) { + versionIds.add(new JsonPrimitive(id)); + } + value.add("versionIds", versionIds); + encodedEvidence.add(value); + } + result.add("evidence", encodedEvidence); + return result; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/QueryMode.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/QueryMode.java new file mode 100644 index 000000000..45fa060c7 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/QueryMode.java @@ -0,0 +1,27 @@ +/* + * 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.api; + +/** Public query modes for the experimental temporal API. */ +public enum QueryMode { + CURRENT, + HISTORICAL, + AS_OF +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/QueryResult.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/QueryResult.java new file mode 100644 index 000000000..f51a2ee9a --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/QueryResult.java @@ -0,0 +1,46 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.trace.QueryTrace; + +/** Query matches and the explanation generated for this request. */ +public final class QueryResult { + + private final List versions; + private final QueryTrace trace; + + QueryResult(List versions, QueryTrace trace) { + this.versions = Collections.unmodifiableList(new ArrayList<>(versions)); + this.trace = trace; + } + + public List getVersions() { + return versions; + } + + public QueryTrace getTrace() { + return trace; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/ReplayResult.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/ReplayResult.java new file mode 100644 index 000000000..dfbdca2a2 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/ReplayResult.java @@ -0,0 +1,65 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; + +/** A committed snapshot and the decisions for this submitted finite batch. */ +public final class ReplayResult { + + private final CanonicalSnapshot snapshot; + private final List traces; + private final Map statistics; + private final String method; + + ReplayResult(CanonicalSnapshot snapshot, List traces, + Map statistics, String method) { + this.snapshot = snapshot; + this.traces = Collections.unmodifiableList(new ArrayList<>(traces)); + this.statistics = Collections.unmodifiableMap(new LinkedHashMap<>(statistics)); + this.method = method; + } + + public CanonicalSnapshot getSnapshot() { + return snapshot; + } + + public List getDecisionTraces() { + return traces; + } + + public Map getStatistics() { + return statistics; + } + + public String getMethod() { + return method; + } + + public String getFallbackStatus() { + return "NOT_APPLICABLE"; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/TemporalMemoryFacade.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/TemporalMemoryFacade.java new file mode 100644 index 000000000..8d8579f2d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/TemporalMemoryFacade.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.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.adapter.MemoryGraphAdapter; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.candidate.TemporalCandidateService; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.integration.IncrementalTemporalIntegrator; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.query.TemporalQueryService; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.SnapshotComparator; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; +import org.apache.geaflow.ai.temporal.trace.QueryTrace; +import org.apache.geaflow.ai.temporal.trace.TraceBuilder; + +/** + * Single-session, finite-batch API. A staged graph and explanations publish together. + * Staging rebuilds Java state; this facade makes no incremental-performance claim. + */ +public final class TemporalMemoryFacade { + + private static final Comparator EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + private final List decisions = new ArrayList<>(); + private final List queries = new ArrayList<>(); + private final List operations = new ArrayList<>(); + private CanonicalSnapshot snapshot = new IncrementalTemporalIntegrator().canonicalSnapshot(); + private int revision; + private int attemptSequence; + + public ReplayResult replay(List events) { + return commit(events, true, null, 20); + } + + public ReplayResult applyBatch(List events) { + return commit(events, false, null, 20); + } + + public ReplayResult replay(List events, RetrievalProfile profile, int topK) { + validateDecisionRetrieval(profile, topK); + return commit(events, true, profile, topK); + } + + public ReplayResult applyBatch(List events, RetrievalProfile profile, int topK) { + validateDecisionRetrieval(profile, topK); + return commit(events, false, profile, topK); + } + + private ReplayResult commit(List events, boolean reset, + RetrievalProfile profile, int topK) { + if (events == null) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "events must not be null"); + } + List ordered = new ArrayList<>(events); + for (NormalizedMemoryEvent event : ordered) { + if (event == null) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "event must not be null"); + } + } + ordered.sort(EVENT_ORDER); + JsonObject operation = eventOperation(reset ? "REPLAY" : "APPLY_BATCH", ordered); + if (profile != null) { + operation.addProperty("candidateProfile", profile.name()); + operation.addProperty("candidateTopK", topK); + } + List attempts = new ArrayList<>(); + try { + IncrementalTemporalIntegrator staged = new IncrementalTemporalIntegrator(); + if (!reset) { + staged.applyBatch(snapshot.getEvents()); + } + List codes = staged.applyBatch(ordered); + CanonicalSnapshot stagedSnapshot = reset && profile == null + ? new FullReplayOracle().replayToSnapshot(ordered) + : roundTrip(staged.canonicalSnapshot()); + if (!new SnapshotComparator().compare(staged.canonicalSnapshot(), stagedSnapshot) + .isEquivalent()) { + throw new IllegalStateException("Java and formal Oracle snapshots differ"); + } + if (profile != null && !new SnapshotComparator().compare(stagedSnapshot, + new FullReplayOracle().replayToSnapshot(stagedSnapshot.getEvents())).isEquivalent()) { + throw new IllegalStateException("Conflict resolver and independent Oracle snapshots differ"); + } + boolean replaySemantics = profile == null || codes.contains(EventLedgerDecision.ACCEPTED); + Map semantics = profile == null + ? TraceBuilder.replay(stagedSnapshot.getEvents()) + : replaySemantics + ? TraceBuilder.replay(stagedSnapshot.getEvents(), profile, topK) + : Collections.emptyMap(); + int nextRevision = revision + 1; + int nextAttempt = attemptSequence; + Set acceptedIds = new TreeSet<>(); + int accepted = 0; + for (int index = 0; index < ordered.size(); index++) { + NormalizedMemoryEvent event = ordered.get(index); + String code = codes.get(index).name(); + boolean changed = codes.get(index) == EventLedgerDecision.ACCEPTED; + if (changed) { + accepted++; + acceptedIds.add(event.getEventId()); + } + attempts.add(TraceBuilder.decision(event, + changed ? semantics.get(event.getEventId()) : null, + "attempt-" + (++nextAttempt), nextRevision, "INPUT", code, code, null, profile, topK)); + } + List replayed = new ArrayList<>(); + if (!reset && accepted > 0) { + for (NormalizedMemoryEvent event : stagedSnapshot.getEvents()) { + if (!acceptedIds.contains(event.getEventId())) { + replayed.add(TraceBuilder.decision(event, semantics.get(event.getEventId()), + "replay-" + nextRevision + "-" + event.getEventId(), nextRevision, + "REPLAY", "ACCEPTED", "ACCEPTED", null, profile, topK)); + } + } + } + Map statistics = new LinkedHashMap<>(); + statistics.put("submittedCount", ordered.size()); + statistics.put("acceptedCount", accepted); + statistics.put("duplicateCount", ordered.size() - accepted); + statistics.put("stateEventCount", stagedSnapshot.getEvents().size()); + statistics.put("traceReplayEventCount", replaySemantics ? stagedSnapshot.getEvents().size() : 0); + statistics.put("versionCount", stagedSnapshot.getState().getVersions().size()); + statistics.put("relationCount", stagedSnapshot.getState().getRelations().size()); + final ReplayResult result = new ReplayResult(stagedSnapshot, attempts, statistics, + profile != null ? "JAVA_CONFLICT_RESOLVER_MEMORY_GRAPH" + : reset ? "FULL_REPLAY_MEMORY_GRAPH" : "JAVA_LOCAL_STAGED_REPLAY"); + snapshot = stagedSnapshot; + revision = nextRevision; + attemptSequence = nextAttempt; + decisions.addAll(attempts); + decisions.addAll(replayed); + operations.add(operation); + return result; + } catch (IllegalArgumentException error) { + TemporalValidationException failure = error instanceof TemporalValidationException + ? (TemporalValidationException) error + : new TemporalValidationException(TemporalErrorCode.REJECT_REFERENCE_INTEGRITY, + error.getMessage()); + operation.addProperty("expectedError", failure.getCode().name()); + for (NormalizedMemoryEvent event : ordered) { + decisions.add(TraceBuilder.decision(event, null, + "attempt-" + (++attemptSequence), revision, "INPUT", "BATCH_ROLLED_BACK", + failure.getCode().name(), failure.getMessage(), profile, topK)); + } + operations.add(operation); + throw failure; + } + } + + private static void validateDecisionRetrieval(RetrievalProfile profile, int topK) { + if (profile == null || !profile.isCandidateStrategy() || topK <= 0) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "Decision retrieval requires RULE_ONLY, RULE_BM25, RULE_MINHASH or RULE_ALL and positive topK"); + } + } + + public CanonicalSnapshot getSnapshot() { + return snapshot; + } + + public CandidateResult candidates(QueryMode mode, FactKey factKey, String text, + Instant validAt, Instant systemAt, int topK) + throws IOException { + return candidates(mode, factKey, text, validAt, systemAt, topK, RetrievalProfile.BASELINE); + } + + public CandidateResult candidates(QueryMode mode, FactKey factKey, String text, + Instant validAt, Instant systemAt, int topK, + RetrievalProfile profile) throws IOException { + if (mode == null || factKey == null || text == null || validAt == null || topK <= 0) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "mode, factKey, text and validAt are required; topK must be positive"); + } + if ((mode == QueryMode.AS_OF) != (systemAt != null)) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "systemAt is required only for AS_OF"); + } + if (profile == null) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "profile must not be null"); + } + TemporalCandidateService service = new TemporalCandidateService(profile); + return new CandidateResult(mode, factKey, text, validAt, systemAt, topK, + service.facts(snapshot, mode, factKey, validAt, systemAt, text, topK), + service.evidence(snapshot, mode, factKey, validAt, systemAt, text, topK), + snapshot.getGeneratingEventIds(), profile); + } + + public QueryResult query(QueryMode mode, FactKey factKey, Instant validAt, Instant systemAt) { + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(factKey, "factKey"); + Objects.requireNonNull(validAt, "validAt"); + if ((mode == QueryMode.AS_OF) != (systemAt != null)) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, + "systemAt is required only for AS_OF"); + } + TemporalQueryService service = new TemporalQueryService(); + List matches; + if (mode == QueryMode.CURRENT) { + matches = service.current(snapshot.getState(), factKey, validAt); + } else if (mode == QueryMode.HISTORICAL) { + matches = service.historical(snapshot.getState(), factKey, validAt); + } else { + matches = service.asOf(snapshot.getState(), factKey, validAt, systemAt); + } + final QueryTrace trace = TraceBuilder.query("query-" + (queries.size() + 1), revision, + mode, factKey, validAt, systemAt, matches, snapshot.getState()); + JsonObject operation = new JsonObject(); + operation.addProperty("method", "QUERY"); + operation.addProperty("mode", mode.name()); + operation.add("factKey", codec.factKeyToJson(factKey)); + operation.addProperty("validAt", validAt.toString()); + operation.addProperty("systemAt", systemAt == null ? null : systemAt.toString()); + queries.add(trace); + operations.add(operation); + return new QueryResult(matches, trace); + } + + public List getDecisionTraces() { + return Collections.unmodifiableList(new ArrayList<>(decisions)); + } + + public List getQueryTraces() { + return Collections.unmodifiableList(new ArrayList<>(queries)); + } + + public List getTrace(String eventId) { + Objects.requireNonNull(eventId, "eventId"); + List matches = new ArrayList<>(); + for (DecisionTrace trace : decisions) { + if (eventId.equals(trace.getEventId()) || eventId.equals(trace.getId())) { + matches.add(trace); + } + } + return Collections.unmodifiableList(matches); + } + + public QueryTrace getQueryTrace(String queryId) { + for (QueryTrace trace : queries) { + if (trace.getId().equals(queryId)) { + return trace; + } + } + return null; + } + + public JsonArray getOperations() { + JsonArray result = new JsonArray(); + for (JsonObject operation : operations) { + result.add(codec.parseObject(codec.toJson(operation))); + } + return result; + } + + public ValidationResult validateInvariants() { + List violations = new ArrayList<>(); + try { + CanonicalSnapshot reloaded = roundTrip(snapshot); + if (!new SnapshotComparator().compare(snapshot, reloaded).isEquivalent()) { + violations.add("GRAPH_ROUND_TRIP_DIFF"); + } + CanonicalSnapshot oracle = new FullReplayOracle().replayToSnapshot(snapshot.getEvents()); + if (!new SnapshotComparator().compare(snapshot, oracle).isEquivalent()) { + violations.add("ORACLE_DIFF"); + } + } catch (IllegalArgumentException error) { + violations.add("REFERENCE_INTEGRITY: " + error.getMessage()); + } + return new ValidationResult(violations); + } + + private JsonObject eventOperation(String method, List events) { + JsonObject operation = new JsonObject(); + operation.addProperty("method", method); + JsonArray encoded = new JsonArray(); + for (NormalizedMemoryEvent event : events) { + encoded.add(codec.eventToJson(event)); + } + operation.add("events", encoded); + operation.addProperty("expectedError", (String) null); + return operation; + } + + private static CanonicalSnapshot roundTrip(CanonicalSnapshot value) { + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + return adapter.fromGraph(adapter.toGraph(value)); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/ValidationResult.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/ValidationResult.java new file mode 100644 index 000000000..9c329c5b5 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/api/ValidationResult.java @@ -0,0 +1,42 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Invariant validation has no side effects on committed state. */ +public final class ValidationResult { + + private final List violations; + + ValidationResult(List violations) { + this.violations = Collections.unmodifiableList(new ArrayList<>(violations)); + } + + public boolean isValid() { + return violations.isEmpty(); + } + + public List getViolations() { + return violations; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/baseline/LwwBaseline.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/baseline/LwwBaseline.java new file mode 100644 index 000000000..cdcfd7ef3 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/baseline/LwwBaseline.java @@ -0,0 +1,140 @@ +/* + * 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.baseline; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.oracle.ReplayMethod; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedger; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; + +/** + * Replays events using deterministic last-write-wins current-state semantics. + */ +public final class LwwBaseline implements ReplayMethod { + + private static final Comparator EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + private static final TimeInterval ALL_VALID_TIME = + TimeInterval.unboundedFrom( + Instant.ofEpochMilli(Long.MIN_VALUE)); + + @Override + public CanonicalSnapshot replayToSnapshot( + List events) { + List ordered = new ArrayList<>( + Objects.requireNonNull(events, "events")); + for (NormalizedMemoryEvent event : ordered) { + Objects.requireNonNull(event, "event"); + } + Collections.sort(ordered, EVENT_ORDER); + + EventLedger ledger = new EventLedger(); + List accepted = new ArrayList<>(); + Map current = new TreeMap<>(); + Map generatingEventIds = new TreeMap<>(); + for (NormalizedMemoryEvent event : ordered) { + EventLedgerDecision decision = ledger.check(event); + if (decision == EventLedgerDecision.DUPLICATE_NOOP) { + continue; + } + if (decision == EventLedgerDecision.REJECT_EVENT_ID_REUSE) { + throw new IllegalArgumentException( + "Event id reused with a different payload: " + + event.getEventId()); + } + + apply(event, current, generatingEventIds); + ledger.commit(event); + accepted.add(event); + } + + Map> versions = + new TreeMap<>(); + for (Map.Entry entry + : current.entrySet()) { + versions.put( + entry.getKey(), + Collections.singletonList(entry.getValue())); + } + return new CanonicalSnapshot( + new TemporalState(versions, Collections.emptyList()), + accepted, + generatingEventIds); + } + + private static void apply( + NormalizedMemoryEvent event, + Map current, + Map generatingEventIds) { + FactKey key = event.getFactKey(); + MemoryEventOperation operation = event.getOperation(); + if (operation == MemoryEventOperation.RETRACT) { + MemoryFactVersion removed = current.remove(key); + if (removed == null) { + throw new IllegalArgumentException( + "Cannot retract a fact without current state: " + + event.getFactId()); + } + generatingEventIds.remove(removed.getId()); + return; + } + if (operation == MemoryEventOperation.CORRECT + && !current.containsKey(key)) { + throw new IllegalArgumentException( + "Cannot correct a fact without current state: " + + event.getFactId()); + } + if (operation != MemoryEventOperation.ADD + && operation != MemoryEventOperation.CORRECT) { + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + operation); + } + + MemoryFactVersion version = new MemoryFactVersion( + event.getEventId() + ":version:0", + event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + ALL_VALID_TIME, + TimeInterval.unboundedFrom(event.getRecordedAt()), + event.getEvidence()); + MemoryFactVersion replaced = current.put(key, version); + if (replaced != null) { + generatingEventIds.remove(replaced.getId()); + } + generatingEventIds.put(version.getId(), event.getEventId()); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/baseline/SingleTimestampBaseline.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/baseline/SingleTimestampBaseline.java new file mode 100644 index 000000000..c58d23efc --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/baseline/SingleTimestampBaseline.java @@ -0,0 +1,169 @@ +/* + * 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.baseline; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.ReplayMethod; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedger; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; + +/** + * Keeps the current distinct values while ignoring valid-time intervals. + */ +public final class SingleTimestampBaseline implements ReplayMethod { + + private static final Comparator EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + private static final TimeInterval ALL_VALID_TIME = + TimeInterval.unboundedFrom(Instant.ofEpochMilli(Long.MIN_VALUE)); + + @Override + public CanonicalSnapshot replayToSnapshot( + List events) { + List ordered = new ArrayList<>( + Objects.requireNonNull(events, "events")); + for (NormalizedMemoryEvent event : ordered) { + Objects.requireNonNull(event, "event"); + } + Collections.sort(ordered, EVENT_ORDER); + + EventLedger ledger = new EventLedger(); + List accepted = new ArrayList<>(); + Map> current = + new TreeMap<>(); + for (NormalizedMemoryEvent event : ordered) { + EventLedgerDecision decision = ledger.check(event); + if (decision == EventLedgerDecision.DUPLICATE_NOOP) { + continue; + } + if (decision == EventLedgerDecision.REJECT_EVENT_ID_REUSE) { + throw new IllegalArgumentException( + "Event id reused with a different payload: " + + event.getEventId()); + } + + Map next = new TreeMap<>(); + Map existing = + current.get(event.getFactKey()); + if (existing != null) { + next.putAll(existing); + } + apply(event, next); + ledger.commit(event); + if (next.isEmpty()) { + current.remove(event.getFactKey()); + } else { + current.put(event.getFactKey(), next); + } + accepted.add(event); + } + + Map> versions = + new TreeMap<>(); + Map generatingEventIds = new TreeMap<>(); + List relations = new ArrayList<>(); + for (Map.Entry> entry + : current.entrySet()) { + List versionsForKey = new ArrayList<>(); + for (NormalizedMemoryEvent event : entry.getValue().values()) { + MemoryFactVersion version = version(event); + versionsForKey.add(version); + generatingEventIds.put( + version.getId(), event.getEventId()); + } + versions.put(entry.getKey(), versionsForKey); + addConflicts(versionsForKey, relations); + } + return new CanonicalSnapshot( + new TemporalState(versions, relations), + accepted, + generatingEventIds); + } + + private static void apply( + NormalizedMemoryEvent event, + Map current) { + MemoryEventOperation operation = event.getOperation(); + if (operation == MemoryEventOperation.ADD) { + current.put(event.getFactValue().get(), event); + return; + } + if (current.isEmpty()) { + throw new IllegalArgumentException( + "Operation requires current values for fact key: " + + event.getFactKey()); + } + current.clear(); + if (operation == MemoryEventOperation.CORRECT) { + current.put(event.getFactValue().get(), event); + } else if (operation != MemoryEventOperation.RETRACT) { + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + operation); + } + } + + private static MemoryFactVersion version( + NormalizedMemoryEvent event) { + return new MemoryFactVersion( + versionId(event), + event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + ALL_VALID_TIME, + TimeInterval.unboundedFrom(event.getRecordedAt()), + event.getEvidence()); + } + + private static String versionId(NormalizedMemoryEvent event) { + return event.getEventId() + ":version:0"; + } + + private static void addConflicts( + List versions, + List relations) { + for (int left = 0; left < versions.size(); left++) { + for (int right = left + 1; right < versions.size(); right++) { + relations.add(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + versions.get(left).getId(), + versions.get(right).getId())); + } + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/ClimateFeverDataset.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/ClimateFeverDataset.java new file mode 100644 index 000000000..1a52b474b --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/ClimateFeverDataset.java @@ -0,0 +1,390 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.Source; + +public final class ClimateFeverDataset { + + public static final int SUBSET_SIZE = 1000; + public static final String RELEASE_COMMIT = "336f0a46c98920a6f40926385ce71ba877a7b863"; + public static final String RELEASE_SHA256 = + "8a4b9032d861be482ffb49dddfd283ffa6089e654f1e968040011882c5eb6e0b"; + public static final String SOURCE_URL = "https://raw.githubusercontent.com/tdiggelm/" + + "climate-fever-dataset/" + RELEASE_COMMIT + "/dataset/climate-fever.jsonl"; + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + private final List claims; + private final List corpus; + private final String subsetJsonl; + private final String corpusJsonl; + private final String mappingsJsonl; + private final String goldJsonl; + private final Map> titlesByEvidenceId; + private final String titlesJsonl; + private final JsonObject manifest; + + private ClimateFeverDataset(List claims, List corpus, String subsetJsonl, + String corpusJsonl, String mappingsJsonl, String goldJsonl, + Map> titlesByEvidenceId, String titlesJsonl, + JsonObject manifest) { + this.claims = Collections.unmodifiableList(claims); + this.corpus = Collections.unmodifiableList(corpus); + this.subsetJsonl = subsetJsonl; + this.corpusJsonl = corpusJsonl; + this.mappingsJsonl = mappingsJsonl; + this.goldJsonl = goldJsonl; + this.titlesByEvidenceId = Collections.unmodifiableMap(titlesByEvidenceId); + this.titlesJsonl = titlesJsonl; + this.manifest = manifest; + } + + public static ClimateFeverDataset load(Path raw, String expectedSha256) throws IOException { + if (expectedSha256 == null || !expectedSha256.matches("[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("Expected SHA-256 must contain exactly 64 hex digits"); + } + byte[] bytes = Files.readAllBytes(raw); + String actualSha256 = sha256(bytes); + if (!actualSha256.equals(expectedSha256.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Raw dataset SHA-256 does not match: " + actualSha256); + } + TemporalJsonCodec codec = new TemporalJsonCodec(); + Map records = new TreeMap<>(); + Map rawEvidenceContents = new HashMap<>(); + int lineNumber = 0; + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\\r?\\n", -1)) { + lineNumber++; + if (line.trim().isEmpty()) { + continue; + } + JsonObject record; + try { + record = codec.parseObject(line); + validateRecord(record, rawEvidenceContents); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Invalid dataset line " + lineNumber + ": " + + exception.getMessage(), exception); + } + String id = text(record, "claim_id"); + if (!id.matches("0|[1-9][0-9]*")) { + throw new IllegalArgumentException("claim_id must be a canonical nonnegative integer: " + id); + } + if (records.put(new BigInteger(id), record) != null) { + throw new IllegalArgumentException("Duplicate claim_id: " + id); + } + } + if (records.isEmpty()) { + throw new IllegalArgumentException("Dataset must contain at least one claim"); + } + + List claims = new ArrayList<>(); + Map corpus = new TreeMap<>(); + Map> titleSets = new TreeMap<>(); + StringBuilder subset = new StringBuilder(); + StringBuilder mappings = new StringBuilder(); + StringBuilder gold = new StringBuilder(); + int noGoldCount = 0; + int neiCount = 0; + int evidencePairCount = 0; + long goldPairCount = 0; + for (JsonObject record : records.values()) { + if (claims.size() == SUBSET_SIZE) { + break; + } + String claimId = text(record, "claim_id"); + String claimLabel = text(record, "claim_label"); + Set goldIds = new TreeSet<>(); + JsonObject selected = new JsonObject(); + selected.addProperty("claim_id", claimId); + selected.addProperty("claim", text(record, "claim")); + selected.addProperty("claim_label", claimLabel); + JsonArray selectedEvidence = new JsonArray(); + Map sortedEvidence = new TreeMap<>(); + for (JsonElement item : record.getAsJsonArray("evidences")) { + JsonObject evidence = item.getAsJsonObject(); + sortedEvidence.put(text(evidence, "evidence_id"), evidence); + } + for (JsonObject evidence : sortedEvidence.values()) { + String content = text(evidence, "evidence"); + String evidenceId = "climate-evidence-" + sha256(content.getBytes(StandardCharsets.UTF_8)); + String article = text(evidence, "article"); + titleSets.computeIfAbsent(evidenceId, ignored -> new TreeSet<>()).add(article); + String sourceId = "wikipedia-" + sha256(article.getBytes(StandardCharsets.UTF_8)); + Evidence candidate = new Evidence(evidenceId, new Source(sourceId, article), content); + Evidence existing = corpus.get(evidenceId); + if (existing != null && !existing.getContent().equals(content)) { + throw new IllegalArgumentException("Evidence SHA-256 collision: " + evidenceId); + } + // One text may occur in several articles; all original sources stay in mappings. + if (existing == null || article.compareTo(existing.getSource().getName()) < 0) { + corpus.put(evidenceId, candidate); + } + String label = text(evidence, "evidence_label"); + if ("SUPPORTS".equals(label) || "REFUTES".equals(label)) { + goldIds.add(evidenceId); + } + JsonObject mapping = new JsonObject(); + mapping.addProperty("claim_id", claimId); + mapping.addProperty("raw_evidence_id", text(evidence, "evidence_id")); + mapping.addProperty("evidence_id", evidenceId); + mapping.addProperty("evidence_label", label); + mapping.addProperty("source_id", sourceId); + mapping.addProperty("article", article); + append(mappings, mapping); + JsonObject entry = new JsonObject(); + entry.addProperty("raw_evidence_id", text(evidence, "evidence_id")); + entry.addProperty("evidence_id", evidenceId); + entry.addProperty("evidence_label", label); + entry.addProperty("article", article); + selectedEvidence.add(entry); + evidencePairCount++; + } + selected.add("evidences", selectedEvidence); + append(subset, selected); + Claim claim = new Claim(claimId, text(record, "claim"), claimLabel, goldIds); + claims.add(claim); + JsonObject queryGold = new JsonObject(); + queryGold.addProperty("claim_id", claimId); + queryGold.add("gold_evidence_ids", strings(goldIds)); + append(gold, queryGold); + goldPairCount += goldIds.size(); + if (goldIds.isEmpty()) { + noGoldCount++; + } + if ("NOT_ENOUGH_INFO".equals(claimLabel)) { + neiCount++; + } + } + StringBuilder corpusJsonl = new StringBuilder(); + for (Evidence evidence : corpus.values()) { + JsonObject item = new JsonObject(); + item.addProperty("evidence_id", evidence.getId()); + item.addProperty("text", evidence.getContent()); + item.addProperty("source_id", evidence.getSource().getId()); + item.addProperty("article", evidence.getSource().getName()); + append(corpusJsonl, item); + } + Map> titlesByEvidenceId = new TreeMap<>(); + StringBuilder titlesJsonl = new StringBuilder(); + for (Map.Entry> entry : titleSets.entrySet()) { + titlesByEvidenceId.put(entry.getKey(), + Collections.unmodifiableList(new ArrayList<>(entry.getValue()))); + JsonObject item = new JsonObject(); + item.addProperty("evidence_id", entry.getKey()); + item.add("titles", strings(entry.getValue())); + append(titlesJsonl, item); + } + JsonObject manifest = new JsonObject(); + manifest.addProperty("processorVersion", "climate-fever-prefix-v1"); + manifest.addProperty("dataset", "CLIMATE-FEVER"); + boolean official = RELEASE_SHA256.equals(actualSha256); + manifest.addProperty("release", official ? "1.0.1" : "custom-input"); + manifest.addProperty("officialReleaseBytes", official); + if (official) { + manifest.addProperty("sourceCommit", RELEASE_COMMIT); + manifest.addProperty("sourceUrl", SOURCE_URL); + manifest.addProperty("sourcePage", "https://github.com/tdiggelm/climate-fever-dataset/releases/tag/1.0.1"); + } + manifest.addProperty("rawSha256", actualSha256); + manifest.addProperty("rawBytes", bytes.length); + manifest.addProperty("license", "unknown"); + manifest.addProperty("licenseSource", "https://huggingface.co/datasets/tdiggelm/climate_fever/blob/main/README.md"); + manifest.addProperty("licenseNote", "Author dataset card declares unknown; no dataset license in source repository"); + manifest.addProperty("attribution", "Diggelmann, Boyd-Graber, Bulian, Ciaramita, Leippold (2020), arXiv:2012.00614"); + manifest.addProperty("selection", "Ascending numeric claim_id; first min(1000, raw claim count)"); + manifest.addProperty("evaluationCorpus", "Deduplicated attached evidence of selected claims; not full Wikipedia"); + manifest.addProperty("evidenceIdentity", "Exact original UTF-8 text SHA-256; no lexical normalization"); + manifest.addProperty("sourceSelection", "Lexicographically smallest article per identical text; retain all source mappings"); + manifest.addProperty("goldDefinition", "Distinct evidence text IDs with SUPPORTS or REFUTES; NOT_ENOUGH_INFO is not positive"); + manifest.addProperty("zeroGoldPolicy", "Exclude zero-positive queries from recall denominators; report separately"); + manifest.addProperty("rawClaimCount", records.size()); + manifest.addProperty("selectedClaimCount", claims.size()); + manifest.addProperty("selectedEvidencePairCount", evidencePairCount); + manifest.addProperty("corpusCount", corpus.size()); + manifest.addProperty("positivePairCount", goldPairCount); + manifest.addProperty("zeroGoldClaimCount", noGoldCount); + manifest.addProperty("notEnoughInfoClaimCount", neiCount); + List claimIds = new ArrayList<>(); + for (Claim claim : claims) { + claimIds.add(claim.getId()); + } + manifest.add("selectedClaimIds", strings(claimIds)); + manifest.addProperty("subsetSha256", sha256(subset.toString().getBytes(StandardCharsets.UTF_8))); + manifest.addProperty("corpusSha256", sha256(corpusJsonl.toString().getBytes(StandardCharsets.UTF_8))); + manifest.addProperty("mappingsSha256", sha256(mappings.toString().getBytes(StandardCharsets.UTF_8))); + manifest.addProperty("goldSha256", sha256(gold.toString().getBytes(StandardCharsets.UTF_8))); + manifest.addProperty("titleSelection", "All distinct original article titles per selected evidence text, regardless of label"); + manifest.addProperty("titlesSha256", sha256(titlesJsonl.toString().getBytes(StandardCharsets.UTF_8))); + return new ClimateFeverDataset(claims, new ArrayList<>(corpus.values()), subset.toString(), + corpusJsonl.toString(), mappings.toString(), gold.toString(), + titlesByEvidenceId, titlesJsonl.toString(), manifest); + } + + private static void validateRecord(JsonObject record, Map rawEvidenceContents) { + text(record, "claim_id"); + text(record, "claim"); + label(record, "claim_label", true); + if (!record.has("evidences") || !record.get("evidences").isJsonArray()) { + throw new IllegalArgumentException("evidences must be an array"); + } + Set evidenceIds = new HashSet<>(); + for (JsonElement item : record.getAsJsonArray("evidences")) { + if (!item.isJsonObject()) { + throw new IllegalArgumentException("Evidence must be an object"); + } + JsonObject evidence = item.getAsJsonObject(); + String id = text(evidence, "evidence_id"); + if (!evidenceIds.add(id)) { + throw new IllegalArgumentException("Duplicate evidence_id in one claim: " + id); + } + String content = text(evidence, "evidence"); + String article = text(evidence, "article"); + label(evidence, "evidence_label", false); + String signature = GSON.toJson(Arrays.asList(article, content)); + String existing = rawEvidenceContents.put(id, signature); + if (existing != null && !existing.equals(signature)) { + throw new IllegalArgumentException("Conflicting content/source for evidence_id: " + id); + } + } + } + + private static void label(JsonObject object, String name, boolean claim) { + String value = text(object, name); + if (!Arrays.asList("SUPPORTS", "REFUTES", "NOT_ENOUGH_INFO").contains(value) + && !(claim && "DISPUTED".equals(value))) { + throw new IllegalArgumentException("Unknown " + name + ": " + value); + } + } + + private static String text(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() + || value.getAsString().trim().isEmpty()) { + throw new IllegalArgumentException(name + " must be a nonempty string"); + } + return value.getAsString(); + } + + static String sha256(byte[] bytes) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); + char[] result = new char[digest.length * 2]; + for (int index = 0; index < digest.length; index++) { + result[index * 2] = Character.forDigit((digest[index] & 0xff) >>> 4, 16); + result[index * 2 + 1] = Character.forDigit(digest[index] & 0x0f, 16); + } + return new String(result); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private static JsonArray strings(Iterable values) { + JsonArray array = new JsonArray(); + for (String value : values) { + array.add(new com.google.gson.JsonPrimitive(value)); + } + return array; + } + + private static void append(StringBuilder builder, JsonObject object) { + builder.append(GSON.toJson(object)).append('\n'); + } + + public List getClaims() { + return claims; + } + + public List getCorpus() { + return corpus; + } + + public Map> getTitlesByEvidenceId() { + return titlesByEvidenceId; + } + + public JsonObject getManifest() { + return new com.google.gson.JsonParser().parse(GSON.toJson(manifest)).getAsJsonObject(); + } + + public void writeArtifacts(Path output) throws IOException { + Files.write(output.resolve("subset.jsonl"), subsetJsonl.getBytes(StandardCharsets.UTF_8)); + Files.write(output.resolve("corpus.jsonl"), corpusJsonl.getBytes(StandardCharsets.UTF_8)); + Files.write(output.resolve("source-mappings.jsonl"), mappingsJsonl.getBytes(StandardCharsets.UTF_8)); + Files.write(output.resolve("gold.jsonl"), goldJsonl.getBytes(StandardCharsets.UTF_8)); + Files.write(output.resolve("titles.jsonl"), titlesJsonl.getBytes(StandardCharsets.UTF_8)); + } + + public static final class Claim { + + private final String id; + private final String text; + private final String label; + private final Set goldIds; + + private Claim(String id, String text, String label, Set goldIds) { + this.id = id; + this.text = text; + this.label = label; + this.goldIds = Collections.unmodifiableSet(goldIds); + } + + public String getId() { + return id; + } + + public String getText() { + return text; + } + + public String getLabel() { + return label; + } + + public Set getGoldIds() { + return goldIds; + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/FactGoldEvaluation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/FactGoldEvaluation.java new file mode 100644 index 000000000..5d051e83c --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/FactGoldEvaluation.java @@ -0,0 +1,640 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; +import org.apache.geaflow.ai.temporal.adapter.MemoryGraphAdapter; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.QueryResult; +import org.apache.geaflow.ai.temporal.api.ReplayResult; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryEntity; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.SnapshotComparator; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; +import org.apache.geaflow.ai.temporal.trace.QueryTrace; + +/** Runs hand-authored fact Gold through every production candidate strategy. */ +public final class FactGoldEvaluation { + + private static final TemporalJsonCodec CODEC = new TemporalJsonCodec(); + private static final int TOP_K = 1; + private static final List FIXTURES = Arrays.asList( + "01-assert-entity-reference", "02-identical-duplicate", "03-event-id-reuse", + "04-evidence-merge", "05-conflict-coexistence", "06-adjacent-intervals", + "07-partial-correction", "08-partial-retraction", "09-valid-time-late", + "10-system-time-disorder", "11-same-timestamp", "12-invalid-target-atomicity"); + private static final List PROFILES = Arrays.asList( + RetrievalProfile.RULE_ONLY, RetrievalProfile.RULE_BM25, + RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL); + private static final Comparator EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + private static final Comparator END_ORDER = + Comparator.nullsLast(Comparator.naturalOrder()); + private static final Comparator QUERY_ORDER = + Comparator.comparing((MemoryFactVersion v) -> v.getValidTime().getStart()) + .thenComparing(v -> v.getValidTime().getEnd().orElse(null), END_ORDER) + .thenComparing(v -> v.getTransactionTime().getStart()) + .thenComparing(v -> v.getTransactionTime().getEnd().orElse(null), END_ORDER) + .thenComparing(MemoryFactVersion::getId); + private static final List CATEGORIES = Arrays.asList( + "semantic", "graph", "query", "decision", "oracle", "rollback"); + private static final List CSV_FIELDS = Arrays.asList( + "profile", "caseCount", "passedCaseCount", "conflictTP", "conflictFP", "conflictFN", + "conflictPrecision", "conflictRecall", "conflictF1", "negativeCaseCount", + "correctNegativeCaseCount", "semanticPassedCount", "graphPassedCount", + "queryPassedCount", "decisionPassedCount", "oraclePassedCount", "duplicateCount", + "rejectedBatchCount", "rollbackCheckCount"); + + public JsonObject evaluate(Path goldDirectory, Path outputDirectory) throws IOException { + require(Files.isDirectory(goldDirectory), "Gold directory does not exist: " + goldDirectory); + final Map fixtures = new TreeMap<>(); + final JsonObject hashes = new JsonObject(); + for (String name : FIXTURES) { + final byte[] bytes = Files.readAllBytes(goldDirectory.resolve(name + ".json")); + final JsonObject fixture = CODEC.parseObject(new String(bytes, StandardCharsets.UTF_8)); + require(fixture.get("schemaVersion").getAsInt() == 2 + && name.equals(text(fixture, "name")), "Unexpected Gold fixture: " + name); + fixtures.put(name, fixture); + hashes.addProperty(name + ".json", ClimateFeverDataset.sha256(bytes)); + } + if (Files.exists(outputDirectory)) { + try (Stream entries = Files.list(outputDirectory)) { + require(!entries.findAny().isPresent(), "Output directory must be empty"); + } + } else { + Files.createDirectories(outputDirectory); + } + final JsonArray cases = new JsonArray(); + final JsonArray summaries = new JsonArray(); + boolean passed = true; + for (RetrievalProfile profile : PROFILES) { + final List profileCases = new ArrayList<>(); + for (Map.Entry fixture : fixtures.entrySet()) { + final Path diagnostics = outputDirectory.resolve("diagnostics") + .resolve(profile.name()).resolve(fixture.getKey()); + Files.createDirectories(diagnostics); + final CaseRun run = new CaseRun(fixture.getValue(), profile); + run.execute(diagnostics); + cases.add(run.result); + profileCases.add(run.result); + passed &= run.result.get("passed").getAsBoolean(); + } + summaries.add(aggregate(profile, profileCases)); + } + writeCsv(outputDirectory.resolve("comparison.csv"), CSV_FIELDS, summaries); + final List caseFields = new ArrayList<>(CSV_FIELDS); + caseFields.add(1, "fixture"); + caseFields.add("passed"); + caseFields.add("firstError"); + writeCsv(outputDirectory.resolve("cases.csv"), caseFields, cases); + final JsonObject manifest = new JsonObject(); + manifest.addProperty("schema", "fact-gold-v1"); + manifest.addProperty("fixtureCount", fixtures.size()); + manifest.addProperty("caseCount", cases.size()); + manifest.addProperty("topK", TOP_K); + manifest.addProperty("passed", passed); + manifest.addProperty("goldSource", "hand-authored fixtures; Oracle is a separate check"); + manifest.addProperty("conflictUnit", "final materialized canonical CONFLICTS_WITH edge"); + manifest.addProperty("negativeCasePolicy", "empty Gold and prediction: null P/R/F1; " + + "count separately as a correct negative; aggregate uses summed TP/FP/FN"); + manifest.addProperty("zeroDenominatorPolicy", "undefined precision/recall/F1 is null"); + manifest.addProperty("batchPolicy", "fixture endExclusive boundaries; atomic applyBatch"); + manifest.add("inputSha256", hashes); + manifest.add("profiles", summaries); + manifest.add("cases", cases); + final JsonObject outputs = new JsonObject(); + for (String file : Arrays.asList("comparison.csv", "cases.csv")) { + outputs.addProperty(file, ClimateFeverDataset.sha256( + Files.readAllBytes(outputDirectory.resolve(file)))); + } + manifest.add("outputSha256", outputs); + writeJson(outputDirectory.resolve("manifest.json"), manifest); + return manifest; + } + + static JsonObject conflictMetrics(List gold, + List predicted) { + final Set expected = conflicts(gold); + final Set actual = conflicts(predicted); + final Set hits = new HashSet<>(expected); + hits.retainAll(actual); + return metrics(hits.size(), actual.size() - hits.size(), expected.size() - hits.size()); + } + + private static Set conflicts(List relations) { + final Set result = new HashSet<>(); + for (VersionRelation relation : relations) { + if (relation.getType() == VersionRelationType.CONFLICTS_WITH) { + result.add(relation); + } + } + return result; + } + + private static JsonObject metrics(int tp, int fp, int fn) { + final JsonObject result = new JsonObject(); + result.addProperty("conflictTP", tp); + result.addProperty("conflictFP", fp); + result.addProperty("conflictFN", fn); + result.add("conflictPrecision", ratio(tp, tp + fp)); + result.add("conflictRecall", ratio(tp, tp + fn)); + result.add("conflictF1", ratio(2 * tp, 2 * tp + fp + fn)); + return result; + } + + private static JsonElement ratio(int numerator, int denominator) { + return denominator == 0 ? JsonNull.INSTANCE + : new JsonPrimitive((double) numerator / denominator); + } + + private static JsonObject aggregate(RetrievalProfile profile, List cases) { + final JsonObject result = new JsonObject(); + result.addProperty("profile", profile.name()); + for (String field : CSV_FIELDS.subList(1, CSV_FIELDS.size())) { + if ("conflictPrecision".equals(field) || "conflictRecall".equals(field) + || "conflictF1".equals(field)) { + continue; + } + int total = 0; + for (JsonObject item : cases) { + total += item.get(field).getAsInt(); + } + result.addProperty(field, total); + } + copy(metrics(result.get("conflictTP").getAsInt(), result.get("conflictFP").getAsInt(), + result.get("conflictFN").getAsInt()), result); + return result; + } + + private static final class CaseRun { + + private final JsonObject fixture; + private final RetrievalProfile profile; + private final Map eventSpecs = new HashMap<>(); + private final Map definitions = new HashMap<>(); + private final Map events = new HashMap<>(); + private final Map identities = new HashMap<>(); + private final Set resolving = new HashSet<>(); + private final TemporalMemoryFacade facade = new TemporalMemoryFacade(); + private final List committed = new ArrayList<>(); + private final JsonObject result = new JsonObject(); + private final JsonArray failures = new JsonArray(); + private final Map checks = new TreeMap<>(); + private CanonicalSnapshot expected; + private int duplicates; + private int rejections; + private int rollbackChecks; + + private CaseRun(JsonObject fixture, RetrievalProfile profile) { + this.fixture = fixture; + this.profile = profile; + result.addProperty("profile", profile.name()); + result.addProperty("fixture", text(fixture, "name")); + for (JsonElement item : fixture.getAsJsonArray("events")) { + final JsonObject spec = item.getAsJsonObject(); + require(eventSpecs.put(text(spec, "alias"), spec) == null, "Duplicate input alias"); + } + for (String field : Arrays.asList("identities", "versions")) { + for (JsonElement item : fixture.getAsJsonObject("expected").getAsJsonArray(field)) { + final JsonObject spec = item.getAsJsonObject(); + require(definitions.put(text(spec, "alias"), spec) == null, + "Duplicate version alias"); + } + } + for (String alias : definitions.keySet()) { + identity(alias); + version(definitions.get(alias)); + } + for (String alias : eventSpecs.keySet()) { + normalize(alias); + } + expectedRelations(); + for (JsonElement item : fixture.getAsJsonObject("expected").getAsJsonArray("queries")) { + for (JsonElement alias : item.getAsJsonObject().getAsJsonArray("versions")) { + require(definitions.containsKey(alias.getAsString()), + "Unknown Gold query version alias: " + alias.getAsString()); + } + } + } + + private void execute(Path output) throws IOException { + try { + batches(); + expected = expectedSnapshot(); + check("semantic", new SnapshotComparator().compare(expected, facade.getSnapshot()) + .isEquivalent(), "Final snapshot differs from hand-authored Gold"); + check("oracle", new SnapshotComparator().compare(expected, + new FullReplayOracle().replayToSnapshot(committed)).isEquivalent(), + "Independent Oracle differs from hand-authored Gold"); + final MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + check("graph", new SnapshotComparator().compare(expected, + adapter.fromGraph(adapter.toGraph(facade.getSnapshot()))).isEquivalent(), + "Graph round trip differs from hand-authored Gold"); + check("graph", facade.validateInvariants().isValid(), "Facade invariants failed"); + queries(); + traces(); + } catch (RuntimeException error) { + check("semantic", false, error.getClass().getSimpleName() + ": " + error.getMessage()); + } + copy(conflictMetrics(expectedRelations(), facade.getSnapshot().getState().getRelations()), + result); + final boolean negative = result.get("conflictTP").getAsInt() + + result.get("conflictFN").getAsInt() == 0; + result.addProperty("negativeCaseCount", negative ? 1 : 0); + result.addProperty("correctNegativeCaseCount", negative + && result.get("conflictFP").getAsInt() == 0 ? 1 : 0); + result.addProperty("caseCount", 1); + result.addProperty("passed", failures.size() == 0); + result.addProperty("passedCaseCount", failures.size() == 0 ? 1 : 0); + for (String category : CATEGORIES) { + result.addProperty(category + "PassedCount", + Boolean.TRUE.equals(checks.get(category)) ? 1 : 0); + } + result.addProperty("duplicateCount", duplicates); + result.addProperty("rejectedBatchCount", rejections); + result.addProperty("rollbackCheckCount", rollbackChecks); + result.addProperty("firstError", failures.size() == 0 + ? null : failures.get(0).getAsString()); + result.add("failures", failures); + writeJson(output.resolve("checks.json"), result); + writeJson(output.resolve("actual-snapshot.json"), CODEC.snapshotToJson(facade.getSnapshot())); + if (expected != null) { + writeJson(output.resolve("expected-snapshot.json"), CODEC.snapshotToJson(expected)); + } + final List decisionTraces = new ArrayList<>(); + for (DecisionTrace trace : facade.getDecisionTraces()) { + decisionTraces.add(trace.toJson()); + } + writeLines(output.resolve("decision-traces.jsonl"), decisionTraces); + final List queryTraces = new ArrayList<>(); + for (QueryTrace trace : facade.getQueryTraces()) { + queryTraces.add(trace.toJson()); + } + writeLines(output.resolve("query-traces.jsonl"), queryTraces); + } + + private void batches() { + int start = 0; + for (JsonElement item : fixture.getAsJsonArray("batches")) { + final JsonObject batch = item.getAsJsonObject(); + final int end = batch.get("endExclusive").getAsInt(); + require(end > start && end <= fixture.getAsJsonArray("events").size(), + "Invalid batch boundary"); + final List attempted = new ArrayList<>(); + for (int index = start; index < end; index++) { + attempted.add(normalize(text(fixture.getAsJsonArray("events") + .get(index).getAsJsonObject(), "alias"))); + } + attempted.sort(EVENT_ORDER); + final CanonicalSnapshot before = facade.getSnapshot(); + final int traceCount = facade.getDecisionTraces().size(); + final String expectedFailure = nullable(batch, "expectedFailure"); + String actualFailure = null; + ReplayResult replay = null; + try { + replay = facade.applyBatch(attempted, profile, TOP_K); + } catch (TemporalValidationException error) { + actualFailure = error.getCode().name(); + } + check("decision", java.util.Objects.equals(expectedFailure, actualFailure), + "Batch " + end + " expected " + expectedFailure + ", got " + actualFailure); + if (expectedFailure == null) { + committed.addAll(attempted); + check("decision", replay != null, "Expected committed batch at " + end); + if (replay != null) { + batchDecisions(batch, replay, attempted); + } + } else { + rejections++; + rollbackChecks++; + check("rollback", before.equals(facade.getSnapshot()), + "Rejected batch published state at " + end); + final List traces = facade.getDecisionTraces(); + check("decision", traces.size() == traceCount + attempted.size(), + "Rejected batch trace count mismatch at " + end); + for (DecisionTrace trace : traces.subList(traceCount, traces.size())) { + final JsonObject json = trace.toJson(); + check("decision", "BATCH_ROLLED_BACK".equals(text(json, "code")) + && expectedFailure.equals(text(json, "validationCode")) + && strings(json, "generatedVersionIds").isEmpty(), + "Rejected attempt trace has publication or wrong error at " + end); + } + final List invalid = new ArrayList<>(committed); + invalid.addAll(attempted); + String oracleFailure = null; + try { + new FullReplayOracle().replayNormalized(invalid); + } catch (TemporalValidationException error) { + oracleFailure = error.getCode().name(); + } + check("oracle", expectedFailure.equals(oracleFailure), + "Independent Oracle rejection mismatch at " + end); + } + check("oracle", new FullReplayOracle().replayNormalized(committed) + .equals(facade.getSnapshot().getState()), "Oracle prefix mismatch at " + end); + start = end; + } + require(start == fixture.getAsJsonArray("events").size(), "Unconsumed input events"); + } + + private void batchDecisions(JsonObject batch, ReplayResult replay, + List attempted) { + final JsonArray specs = batch.getAsJsonArray("decisions"); + final List traces = replay.getDecisionTraces(); + check("decision", specs.size() == traces.size(), "Batch decision count mismatch"); + for (int index = 0; index < Math.min(specs.size(), traces.size()); index++) { + final JsonObject spec = specs.get(index).getAsJsonObject(); + final JsonObject trace = traces.get(index).toJson(); + final String code = text(spec, "code"); + check("decision", normalize(text(spec, "event")).equals(attempted.get(index)) + && normalize(text(spec, "event")).getEventId().equals(text(trace, "eventId")) + && code.equals(text(trace, "code")), "Batch decision/order mismatch"); + if ("DUPLICATE_NOOP".equals(code)) { + duplicates++; + check("decision", "NOT_EVALUATED_DUPLICATE".equals(text(trace, "retrievalStatus")) + && strings(trace, "generatedVersionIds").isEmpty(), + "Duplicate attempt performed retrieval/publication"); + } + } + } + + private void traces() { + final Map accepted = new HashMap<>(); + final Set attemptIds = new HashSet<>(); + for (DecisionTrace trace : facade.getDecisionTraces()) { + final JsonObject json = trace.toJson(); + check("decision", attemptIds.add(trace.getId()), "Duplicate trace attempt ID"); + check("decision", profile.name().equals(text(json, "retrievalProfile")) + && json.get("candidateTopK").getAsInt() == TOP_K, + "Trace lost candidate strategy or topK"); + if ("ACCEPTED".equals(text(json, "code"))) { + accepted.put(trace.getEventId(), json); + } + } + for (NormalizedMemoryEvent event : facade.getSnapshot().getEvents()) { + final JsonObject trace = accepted.get(event.getEventId()); + check("decision", trace != null, "Missing accepted trace: " + event.getEventId()); + if (trace != null) { + check("decision", event.getPayloadHash().equals(text(trace, "payloadHash")) + && !strings(trace, "rules").isEmpty() + && "COMPLETED".equals(text(trace, "retrievalStatus")), + "Accepted trace lost rule inputs or completed retrieval"); + if (event.getTargetVersionId().isPresent()) { + check("decision", event.getTargetVersionId().get() + .equals(text(trace, "targetVersionId")) + && strings(trace, "readVersionIds") + .contains(event.getTargetVersionId().get()), + "Targeted trace did not read the exact target"); + } + } + } + } + + private void queries() { + final Set modes = new HashSet<>(); + for (JsonElement item : fixture.getAsJsonObject("expected").getAsJsonArray("queries")) { + final JsonObject spec = item.getAsJsonObject(); + final QueryMode mode = QueryMode.valueOf(text(spec, "mode")); + modes.add(mode.name()); + final FactKey key = new FactKey(text(spec, "subjectId"), + text(spec, "predicate"), text(spec, "scope")); + final List hits = new ArrayList<>(); + for (JsonElement alias : spec.getAsJsonArray("versions")) { + hits.add(version(definitions.get(alias.getAsString()))); + } + hits.sort(QUERY_ORDER); + final QueryResult actual = facade.query(mode, key, Instant.parse(text(spec, "validAt")), + nullable(spec, "systemAt") == null ? null : Instant.parse(text(spec, "systemAt"))); + check("query", hits.equals(actual.getVersions()), "Query hits/order differ: " + spec); + check("query", actual.getTrace().toJson().equals( + facade.getQueryTrace(actual.getTrace().getId()).toJson()), "Query trace lookup differs"); + final Set expectedIds = new TreeSet<>(); + for (MemoryFactVersion version : hits) { + expectedIds.add(version.getId()); + } + check("query", expectedIds.equals(strings(actual.getTrace().toJson(), "hitVersionIds")), + "Query trace hit IDs differ from Gold"); + } + check("query", modes.containsAll(Arrays.asList("CURRENT", "HISTORICAL", "AS_OF")), + "Fixture does not cover all query modes"); + } + + private CanonicalSnapshot expectedSnapshot() { + final Map> byKey = new TreeMap<>(); + final Map provenance = new TreeMap<>(); + for (JsonElement item : fixture.getAsJsonObject("expected").getAsJsonArray("versions")) { + final JsonObject spec = item.getAsJsonObject(); + final MemoryFactVersion version = version(spec); + final FactKey key = new FactKey(text(spec.getAsJsonObject("subject"), "id"), + text(spec, "predicate"), text(spec, "scope")); + byKey.computeIfAbsent(key, ignored -> new ArrayList<>()).add(version); + provenance.put(version.getId(), normalize(text(spec, "event")).getEventId()); + } + return new CanonicalSnapshot(new TemporalState(byKey, expectedRelations()), + committed, provenance); + } + + private List expectedRelations() { + final List result = new ArrayList<>(); + for (JsonElement item : fixture.getAsJsonObject("expected").getAsJsonArray("relations")) { + final JsonObject spec = item.getAsJsonObject(); + result.add(new VersionRelation(VersionRelationType.valueOf(text(spec, "type")), + identity(text(spec, "from")), identity(text(spec, "to")))); + } + return result; + } + + private NormalizedMemoryEvent normalize(String alias) { + if (events.containsKey(alias)) { + return events.get(alias); + } + final JsonObject spec = eventSpecs.get(alias); + require(spec != null && resolving.add("event:" + alias), "Unknown/cyclic event alias: " + alias); + final JsonObject json = new JsonObject(); + json.addProperty("schemaVersion", 2); + for (String field : Arrays.asList("id", "operation", "subject", "predicate", "scope", + "factId", "value", "validFrom", "validTo", "recordedAt", "evidence")) { + json.add(field, spec.has(field) ? spec.get(field) : JsonNull.INSTANCE); + } + final String target = nullable(spec, "target"); + json.addProperty("target", definitions.containsKey(target) ? identity(target) : target); + final NormalizedMemoryEvent result = CODEC.decodeEvent(json); + events.put(alias, result); + resolving.remove("event:" + alias); + return result; + } + + private String identity(String alias) { + if (identities.containsKey(alias)) { + return identities.get(alias); + } + final JsonObject spec = definitions.get(alias); + require(spec != null && resolving.add("version:" + alias), "Unknown/cyclic version alias: " + alias); + final String source = nullable(spec, "sourceVersion"); + final String result = VersionIdentity.create(normalize(text(spec, "event")), fact(spec), + MemoryFactVersionStatus.valueOf(text(spec, "status")), interval(spec, "valid"), + source == null ? null : identity(source)); + identities.put(alias, result); + resolving.remove("version:" + alias); + return result; + } + + private MemoryFactVersion version(JsonObject spec) { + require(spec != null, "Unknown Gold query version alias"); + return new MemoryFactVersion(identity(text(spec, "alias")), fact(spec), + MemoryFactVersionStatus.valueOf(text(spec, "status")), interval(spec, "valid"), + interval(spec, "system"), evidence(spec.getAsJsonArray("evidence"))); + } + + private void check(String category, boolean condition, String message) { + checks.put(category, checks.getOrDefault(category, true) && condition); + if (!condition) { + failures.add(new JsonPrimitive(category + ": " + message)); + } + } + } + + private static MemoryFact fact(JsonObject spec) { + final JsonObject subject = spec.getAsJsonObject("subject"); + final MemoryEntity entity = new MemoryEntity(text(subject, "id"), text(subject, "label")); + final JsonObject value = spec.getAsJsonObject("value"); + if ("ENTITY_REF".equals(text(value, "kind"))) { + return MemoryFact.relationship(text(spec, "factId"), entity, text(spec, "predicate"), + new MemoryEntity(text(value, "value"), text(value, "label"))); + } + require("LITERAL".equals(text(value, "kind")), "Invalid Gold value kind"); + return MemoryFact.attribute(text(spec, "factId"), entity, text(spec, "predicate"), + text(value, "value")); + } + + private static List evidence(JsonArray specs) { + final List result = new ArrayList<>(); + for (JsonElement item : specs) { + final JsonObject spec = item.getAsJsonObject(); + final JsonObject source = spec.getAsJsonObject("source"); + result.add(new Evidence(text(spec, "id"), new Source(text(source, "id"), + text(source, "name")), text(spec, "content"))); + } + return result; + } + + private static TimeInterval interval(JsonObject spec, String prefix) { + final String end = nullable(spec, prefix + "To"); + return new TimeInterval(Instant.parse(text(spec, prefix + "From")), + end == null ? null : Instant.parse(end)); + } + + private static Set strings(JsonObject json, String field) { + final Set result = new TreeSet<>(); + for (JsonElement value : json.getAsJsonArray(field)) { + require(result.add(value.getAsString()), "Duplicate trace reference in " + field); + } + return result; + } + + private static String text(JsonObject json, String field) { + final String value = nullable(json, field); + require(value != null && !value.trim().isEmpty(), "Missing Gold field: " + field); + return value; + } + + private static String nullable(JsonObject json, String field) { + return !json.has(field) || json.get(field).isJsonNull() ? null : json.get(field).getAsString(); + } + + private static void require(boolean valid, String message) { + if (!valid) { + throw new IllegalArgumentException(message); + } + } + + private static void copy(JsonObject source, JsonObject target) { + for (Map.Entry item : source.entrySet()) { + target.add(item.getKey(), item.getValue()); + } + } + + private static void writeJson(Path path, JsonObject value) throws IOException { + Files.write(path, (CODEC.toJson(value) + "\n").getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE_NEW); + } + + private static void writeLines(Path path, List values) throws IOException { + final List lines = new ArrayList<>(); + for (JsonObject value : values) { + lines.add(CODEC.toJson(value)); + } + Files.write(path, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + } + + private static void writeCsv(Path path, List fields, JsonArray rows) throws IOException { + final List lines = new ArrayList<>(); + lines.add(String.join(",", fields)); + for (JsonElement row : rows) { + final JsonObject json = row.getAsJsonObject(); + final List values = new ArrayList<>(); + for (String field : fields) { + final String value = nullable(json, field); + values.add(value == null ? "" : "\"" + value.replace("\"", "\"\"") + "\""); + } + lines.add(String.join(",", values)); + } + Files.write(path, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineEvaluation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineEvaluation.java new file mode 100644 index 000000000..20c428cbc --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineEvaluation.java @@ -0,0 +1,724 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; +import org.apache.geaflow.ai.temporal.candidate.TextNormalizer; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; + +/** Evaluates the fixed title/body baseline, retaining only one scored pool at a time. */ +public final class RetrievalBaselineEvaluation { + + private static final String PROFILE = "CE_TITLE_BODY"; + private static final int DEPTH = 200; + private static final int FINAL_K = 20; + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().serializeNulls().create(); + private static final TemporalJsonCodec CODEC = new TemporalJsonCodec(); + private static final String[] INPUT_FILES = {"subset.jsonl", "corpus.jsonl", "gold.jsonl", + "source-mappings.jsonl", "titles.jsonl", "title-input.jsonl", "candidates.csv"}; + private static final String[] DATA_HASH_FIELDS = {"subsetSha256", "corpusSha256", "goldSha256", + "mappingsSha256", "titlesSha256"}; + + public JsonObject evaluate(Path preparedDirectory, Path scoresPath, Path metadataPath, Path output) + throws IOException { + final Path prepared = preparedDirectory.toRealPath(); + final Path scores = scoresPath.toRealPath(); + final Path metadataFile = metadataPath.toRealPath(); + final Path destination = output.toAbsolutePath().normalize(); + final String preparedHash = sha256(prepared.resolve("manifest.json")); + final Prepared data = loadPrepared(prepared); + final String metadataHash = sha256(metadataFile); + final JsonObject metadata = readJson(metadataFile); + validateMetadata(metadata, data.manifest); + require(sha256(scores).equals(text(metadata, "scoresSha256")), "Scores SHA-256 mismatch"); + emptyDirectory(destination); + final Metrics totals = new Metrics(); + long elapsedSum = 0; + try (BufferedReader inputRows = reader(prepared.resolve("title-input.jsonl")); + BufferedReader scoreRows = reader(scores); + BufferedReader annotations = reader(prepared.resolve("candidates.csv")); + BufferedWriter queryFile = writer(destination.resolve("query-results.csv")); + BufferedWriter candidateFile = writer(destination.resolve("final-candidates.csv"))) { + require("query_id,pool_rank,evidence_id,rule_exact".equals(annotations.readLine()), + "Unexpected candidate CSV columns"); + csv(queryFile, "profile", "query_id", "claim_label", "gold_count", "actual_pool_count", + "pool_hits", "pool_recall", "final_hits", "recall"); + csv(candidateFile, "profile", "query_id", "rank", "evidence_id", "cross_encoder_logit", + "source_pool_rank", "rule_exact", "is_gold"); + for (Claim claim : data.claims) { + final JsonObject query = nextJson(inputRows, "input query"); + validateQuery(query); + require(text(query, "queryId").equals(claim.id) + && text(query, "queryText").equals(claim.text), "Input query order/text mismatch"); + final JsonArray candidates = array(query, "candidates"); + final boolean[] rules = validateCandidates(query, data.corpus, claim, annotations); + final ScoredPool scored = scoreRow(nextJson(scoreRows, "score query"), query); + elapsedSum = Math.addExact(elapsedSum, scored.elapsedNanos); + final List order = rankCandidates(candidates, scored.logits, rules); + int poolHits = 0; + int hits = 0; + for (JsonElement candidate : candidates) { + if (claim.gold.contains(text(object(candidate, "candidate"), "evidenceId"))) { + poolHits++; + } + } + for (int index : order.subList(0, Math.min(FINAL_K, order.size()))) { + if (claim.gold.contains(text(candidates.get(index).getAsJsonObject(), "evidenceId"))) { + hits++; + } + } + totals.add(claim.gold.size(), hits, poolHits, candidates.size()); + csv(queryFile, PROFILE, claim.id, claim.label, claim.gold.size(), candidates.size(), + poolHits, fraction(poolHits, claim.gold.size()), hits, fraction(hits, claim.gold.size())); + for (int rank = 0; rank < Math.min(FINAL_K, order.size()); rank++) { + final int index = order.get(rank); + final String evidenceId = text(candidates.get(index).getAsJsonObject(), "evidenceId"); + csv(candidateFile, PROFILE, claim.id, rank + 1, evidenceId, scored.logits[index], + index + 1, rules[index], claim.gold.contains(evidenceId)); + } + } + exhausted(inputRows, "input query"); + exhausted(scoreRows, "score query"); + exhausted(annotations, "candidate annotation"); + } + require(totals.candidatePairCount == integer(data.manifest, "pairCount", 0) + && elapsedSum == integer(metadata, "inferenceNanos", 0), + "Score pair/timing totals differ from metadata"); + verifyFiles(prepared, object(data.manifest.get("outputSha256"), "outputSha256")); + require(sha256(prepared.resolve("manifest.json")).equals(preparedHash) + && sha256(metadataFile).equals(metadataHash) + && sha256(scores).equals(text(metadata, "scoresSha256")), + "Input artifacts changed during evaluation"); + final JsonObject comparison = totals.summary(); + try (BufferedWriter file = writer(destination.resolve("comparison.csv"))) { + final List names = new ArrayList<>(); + final List values = new ArrayList<>(); + for (Map.Entry entry : comparison.entrySet()) { + names.add(entry.getKey()); + values.add(entry.getValue().isJsonNull() ? null : entry.getValue().getAsString()); + } + csv(file, names.toArray()); + csv(file, values.toArray()); + } + final JsonObject result = result(data.manifest, metadata, comparison, prepared, scores, + metadataFile, preparedHash, metadataHash, destination); + // The manifest is the completion marker and is deliberately written last. + try (BufferedWriter file = writer(destination.resolve("manifest.json"))) { + file.write(JSON.toJson(result)); + file.write('\n'); + } + return result; + } + + static Prepared loadPrepared(Path directory) throws IOException { + final JsonObject manifest = readJson(directory.resolve("manifest.json")); + require("retrieval-baseline-inputs-v1".equals(text(manifest, "schema")) + && PROFILE.equals(text(manifest, "profile")) && integer(manifest, "depth", 1) == DEPTH + && integer(manifest, "finalK", 1) == FINAL_K, "Expected fixed retrieval baseline inputs"); + require("CLIMATE-FEVER".equals(text(manifest, "dataset")) + && "1.0.1".equals(text(manifest, "release")) + && ClimateFeverDataset.RELEASE_SHA256.equals(text(manifest, "rawSha256")) + && bool(manifest, "officialReleaseBytes"), "Expected pinned CLIMATE-FEVER release"); + final JsonObject hashes = object(manifest.get("outputSha256"), "outputSha256"); + verifyFiles(directory, hashes); + for (int index = 0; index < DATA_HASH_FIELDS.length; index++) { + require(text(hashes, INPUT_FILES[index]).equals(fingerprint(manifest, DATA_HASH_FIELDS[index])), + "Dataset hash reference mismatch: " + INPUT_FILES[index]); + } + final long count = integer(manifest, "queryCount", 1); + require(count <= 1000 && count == integer(manifest, "evaluatedQueryCount", 1), + "Invalid evaluated query count"); + integer(manifest, "pairCount", 0); + final Map corpus = loadCorpus(directory, manifest); + final List claims = loadClaims(directory, manifest, corpus); + return new Prepared(manifest, corpus, new ArrayList<>(claims.subList(0, (int) count))); + } + + private static Map loadCorpus(Path directory, JsonObject manifest) throws IOException { + final Map corpus = new TreeMap<>(); + try (BufferedReader rows = reader(directory.resolve("corpus.jsonl"))) { + String line; + while ((line = rows.readLine()) != null) { + final JsonObject row = CODEC.parseObject(line); + final String evidenceId = text(row, "evidence_id"); + final String body = text(row, "text"); + final String article = text(row, "article"); + require(evidenceId.equals("climate-evidence-" + hashText(body)), + "Corpus evidence ID differs from exact body hash"); + require(!corpus.containsKey(evidenceId) + && text(row, "source_id").equals("wikipedia-" + hashText(article)), "Invalid corpus identity"); + corpus.put(evidenceId, new Body(body, article)); + } + } + require(corpus.size() == integer(manifest, "corpusCount", 0), "Corpus count mismatch"); + return corpus; + } + + private static List loadClaims(Path directory, JsonObject manifest, Map corpus) + throws IOException { + final Map> titles = new HashMap<>(); + for (String evidenceId : corpus.keySet()) { + titles.put(evidenceId, new TreeSet<>()); + } + final List claims = new ArrayList<>(); + final JsonArray selectedIds = new JsonArray(); + long pairCount = 0; + long goldPairs = 0; + long zeroGoldCount = 0; + try (BufferedReader rows = reader(directory.resolve("subset.jsonl")); + BufferedReader mappings = reader(directory.resolve("source-mappings.jsonl")); + BufferedReader goldRows = reader(directory.resolve("gold.jsonl"))) { + String line; + while ((line = rows.readLine()) != null) { + final JsonObject claim = CODEC.parseObject(line); + final String id = text(claim, "claim_id"); + require(id.matches("0|[1-9][0-9]*"), "Invalid claim ID"); + require(claims.isEmpty() || new BigInteger(id).compareTo( + new BigInteger(claims.get(claims.size() - 1).id)) > 0, "Claims must follow numeric ID order"); + final String label = text(claim, "claim_label"); + require(Arrays.asList("SUPPORTS", "REFUTES", "NOT_ENOUGH_INFO", "DISPUTED").contains(label), + "Invalid claim label"); + final Set gold = new HashSet<>(); + final Set rawIds = new HashSet<>(); + for (JsonElement value : array(claim, "evidences")) { + final JsonObject evidence = object(value, "selected evidence"); + final String evidenceId = text(evidence, "evidence_id"); + final String article = text(evidence, "article"); + final String rawId = text(evidence, "raw_evidence_id"); + final String evidenceLabel = text(evidence, "evidence_label"); + require(corpus.containsKey(evidenceId) && rawIds.add(rawId) + && Arrays.asList("SUPPORTS", "REFUTES", "NOT_ENOUGH_INFO").contains(evidenceLabel), + "Invalid selected evidence"); + titles.get(evidenceId).add(article); + final JsonObject expected = new JsonObject(); + expected.addProperty("claim_id", id); + expected.addProperty("raw_evidence_id", rawId); + expected.addProperty("evidence_id", evidenceId); + expected.addProperty("evidence_label", evidenceLabel); + expected.addProperty("source_id", "wikipedia-" + hashText(article)); + expected.addProperty("article", article); + require(expected.equals(nextJson(mappings, "source mapping")), + "Source mapping differs from selected evidence"); + pairCount++; + if (!"NOT_ENOUGH_INFO".equals(evidenceLabel)) { + gold.add(evidenceId); + } + } + final JsonObject goldRow = nextJson(goldRows, "Gold query"); + final Set actualGold = new HashSet<>(); + for (JsonElement value : array(goldRow, "gold_evidence_ids")) { + require(actualGold.add(text(value, "gold evidence ID")), "Duplicate Gold evidence ID"); + } + require(id.equals(text(goldRow, "claim_id")) && actualGold.equals(gold), + "Gold differs from selected evidence labels"); + claims.add(new Claim(id, text(claim, "claim"), label, gold)); + selectedIds.add(new JsonPrimitive(id)); + goldPairs += gold.size(); + if (gold.isEmpty()) { + zeroGoldCount++; + } + } + exhausted(mappings, "source mapping"); + exhausted(goldRows, "Gold query"); + } + require(claims.size() == 1000 && integer(manifest, "selectedClaimCount", 1) == 1000 + && integer(manifest, "rawClaimCount", 1) == 1535 + && selectedIds.equals(array(manifest, "selectedClaimIds")), + "Selected claims differ from the pinned 1000-query subset"); + require(pairCount == integer(manifest, "selectedEvidencePairCount", 0) + && goldPairs == integer(manifest, "positivePairCount", 0) + && zeroGoldCount == integer(manifest, "zeroGoldClaimCount", 0), + "Dataset counts differ from prepared manifest"); + try (BufferedReader rows = reader(directory.resolve("titles.jsonl"))) { + for (Map.Entry entry : corpus.entrySet()) { + final Set sources = titles.get(entry.getKey()); + require(!sources.isEmpty() && sources.iterator().next().equals(entry.getValue().article), + "Corpus article must be the fixed lexicographically smallest source"); + final JsonObject expected = new JsonObject(); + expected.addProperty("evidence_id", entry.getKey()); + final JsonArray sourceTitles = new JsonArray(); + for (String title : sources) { + sourceTitles.add(new JsonPrimitive(title)); + } + expected.add("titles", sourceTitles); + require(expected.equals(nextJson(rows, "title row")), "Titles differ from original source mappings"); + } + exhausted(rows, "title row"); + } + return claims; + } + + static void verifyFiles(Path directory, JsonObject hashes) throws IOException { + fields(hashes, "Prepared hashes", INPUT_FILES); + final Path root = directory.toRealPath(); + for (String name : INPUT_FILES) { + final Path path = root.resolve(name).toRealPath(); + require(path.startsWith(root) && Files.isRegularFile(path), "Missing or unsafe artifact: " + name); + require(sha256(path).equals(fingerprint(hashes, name)), "Artifact SHA-256 mismatch: " + name); + } + } + + static void validateMetadata(JsonObject metadata, JsonObject manifest) { + validateMetadata(metadata, text(manifest.getAsJsonObject("outputSha256"), "title-input.jsonl"), + integer(manifest, "queryCount", 1), integer(manifest, "pairCount", 0), DEPTH); + } + + static void validateMetadata(JsonObject metadata, String inputSha256, long queryCount, + long pairCount, int maxCandidates) { + require(integer(metadata, "schemaVersion", 1) == 1, "Unsupported scoring metadata schema"); + require("cross-encoder/ms-marco-MiniLM-L6-v2".equals(text(metadata, "modelId")) + && "233902d25c440f23af6f7d6e94d2946bac0bee0a".equals(text(metadata, "modelRevision")), + "Expected pinned cross-encoder model and revision"); + final String device = text(metadata, "device"); + require("cpu".equals(device) || "cuda".equals(device), "Scoring device must be cpu or cuda"); + require(integer(metadata, "maxLength", 1) == 512 + && integer(metadata, "maxCandidates", 1) == maxCandidates + && integer(metadata, "batchSize", 1) == 16 && integer(metadata, "threads", 1) == 4, + "Baseline scoring configuration differs"); + require("raw_logit".equals(text(metadata, "scoreKind")) + && "complete_pool".equals(text(metadata, "timingScope")) + && (!metadata.has("reusedPairCount") || integer(metadata, "reusedPairCount", 0) == 0), + "Expected complete-pool raw logits"); + require(text(metadata, "inputSha256").equals(inputSha256) + && integer(metadata, "queryCount", 0) == queryCount + && integer(metadata, "pairCount", 0) == pairCount, + "Inference input/count references differ"); + integer(metadata, "inferenceNanos", 0); + integer(metadata, "loadNanos", 0); + fingerprint(metadata, "scoresSha256"); + require("2.8.0".equals(text(metadata, "torchVersion").split("\\+", 2)[0]) + && "4.57.1".equals(text(metadata, "transformersVersion")), + "Scoring dependencies differ from the fixed baseline environment"); + if ("cuda".equals(device)) { + text(metadata, "cudaVersion"); + text(metadata, "deviceName"); + integer(metadata, "deviceMemoryBytes", 1); + } + } + + static void validateQuery(JsonObject query) { + validateQuery(query, DEPTH); + } + + static void validateQuery(JsonObject query, int maxCandidates) { + fields(query, "Query", "schemaVersion", "queryId", "queryText", "poolSha256", "candidates"); + require(integer(query, "schemaVersion", 1) == 1, "Unsupported query schema"); + text(query, "queryId"); + text(query, "queryText"); + fingerprint(query, "poolSha256"); + final JsonArray candidates = array(query, "candidates"); + require(candidates.size() <= maxCandidates, "Candidate pool exceeds " + maxCandidates); + final Set ids = new HashSet<>(); + final JsonArray canonicalCandidates = new JsonArray(); + for (JsonElement element : candidates) { + final JsonObject candidate = object(element, "candidate"); + fields(candidate, "Candidate", "evidenceId", "text"); + final String id = text(candidate, "evidenceId"); + require(ids.add(id), "Duplicate evidence ID in input query"); + final JsonObject canonical = new JsonObject(); + canonical.addProperty("evidenceId", id); + canonical.addProperty("text", text(candidate, "text")); + canonicalCandidates.add(canonical); + } + final JsonObject canonical = new JsonObject(); + canonical.addProperty("schemaVersion", 1); + canonical.addProperty("queryId", text(query, "queryId")); + canonical.addProperty("queryText", text(query, "queryText")); + canonical.add("candidates", canonicalCandidates); + require(text(query, "poolSha256").equals(hashText(JSON.toJson(canonical))), + "Pool fingerprint mismatch: " + text(query, "queryId")); + } + + static boolean[] validateCandidates(JsonObject query, Map corpus, Claim claim, + BufferedReader annotations) throws IOException { + final JsonArray candidates = array(query, "candidates"); + final boolean[] rules = new boolean[candidates.size()]; + final String normalized = TextNormalizer.normalize(claim.text); + for (int index = 0; index < candidates.size(); index++) { + final JsonObject candidate = candidates.get(index).getAsJsonObject(); + final String id = text(candidate, "evidenceId"); + final Body body = corpus.get(id); + require(body != null && text(candidate, "text").equals(body.article + "\n" + body.text), + "Candidate must preserve its original body and fixed article title"); + rules[index] = normalized.equals(TextNormalizer.normalize(body.text)); + final String expected = claim.id + "," + (index + 1) + "," + id + "," + rules[index]; + require(expected.equals(annotations.readLine()), "Candidate ID/order/RULE annotation mismatch"); + } + return rules; + } + + static ScoredPool scoreRow(JsonObject row, JsonObject query) { + fields(row, "Score row", "schemaVersion", "queryId", "poolSha256", "elapsedNanos", "scores"); + require(integer(row, "schemaVersion", 1) == 1, "Unsupported score row schema"); + require(text(row, "queryId").equals(text(query, "queryId")) + && text(row, "poolSha256").equals(text(query, "poolSha256")), + "Score query/order/pool fingerprint mismatch"); + final long elapsed = integer(row, "elapsedNanos", 0); + final JsonArray scores = array(row, "scores"); + final JsonArray candidates = array(query, "candidates"); + require(scores.size() == candidates.size(), "Scores must cover the complete candidate pool"); + final double[] logits = new double[scores.size()]; + for (int index = 0; index < scores.size(); index++) { + final JsonObject value = object(scores.get(index), "Candidate score"); + fields(value, "Candidate score", "evidenceId", "logit"); + require(text(value, "evidenceId").equals(text(candidates.get(index).getAsJsonObject(), "evidenceId")), + "Score candidate ID/order mismatch"); + final JsonElement logit = value.get("logit"); + require(logit != null && logit.isJsonPrimitive() && logit.getAsJsonPrimitive().isNumber(), + "Invalid cross-encoder logit"); + logits[index] = logit.getAsDouble(); + require(Double.isFinite(logits[index]), "Invalid cross-encoder logit"); + } + require(logits.length > 0 || elapsed == 0, "An empty pool must have zero inference time"); + return new ScoredPool(logits, elapsed); + } + + static List rankCandidates(JsonArray candidates, double[] logits, boolean[] rules) { + require(candidates.size() == logits.length && logits.length == rules.length, + "Candidate/score/RULE sizes differ"); + final List order = new ArrayList<>(); + for (int index = 0; index < logits.length; index++) { + require(Double.isFinite(logits[index]), "Invalid cross-encoder logit"); + order.add(index); + } + order.sort((left, right) -> { + if (rules[left] != rules[right]) { + return rules[left] ? -1 : 1; + } + if (rules[left]) { + return Integer.compare(left, right); + } + // Numeric equality includes -0.0 == 0.0, as in the original Python sorter. + final int scoreOrder = logits[left] == logits[right] ? 0 : Double.compare(logits[right], logits[left]); + return scoreOrder != 0 ? scoreOrder : text(candidates.get(left).getAsJsonObject(), "evidenceId") + .compareTo(text(candidates.get(right).getAsJsonObject(), "evidenceId")); + }); + return order; + } + + private static JsonObject result(JsonObject manifest, JsonObject metadata, JsonObject comparison, + Path prepared, Path scores, Path metadataPath, String preparedHash, + String metadataHash, Path output) throws IOException { + final JsonObject result = new JsonObject(); + result.addProperty("schema", "retrieval-baseline-results-v1"); + result.addProperty("profile", PROFILE); + result.addProperty("depth", DEPTH); + result.addProperty("finalK", FINAL_K); + result.add("queryCount", manifest.get("queryCount")); + result.add("pairCount", manifest.get("pairCount")); + result.addProperty("preparedDirectory", prepared.toString()); + result.addProperty("preparedManifestSha256", preparedHash); + result.add("preparedOutputSha256", manifest.get("outputSha256")); + result.addProperty("scoresPath", scores.toString()); + result.addProperty("metadataPath", metadataPath.toString()); + result.add("scoresSha256", metadata.get("scoresSha256")); + result.addProperty("metadataSha256", metadataHash); + result.add("inference", metadata); + final JsonArray comparisons = new JsonArray(); + comparisons.add(comparison); + result.add("comparison", comparisons); + result.addProperty("rankingPolicy", "RULE exact matches first in original pool order; " + + "other candidates by raw logit descending, evidence ID ascending"); + result.addProperty("goldPolicy", "SUPPORTS and REFUTES are positive; zero-Gold queries are " + + "excluded from recall denominators; labels never affect ranking"); + final JsonObject hashes = new JsonObject(); + for (String file : new String[] {"comparison.csv", "query-results.csv", "final-candidates.csv"}) { + hashes.addProperty(file, sha256(output.resolve(file))); + } + result.add("outputSha256", hashes); + return result; + } + + static final class Metrics { + private long evaluatedQueryCount; + private long positiveQueryCount; + private long zeroGoldQueryCount; + private long positivePairCount; + private long candidatePairCount; + private long hits; + private long poolHits; + private double recallSum; + private double poolRecallSum; + private long anyHits; + private long allHits; + + void add(int goldCount, int finalHits, int candidateHits, int poolCount) { + evaluatedQueryCount++; + candidatePairCount += poolCount; + if (goldCount == 0) { + zeroGoldQueryCount++; + return; + } + positiveQueryCount++; + positivePairCount += goldCount; + hits += finalHits; + poolHits += candidateHits; + recallSum += (double) finalHits / goldCount; + poolRecallSum += (double) candidateHits / goldCount; + if (finalHits > 0) { + anyHits++; + } + if (finalHits == goldCount) { + allHits++; + } + } + + JsonObject summary() { + final JsonObject result = new JsonObject(); + result.addProperty("profile", PROFILE); + result.addProperty("depth", DEPTH); + result.addProperty("finalK", FINAL_K); + number(result, "macroRecall", fraction(recallSum, positiveQueryCount)); + number(result, "microRecall", fraction(hits, positivePairCount)); + number(result, "anyHitRate", fraction(anyHits, positiveQueryCount)); + number(result, "allGoldHitRate", fraction(allHits, positiveQueryCount)); + number(result, "zeroHitRate", fraction(positiveQueryCount - anyHits, positiveQueryCount)); + number(result, "poolMacroRecall", fraction(poolRecallSum, positiveQueryCount)); + number(result, "poolMicroRecall", fraction(poolHits, positivePairCount)); + result.addProperty("evaluatedQueryCount", evaluatedQueryCount); + result.addProperty("positiveQueryCount", positiveQueryCount); + result.addProperty("zeroGoldQueryCount", zeroGoldQueryCount); + result.addProperty("positivePairCount", positivePairCount); + result.addProperty("candidatePairCount", candidatePairCount); + result.addProperty("hits", hits); + result.addProperty("poolHits", poolHits); + return result; + } + } + + static final class ScoredPool { + final double[] logits; + final long elapsedNanos; + + private ScoredPool(double[] logits, long elapsedNanos) { + this.logits = logits; + this.elapsedNanos = elapsedNanos; + } + } + + static final class Prepared { + final JsonObject manifest; + final Map corpus; + final List claims; + + private Prepared(JsonObject manifest, Map corpus, List claims) { + this.manifest = manifest; + this.corpus = corpus; + this.claims = claims; + } + } + + static final class Body { + final String text; + final String article; + + Body(String text, String article) { + this.text = text; + this.article = article; + } + } + + static final class Claim { + final String id; + final String text; + final String label; + final Set gold; + + private Claim(String id, String text, String label, Set gold) { + this.id = id; + this.text = text; + this.label = label; + this.gold = gold; + } + } + + static String sha256(Path path) throws IOException { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + final byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + digest.update(buffer, 0, count); + } + } + final StringBuilder result = new StringBuilder(64); + for (byte value : digest.digest()) { + result.append(Character.forDigit((value & 0xff) >>> 4, 16)); + result.append(Character.forDigit(value & 0xf, 16)); + } + return result.toString(); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + static String hashText(String value) { + return ClimateFeverDataset.sha256(value.getBytes(StandardCharsets.UTF_8)); + } + + static BufferedReader reader(Path path) throws IOException { + return Files.newBufferedReader(path, StandardCharsets.UTF_8); + } + + static BufferedWriter writer(Path path) throws IOException { + return Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + } + + static JsonObject readJson(Path path) throws IOException { + return CODEC.parseObject(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); + } + + static JsonObject nextJson(BufferedReader reader, String name) throws IOException { + final String line = reader.readLine(); + require(line != null, "Missing " + name); + return CODEC.parseObject(line); + } + + static void exhausted(BufferedReader reader, String name) throws IOException { + require(reader.readLine() == null, "Unexpected extra " + name); + } + + static void emptyDirectory(Path directory) throws IOException { + if (Files.exists(directory)) { + require(Files.isDirectory(directory), "Output must be an empty directory"); + try (Stream files = Files.list(directory)) { + require(!files.findAny().isPresent(), "Output directory must be empty"); + } + } + Files.createDirectories(directory); + } + + static void csv(BufferedWriter writer, Object... values) throws IOException { + for (int index = 0; index < values.length; index++) { + if (index > 0) { + writer.write(','); + } + final String value = values[index] == null ? "" : values[index].toString(); + if (value.indexOf(',') >= 0 || value.indexOf('"') >= 0 + || value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) { + writer.write('"'); + writer.write(value.replace("\"", "\"\"")); + writer.write('"'); + } else { + writer.write(value); + } + } + writer.write("\r\n"); + } + + static void fields(JsonObject value, String name, String... expected) { + final Set actual = new HashSet<>(); + for (Map.Entry entry : value.entrySet()) { + actual.add(entry.getKey()); + } + require(actual.equals(new HashSet<>(Arrays.asList(expected))), name + " has missing or unexpected fields"); + } + + static JsonObject object(JsonElement value, String name) { + require(value != null && value.isJsonObject(), name + " must be a JSON object"); + return value.getAsJsonObject(); + } + + static JsonArray array(JsonObject value, String name) { + final JsonElement field = value.get(name); + require(field != null && field.isJsonArray(), name + " must be an array"); + return field.getAsJsonArray(); + } + + static String text(JsonObject value, String name) { + return text(value.get(name), name); + } + + static String text(JsonElement value, String name) { + require(value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString(), + name + " must be a nonempty string"); + final String result = value.getAsString(); + require(!TextNormalizer.normalize(result).isEmpty(), name + " must be a nonempty string"); + return result; + } + + private static boolean bool(JsonObject value, String name) { + final JsonElement field = value.get(name); + require(field != null && field.isJsonPrimitive() && field.getAsJsonPrimitive().isBoolean(), + name + " must be boolean"); + return field.getAsBoolean(); + } + + static long integer(JsonObject value, String name, long minimum) { + final JsonElement field = value.get(name); + require(field != null && field.isJsonPrimitive() && field.getAsJsonPrimitive().isNumber() + && field.getAsString().matches("0|[1-9][0-9]*"), name + " must be an integer >= " + minimum); + try { + final long result = Long.parseLong(field.getAsString()); + require(result >= minimum, name + " must be an integer >= " + minimum); + return result; + } catch (NumberFormatException error) { + throw new IllegalArgumentException(name + " exceeds the supported integer range", error); + } + } + + static String fingerprint(JsonObject value, String name) { + final String result = text(value, name); + require(result.matches("[0-9a-f]{64}"), name + " must be a lowercase SHA-256"); + return result; + } + + static Double fraction(double numerator, long denominator) { + return denominator == 0 ? null : numerator / denominator; + } + + private static void number(JsonObject target, String name, Double value) { + target.add(name, value == null ? JsonNull.INSTANCE : new JsonPrimitive(value)); + } + + static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselinePreparation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselinePreparation.java new file mode 100644 index 000000000..b9ae6b07d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselinePreparation.java @@ -0,0 +1,199 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.apache.geaflow.ai.temporal.candidate.EvidenceCandidate; +import org.apache.geaflow.ai.temporal.candidate.EvidenceRetriever; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.candidate.TextNormalizer; +import org.apache.geaflow.ai.temporal.model.Evidence; + +/** Prepares the fixed 200-candidate, title-and-body cross-encoder baseline. */ +public final class RetrievalBaselinePreparation { + + private static final int DEPTH = 200; + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + private static final String[] OUTPUT_FILES = {"subset.jsonl", "corpus.jsonl", "gold.jsonl", + "source-mappings.jsonl", "titles.jsonl", "title-input.jsonl", "candidates.csv"}; + + public JsonObject prepare(ClimateFeverDataset dataset, Path output, int queryLimit) + throws IOException { + if (queryLimit < 1 || queryLimit > ClimateFeverDataset.SUBSET_SIZE) { + throw new IllegalArgumentException("queryLimit must be between 1 and 1000"); + } + emptyDirectory(output); + final int count = Math.min(queryLimit, dataset.getClaims().size()); + final Map> exact = new HashMap<>(); + for (Evidence evidence : dataset.getCorpus()) { + exact.computeIfAbsent(TextNormalizer.normalize(evidence.getContent()), + ignored -> new ArrayList<>()).add(evidence); + } + long pairCount = 0; + try (EvidenceRetriever fusion = new EvidenceRetriever(dataset.getCorpus(), + RetrievalProfile.LEXICAL_FUSION); + EvidenceRetriever english = new EvidenceRetriever(dataset.getCorpus(), + RetrievalProfile.ENGLISH); + BufferedWriter input = writer(output.resolve("title-input.jsonl")); + BufferedWriter candidates = writer(output.resolve("candidates.csv"))) { + candidates.write("query_id,pool_rank,evidence_id,rule_exact\n"); + for (ClimateFeverDataset.Claim claim : dataset.getClaims().subList(0, count)) { + final String normalizedQuery = TextNormalizer.normalize(claim.getText()); + final List pool = candidatePool( + exact.getOrDefault(normalizedQuery, Collections.emptyList()), + fusion.search(claim.getText(), 100), english.search(claim.getText(), 500)); + input.write(JSON.toJson(poolInput(claim.getId(), claim.getText(), pool))); + input.write('\n'); + for (int index = 0; index < pool.size(); index++) { + final Evidence evidence = pool.get(index); + candidates.write(claim.getId() + "," + (index + 1) + "," + evidence.getId() + + "," + normalizedQuery.equals(TextNormalizer.normalize(evidence.getContent())) + "\n"); + } + pairCount += pool.size(); + } + } + dataset.writeArtifacts(output); + final JsonObject manifest = dataset.getManifest(); + manifest.addProperty("schema", "retrieval-baseline-inputs-v1"); + manifest.addProperty("profile", "CE_TITLE_BODY"); + manifest.addProperty("depth", DEPTH); + manifest.addProperty("finalK", 20); + manifest.addProperty("queryCount", count); + manifest.addProperty("evaluatedQueryCount", count); + manifest.addProperty("pairCount", pairCount); + manifest.addProperty("poolPolicy", "Freeze RULE-first LEXICAL_FUSION top 100; append original " + + "ENGLISH BM25 top-500 ranks, deduplicate evidence IDs and cap at 200"); + manifest.addProperty("titlePolicy", "Frozen global corpus.article, newline, original body; " + + "independent of query and Gold"); + manifest.addProperty("rulePolicy", "TextNormalizer equality on original query and original body; " + + "never on title-prefixed text"); + manifest.addProperty("goldPolicy", "Labels and Gold links affect metrics only, never candidates or model input"); + manifest.addProperty("querySelection", "First queryCount claims of the fixed numeric-ID subset"); + final JsonObject hashes = new JsonObject(); + for (String file : OUTPUT_FILES) { + hashes.addProperty(file, sha256(output.resolve(file))); + } + manifest.add("outputSha256", hashes); + try (BufferedWriter file = writer(output.resolve("manifest.json"))) { + file.write(JSON.toJson(manifest)); + file.write('\n'); + } + return manifest; + } + + static List candidatePool(List rule, List fusion, + List english) { + final Map original = new LinkedHashMap<>(); + for (Evidence evidence : rule) { + original.put(evidence.getId(), evidence); + } + for (EvidenceCandidate candidate : fusion) { + original.put(candidate.getEvidence().getId(), candidate.getEvidence()); + } + final List first = new ArrayList<>(original.values()); + final Map result = new LinkedHashMap<>(); + for (Evidence evidence : first.subList(0, Math.min(first.size(), 100))) { + result.put(evidence.getId(), evidence); + } + for (EvidenceCandidate candidate : english) { + if (result.size() >= DEPTH) { + break; + } + result.putIfAbsent(candidate.getEvidence().getId(), candidate.getEvidence()); + } + return new ArrayList<>(result.values()); + } + + private static JsonObject poolInput(String queryId, String queryText, List pool) { + final JsonObject input = new JsonObject(); + input.addProperty("schemaVersion", 1); + input.addProperty("queryId", queryId); + input.addProperty("queryText", queryText); + final JsonArray candidates = new JsonArray(); + for (Evidence evidence : pool) { + final JsonObject candidate = new JsonObject(); + candidate.addProperty("evidenceId", evidence.getId()); + candidate.addProperty("text", evidence.getSource().getName() + "\n" + evidence.getContent()); + candidates.add(candidate); + } + input.add("candidates", candidates); + input.addProperty("poolSha256", ClimateFeverDataset.sha256( + JSON.toJson(input).getBytes(StandardCharsets.UTF_8))); + return input; + } + + private static String sha256(Path path) throws IOException { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { + final byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + digest.update(buffer, 0, count); + } + } + final StringBuilder result = new StringBuilder(); + for (byte value : digest.digest()) { + result.append(Character.forDigit((value & 0xff) >>> 4, 16)); + result.append(Character.forDigit(value & 0xf, 16)); + } + return result.toString(); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static BufferedWriter writer(Path path) throws IOException { + return Files.newBufferedWriter(path, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + } + + private static void emptyDirectory(Path output) throws IOException { + if (Files.exists(output)) { + if (!Files.isDirectory(output)) { + throw new IllegalArgumentException("Output must be an empty directory"); + } + try (Stream files = Files.list(output)) { + if (files.findAny().isPresent()) { + throw new IllegalArgumentException("Output directory must be empty"); + } + } + } + Files.createDirectories(output); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineRunner.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineRunner.java new file mode 100644 index 000000000..0d31f4049 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineRunner.java @@ -0,0 +1,292 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; + +/** Runs Java preparation and evaluation around the Python-only model scorer. */ +public final class RetrievalBaselineRunner { + + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + + public JsonObject run(Path moduleRoot, Path inputPath, Path output, String device, + Path python, Path reuseInferenceDirectory, boolean offline) throws IOException { + final Path module = canonical(moduleRoot); + final Path input = canonical(inputPath == null + ? module.resolve("target/climate-fever/climate-fever-1.0.1.jsonl") : inputPath); + final Path worker = module.resolve("bin/score-temporal-cross-encoder.py"); + final Path cache = module.resolve("target/cross-encoder-cache"); + final Path inference = reuseInferenceDirectory == null ? null : canonical(reuseInferenceDirectory); + final String selectedDevice = device == null ? "cuda" : device.toLowerCase(Locale.ROOT); + if (!"cuda".equals(selectedDevice) && !"cpu".equals(selectedDevice)) { + throw new IllegalArgumentException("Device must be cpu or cuda"); + } + final String environment = "cuda".equals(selectedDevice) + ? "cross-encoder-cuda-venv" : "cross-encoder-venv"; + final boolean windows = System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("windows"); + final Path executable = canonical(python == null + ? module.resolve("target/" + environment + (windows ? "/Scripts/python.exe" : "/bin/python")) : python); + final Path destination = initializeOutput(module, input, output, executable, cache, inference); + final Path prepared = destination.resolve("prepared"); + final Path scored = inference == null ? destination.resolve("inference") : inference; + final Path evaluated = destination.resolve("evaluation"); + final JsonArray processes = new JsonArray(); + final String started = Instant.now().toString(); + try { + Files.createDirectories(destination.resolve("logs")); + if (!Files.isRegularFile(input)) { + throw new IOException("The fixed CLIMATE-FEVER dataset is not cached: " + input + + ". Supply a local input path; this Java runner does not download datasets."); + } + if (inference == null && (!Files.isRegularFile(executable) || !Files.isRegularFile(worker))) { + throw new IOException("An existing Python model environment and scorer are required: " + + executable + "; " + worker); + } + if (inference != null && (!Files.isRegularFile(scored.resolve("scores.jsonl")) + || !Files.isRegularFile(scored.resolve("metadata.json")))) { + throw new IOException("Reused inference must contain completed scores.jsonl and metadata.json: " + scored); + } + System.err.println("Preparing the fixed 1000-query, 200-candidate title/body baseline: " + prepared); + final ClimateFeverDataset dataset = ClimateFeverDataset.load(input, ClimateFeverDataset.RELEASE_SHA256); + final JsonObject preparation = new RetrievalBaselinePreparation().prepare(dataset, prepared, + ClimateFeverDataset.SUBSET_SIZE); + if (preparation.get("queryCount").getAsInt() != ClimateFeverDataset.SUBSET_SIZE) { + throw new IOException("The baseline requires all 1000 fixed queries"); + } + final String preparedHash = sha256(prepared.resolve("manifest.json")); + if (inference == null) { + runScoring(scoringCommand(executable, worker, prepared, scored, cache, selectedDevice, offline), + module, destination.resolve("logs"), processes); + } else { + System.err.println("Reusing completed model scores; Java will verify the regenerated input: " + scored); + } + writeJson(destination.resolve("processes.json"), processes, false); + System.err.println("Evaluating CE_TITLE_BODY at Top20 in Java: " + evaluated); + final Path scores = scored.resolve("scores.jsonl"); + final Path metadata = scored.resolve("metadata.json"); + final JsonObject evaluation = new RetrievalBaselineEvaluation().evaluate(prepared, scores, metadata, evaluated); + if (!"retrieval-baseline-results-v1".equals(evaluation.get("schema").getAsString()) + || !preparedHash.equals(evaluation.get("preparedManifestSha256").getAsString()) + || !preparedHash.equals(sha256(prepared.resolve("manifest.json"))) + || !sha256(scores).equals(evaluation.get("scoresSha256").getAsString()) + || !sha256(metadata).equals(evaluation.get("metadataSha256").getAsString()) + || !ClimateFeverDataset.RELEASE_SHA256.equals(sha256(input))) { + throw new IOException("Dataset, prepared input or model scores changed during the baseline run"); + } + Files.copy(evaluated.resolve("comparison.csv"), destination.resolve("comparison.csv")); + final JsonObject model = readObject(metadata); + final JsonObject manifest = new JsonObject(); + manifest.addProperty("schema", "retrieval-baseline-run-v1"); + manifest.addProperty("status", "COMPLETED"); + manifest.addProperty("profile", "CE_TITLE_BODY"); + manifest.addProperty("depth", 200); + manifest.addProperty("finalK", 20); + manifest.addProperty("queryCount", ClimateFeverDataset.SUBSET_SIZE); + manifest.add("pairCount", preparation.get("pairCount")); + manifest.addProperty("startedAtUtc", started); + manifest.addProperty("completedAtUtc", Instant.now().toString()); + manifest.addProperty("inputPath", input.toString()); + manifest.addProperty("rawSha256", ClimateFeverDataset.RELEASE_SHA256); + manifest.addProperty("releaseCommit", ClimateFeverDataset.RELEASE_COMMIT); + manifest.addProperty("datasetUrl", ClimateFeverDataset.SOURCE_URL); + manifest.addProperty("preparedDirectory", prepared.toString()); + manifest.addProperty("evaluationDirectory", evaluated.toString()); + manifest.addProperty("inferenceDirectory", scored.toString()); + manifest.addProperty("inferenceReused", inference != null); + manifest.addProperty("requestedDevice", selectedDevice); + manifest.add("device", model.get("device")); + manifest.addProperty("javaVersion", System.getProperty("java.version")); + if (inference == null) { + manifest.addProperty("pythonExecutable", executable.toString()); + manifest.addProperty("scorerSha256", sha256(worker)); + } + manifest.addProperty("modelDownloadAllowed", !offline && inference == null); + manifest.addProperty("preparedManifestSha256", preparedHash); + manifest.addProperty("evaluationManifestSha256", sha256(evaluated.resolve("manifest.json"))); + manifest.addProperty("metadataSha256", evaluation.get("metadataSha256").getAsString()); + manifest.addProperty("scoresSha256", evaluation.get("scoresSha256").getAsString()); + manifest.add("inference", model); + manifest.add("processResources", processes); + manifest.add("outputSha256", artifactHashes(destination)); + writeJson(destination.resolve("manifest.json"), manifest, true); + System.err.println("Retrieval baseline completed: " + destination); + return manifest; + } catch (IOException | RuntimeException error) { + final JsonObject failure = new JsonObject(); + failure.addProperty("schema", "retrieval-baseline-run-v1"); + failure.addProperty("status", "FAILED"); + failure.addProperty("error", error.toString()); + failure.addProperty("preparedDirectory", prepared.toString()); + failure.addProperty("inferenceDirectory", scored.toString()); + failure.addProperty("evaluationDirectory", evaluated.toString()); + failure.add("processResources", processes); + try { + writeJson(destination.resolve("processes.json"), processes, false); + writeJson(destination.resolve("failure.json"), failure, true); + } catch (IOException recordingError) { + error.addSuppressed(recordingError); + } + throw error; + } + } + + static List scoringCommand(Path python, Path worker, Path prepared, Path inference, + Path cache, String device, boolean offline) { + final List command = new ArrayList<>(Arrays.asList(python.toString(), worker.toString(), + "--input", prepared.resolve("title-input.jsonl").toString(), + "--output", inference.resolve("scores.jsonl").toString(), + "--metadata", inference.resolve("metadata.json").toString(), + "--cache-dir", cache.toString(), "--device", device, + "--max-candidates", "200", "--max-length", "512", "--batch-size", "16", "--threads", "4")); + if (offline) { + command.add("--local-files-only"); + } + return command; + } + + static Path initializeOutput(Path module, Path input, Path output, Path python, + Path cache, Path reusedInference) throws IOException { + final Path destination = canonical(output); + if (!Files.isDirectory(module) || module.startsWith(destination)) { + throw new IllegalArgumentException("Output must be separate from the module root"); + } + final List protectedPaths = new ArrayList<>(Arrays.asList(input, python, cache, + module.resolve("src"), module.resolve("bin"), module.resolve("pom.xml"), + module.resolve("target/classes"), module.resolve("target/test-classes"), + module.resolve("target/maven-status"), module.resolve("target/cross-encoder-venv"), + module.resolve("target/cross-encoder-cuda-venv"))); + if (reusedInference != null) { + protectedPaths.add(reusedInference); + } + for (Path resource : protectedPaths) { + final Path resolved = canonical(resource); + if (destination.startsWith(resolved) || resolved.startsWith(destination)) { + throw new IllegalArgumentException("Output overlaps a baseline input, source, environment or cache: " + resource); + } + } + if (Files.exists(destination)) { + if (!Files.isDirectory(destination)) { + throw new IllegalArgumentException("Output must be a new or empty directory"); + } + try (Stream entries = Files.list(destination)) { + if (entries.findAny().isPresent()) { + throw new IllegalArgumentException("Output must be a new or empty directory"); + } + } + } + Files.createDirectories(destination); + return destination; + } + + static void runScoring(List command, Path module, Path logs, + JsonArray processes) throws IOException { + final JsonObject record = new JsonObject(); + record.addProperty("name", "scoring"); + record.addProperty("executable", command.get(0)); + record.add("arguments", JSON.toJsonTree(command.subList(1, command.size()))); + record.addProperty("status", "RUNNING"); + record.addProperty("stdout", "logs/scoring.stdout.log"); + record.addProperty("stderr", "logs/scoring.stderr.log"); + processes.add(record); + final long started = System.nanoTime(); + Process process = null; + try { + System.err.println("Running Python model scoring; logs: " + logs); + process = new ProcessBuilder(command).directory(module.toFile()) + .redirectOutput(logs.resolve("scoring.stdout.log").toFile()) + .redirectError(logs.resolve("scoring.stderr.log").toFile()).start(); + final int exit = process.waitFor(); + record.addProperty("exitCode", exit); + if (exit != 0) { + throw new IOException("Model scoring failed with exit code " + exit + + ". See " + logs.resolve("scoring.stderr.log")); + } + record.addProperty("status", "COMPLETED"); + } catch (InterruptedException error) { + if (process != null) { + process.destroyForcibly(); + } + Thread.currentThread().interrupt(); + record.addProperty("status", "FAILED"); + throw new IOException("Model scoring was interrupted; no completed baseline was published", error); + } catch (IOException | RuntimeException error) { + record.addProperty("status", "FAILED"); + throw error; + } finally { + record.addProperty("wallMillis", (System.nanoTime() - started) / 1_000_000.0); + } + } + + static Path canonical(Path path) throws IOException { + final Path absolute = path.toAbsolutePath().normalize(); + Path existing = absolute; + while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { + existing = existing.getParent(); + } + return existing.toRealPath().resolve(existing.relativize(absolute)).normalize(); + } + + static JsonObject readObject(Path path) throws IOException { + return new TemporalJsonCodec().parseObject(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); + } + + static JsonObject artifactHashes(Path root) throws IOException { + final JsonObject hashes = new JsonObject(); + final List files; + try (Stream paths = Files.walk(root)) { + files = paths.filter(Files::isRegularFile).sorted().collect(Collectors.toList()); + } + for (Path path : files) { + hashes.addProperty(root.relativize(path).toString().replace('\\', '/'), sha256(path)); + } + return hashes; + } + + private static String sha256(Path path) throws IOException { + return RetrievalBaselineEvaluation.sha256(path); + } + + static void writeJson(Path path, Object value, boolean createNew) throws IOException { + try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, + createNew ? StandardOpenOption.CREATE_NEW : StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING)) { + writer.write(JSON.toJson(value)); + writer.write('\n'); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBodyViewScores.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBodyViewScores.java new file mode 100644 index 000000000..019c10955 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBodyViewScores.java @@ -0,0 +1,231 @@ +/* + * 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.benchmark; + +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.array; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.exhausted; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.fingerprint; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.hashText; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.integer; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.nextJson; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.object; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.readJson; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.reader; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.require; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.scoreRow; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.sha256; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.text; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.validateQuery; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.writer; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** Validates complete body-view score sources before projecting logits onto the frozen pool. */ +final class RetrievalBodyViewScores implements AutoCloseable { + + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + + private final Path inputPath; + private final Path scoresPath; + private final Path metadataPath; + private final String inputHash; + private final String scoresHash; + private final String metadataHash; + private final JsonObject metadata; + private final int maxCandidates; + private final long expectedQueryCount; + private final Map corpus; + private final BufferedReader inputs; + private final BufferedReader scores; + private final Set queryIds = new HashSet<>(); + private long queryCount; + private long pairCount; + private long projectedPairCount; + private long elapsedNanos; + private boolean finished; + + static void prepare(Path preparedDirectory, Path bodyInput) throws IOException { + final Path prepared = preparedDirectory.toRealPath(); + final String manifestHash = sha256(prepared.resolve("manifest.json")); + final RetrievalBaselineEvaluation.Prepared data = RetrievalBaselineEvaluation.loadPrepared(prepared); + final Path requested = bodyInput.toAbsolutePath().normalize(); + require(requested.getFileName() != null && requested.getParent() != null, + "Body input must be a new file outside the prepared directory"); + final Path destination = requested.getParent().toRealPath().resolve(requested.getFileName()); + require(!destination.startsWith(prepared), "Body input must be outside the prepared directory"); + long pairs = 0; + try (BufferedReader titleRows = reader(prepared.resolve("title-input.jsonl")); + BufferedReader annotations = reader(prepared.resolve("candidates.csv")); + BufferedWriter output = writer(destination)) { + require("query_id,pool_rank,evidence_id,rule_exact".equals(annotations.readLine()), + "Unexpected candidate CSV columns"); + for (RetrievalBaselineEvaluation.Claim claim : data.claims) { + final JsonObject titleQuery = nextJson(titleRows, "title input query"); + validateQuery(titleQuery); + require(claim.id.equals(text(titleQuery, "queryId")) + && claim.text.equals(text(titleQuery, "queryText")), "Input query order/text mismatch"); + RetrievalBaselineEvaluation.validateCandidates(titleQuery, data.corpus, claim, annotations); + final JsonObject bodyQuery = new JsonObject(); + bodyQuery.addProperty("schemaVersion", 1); + bodyQuery.addProperty("queryId", claim.id); + bodyQuery.addProperty("queryText", claim.text); + final JsonArray candidates = new JsonArray(); + for (JsonElement value : array(titleQuery, "candidates")) { + final String id = text(object(value, "candidate"), "evidenceId"); + final JsonObject candidate = new JsonObject(); + candidate.addProperty("evidenceId", id); + candidate.addProperty("text", data.corpus.get(id).text); + candidates.add(candidate); + } + bodyQuery.add("candidates", candidates); + bodyQuery.addProperty("poolSha256", hashText(JSON.toJson(bodyQuery))); + output.write(JSON.toJson(bodyQuery)); + output.write('\n'); + pairs = Math.addExact(pairs, candidates.size()); + } + exhausted(titleRows, "title input query"); + exhausted(annotations, "candidate annotation"); + } + require(pairs == integer(data.manifest, "pairCount", 0), "Prepared candidate pair count mismatch"); + RetrievalBaselineEvaluation.verifyFiles(prepared, object(data.manifest.get("outputSha256"), "outputSha256")); + require(manifestHash.equals(sha256(prepared.resolve("manifest.json"))), + "Prepared manifest changed while exporting body input"); + } + + RetrievalBodyViewScores(Path input, Path scoreFile, Path metadataFile, + Map corpus, + long expectedQueryCount) throws IOException { + require(expectedQueryCount > 0, "Expected at least one body-view query"); + inputPath = input.toRealPath(); + scoresPath = scoreFile.toRealPath(); + metadataPath = metadataFile.toRealPath(); + require(!inputPath.equals(scoresPath) && !inputPath.equals(metadataPath) + && !scoresPath.equals(metadataPath), "Body-view source paths must differ"); + inputHash = sha256(inputPath); + scoresHash = sha256(scoresPath); + metadataHash = sha256(metadataPath); + metadata = readJson(metadataPath); + final long depth = integer(metadata, "maxCandidates", 1); + require(depth == 200 || depth == 500, "Body-view source must have maxCandidates 200 or 500"); + maxCandidates = (int) depth; + this.expectedQueryCount = expectedQueryCount; + this.corpus = corpus; + final long sourcePairs = integer(metadata, "pairCount", 0); + require(sourcePairs <= Math.multiplyExact(expectedQueryCount, maxCandidates), + "Body-view pair count exceeds the source pool limit"); + RetrievalBaselineEvaluation.validateMetadata(metadata, inputHash, expectedQueryCount, + sourcePairs, maxCandidates); + require(scoresHash.equals(fingerprint(metadata, "scoresSha256")), "Body-view scores SHA-256 mismatch"); + inputs = reader(inputPath); + try { + scores = reader(scoresPath); + } catch (IOException | RuntimeException error) { + inputs.close(); + throw error; + } + } + + double[] project(JsonObject currentTitleQuery) throws IOException { + require(!finished && queryCount < expectedQueryCount, "Unexpected body-view query projection"); + validateQuery(currentTitleQuery); + final JsonObject source = nextJson(inputs, "body-view input query"); + validateQuery(source, maxCandidates); + require(queryIds.add(text(source, "queryId")), "Duplicate body-view query ID"); + require(text(source, "queryId").equals(text(currentTitleQuery, "queryId")) + && text(source, "queryText").equals(text(currentTitleQuery, "queryText")), + "Body-view source query order/text differs from the frozen title view"); + final RetrievalBaselineEvaluation.ScoredPool scored = scoreRow(nextJson(scores, "body-view score query"), source); + final JsonArray candidates = array(source, "candidates"); + final Map byId = new HashMap<>(); + for (int index = 0; index < candidates.size(); index++) { + final JsonObject candidate = object(candidates.get(index), "body candidate"); + final String id = text(candidate, "evidenceId"); + final RetrievalBaselineEvaluation.Body body = corpus.get(id); + require(body != null && body.text.equals(text(candidate, "text")), + "Body-view candidate must preserve the original corpus body"); + byId.put(id, scored.logits[index]); + } + final JsonArray current = array(currentTitleQuery, "candidates"); + final double[] result = new double[current.size()]; + for (int index = 0; index < current.size(); index++) { + final JsonObject candidate = object(current.get(index), "title candidate"); + final String id = text(candidate, "evidenceId"); + final RetrievalBaselineEvaluation.Body body = corpus.get(id); + require(body != null && (body.article + "\n" + body.text).equals(text(candidate, "text")), + "Title-view candidate must preserve the original corpus title and body"); + final Double logit = byId.get(id); + require(logit != null, "Body-view source does not cover frozen candidate: " + id); + result[index] = logit; + } + queryCount++; + pairCount = Math.addExact(pairCount, candidates.size()); + projectedPairCount = Math.addExact(projectedPairCount, current.size()); + elapsedNanos = Math.addExact(elapsedNanos, scored.elapsedNanos); + return result; + } + + JsonObject finish() throws IOException { + require(!finished && queryCount == expectedQueryCount, "Body-view source query count mismatch"); + exhausted(inputs, "body-view input query"); + exhausted(scores, "body-view score query"); + require(pairCount == integer(metadata, "pairCount", 0) + && elapsedNanos == integer(metadata, "inferenceNanos", 0), + "Body-view complete-source pair/timing totals differ from metadata"); + require(inputHash.equals(sha256(inputPath)) && scoresHash.equals(sha256(scoresPath)) + && metadataHash.equals(sha256(metadataPath)), "Body-view source artifacts changed during evaluation"); + finished = true; + final JsonObject result = new JsonObject(); + result.addProperty("sourceInputPath", inputPath.toString()); + result.addProperty("sourceScoresPath", scoresPath.toString()); + result.addProperty("sourceMetadataPath", metadataPath.toString()); + result.addProperty("inputSha256", inputHash); + result.addProperty("scoresSha256", scoresHash); + result.addProperty("metadataSha256", metadataHash); + result.addProperty("sourceMaxCandidates", maxCandidates); + result.addProperty("sourceQueryCount", queryCount); + result.addProperty("sourcePairCount", pairCount); + result.addProperty("sourceInferenceNanos", elapsedNanos); + result.addProperty("projectedPairCount", projectedPairCount); + result.addProperty("timingScope", "complete original source pool; no inferred timing for the projected 200 pool"); + result.add("originalMetadata", metadata); + return result; + } + + @Override + public void close() throws IOException { + try { + inputs.close(); + } finally { + scores.close(); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalCandidateEvaluation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalCandidateEvaluation.java new file mode 100644 index 000000000..e47e2c10d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalCandidateEvaluation.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.benchmark; + +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.csv; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.emptyDirectory; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.fraction; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.require; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.sha256; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.writer; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import org.apache.geaflow.ai.temporal.candidate.Bm25Index; +import org.apache.geaflow.ai.temporal.candidate.EvidenceCandidate; +import org.apache.geaflow.ai.temporal.candidate.EvidenceRetriever; +import org.apache.geaflow.ai.temporal.candidate.MinHashIndex; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; + +/** Evaluates the four fixed candidate strategies without model inference or network access. */ +public final class RetrievalCandidateEvaluation { + + private static final int MAX_CANDIDATES = 100; + private static final int[] CUTOFFS = {1, 5, 10, 20, 50, 100}; + private static final RetrievalProfile[] PROFILES = { + RetrievalProfile.RULE_ONLY, RetrievalProfile.RULE_BM25, + RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL + }; + private static final String[] OUTPUT_FILES = { + "subset.jsonl", "corpus.jsonl", "source-mappings.jsonl", "gold.jsonl", "titles.jsonl", + "comparison.csv", "query-results.csv", "final-candidates.csv" + }; + private static final String[] COMPARISON_FIELDS = { + "profile", "k", "macroRecall", "microRecall", "anyHitRate", "allGoldHitRate", + "zeroHitRate", "poolMacroRecall", "poolMicroRecall", "evaluatedQueryCount", + "positiveQueryCount", "zeroGoldQueryCount", "positivePairCount", "candidatePairCount", + "hits", "poolHits" + }; + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().serializeNulls().create(); + + public JsonObject evaluate(Path input, Path output) throws IOException { + final Instant started = Instant.now(); + final long startedNanos = System.nanoTime(); + final Path source = input.toRealPath(); + final Path destination = RetrievalBaselineRunner.canonical(output); + require(!source.startsWith(destination) && !destination.startsWith(source), + "Output overlaps the raw dataset: " + source); + final ClimateFeverDataset dataset = ClimateFeverDataset.load(source, + ClimateFeverDataset.RELEASE_SHA256); + require(dataset.getClaims().size() == ClimateFeverDataset.SUBSET_SIZE, + "The fixed candidate experiment requires 1000 queries"); + return evaluate(dataset, destination, source, started, startedNanos); + } + + JsonObject evaluate(ClimateFeverDataset dataset, Path output) throws IOException { + return evaluate(Objects.requireNonNull(dataset, "dataset"), + RetrievalBaselineRunner.canonical(output), null, Instant.now(), System.nanoTime()); + } + + private JsonObject evaluate(ClimateFeverDataset dataset, Path destination, Path source, + Instant started, long startedNanos) throws IOException { + for (Path ancestor = destination; ancestor != null; ancestor = ancestor.getParent()) { + require(!Files.isRegularFile(ancestor.resolve("manifest.json")), + "Output must not be inside an existing experiment directory: " + ancestor); + } + emptyDirectory(destination); + dataset.writeArtifacts(destination); + final JsonArray comparisons = new JsonArray(); + final JsonArray timings = new JsonArray(); + try (BufferedWriter queries = writer(destination.resolve("query-results.csv")); + BufferedWriter candidates = writer(destination.resolve("final-candidates.csv"))) { + csv(queries, "profile", "k", "query_id", "claim_label", "gold_count", + "actual_pool_count", "returned_count", "hits", "pool_hits", "recall"); + csv(candidates, "profile", "query_id", "rank", "evidence_id", "score", + "channel_scores", "channel_ranks", "is_gold"); + for (RetrievalProfile profile : PROFILES) { + evaluateProfile(dataset, profile, queries, candidates, comparisons, timings); + } + } + try (BufferedWriter comparison = writer(destination.resolve("comparison.csv"))) { + csv(comparison, (Object[]) COMPARISON_FIELDS); + for (JsonElement item : comparisons) { + final JsonObject row = item.getAsJsonObject(); + final Object[] values = new Object[COMPARISON_FIELDS.length]; + for (int index = 0; index < COMPARISON_FIELDS.length; index++) { + final JsonElement value = row.get(COMPARISON_FIELDS[index]); + values[index] = value.isJsonNull() ? null : value.getAsString(); + } + csv(comparison, values); + } + } + if (source != null) { + require(ClimateFeverDataset.RELEASE_SHA256.equals(sha256(source)), + "Raw dataset changed during candidate evaluation"); + } + final JsonObject manifest = dataset.getManifest(); + manifest.addProperty("schema", "retrieval-candidates-v1"); + manifest.addProperty("status", "COMPLETED"); + manifest.addProperty("queryCount", dataset.getClaims().size()); + manifest.addProperty("corpusCount", dataset.getCorpus().size()); + manifest.addProperty("modelInference", false); + manifest.addProperty("device", "cpu"); + manifest.addProperty("networkAccess", false); + manifest.addProperty("maxCandidates", MAX_CANDIDATES); + manifest.addProperty("retrievalInput", "Original query and original evidence body only; no title or Gold input"); + manifest.addProperty("goldPolicy", "Gold labels affect metrics and export annotations only; never candidate generation"); + manifest.addProperty("rankingPolicy", "One search(query,100) per profile and query; every K uses the same ranking prefix"); + manifest.addProperty("unjudgedPolicy", "Non-Gold candidates are unjudged, not asserted negative"); + manifest.addProperty("candidateExportPolicy", "All returned candidates up to 100, once per profile and query"); + if (source != null) { + manifest.addProperty("inputPath", source.toString()); + } + final JsonArray profiles = new JsonArray(); + for (RetrievalProfile profile : PROFILES) { + profiles.add(new JsonPrimitive(profile.name())); + } + manifest.add("profiles", profiles); + final JsonArray cutoffs = new JsonArray(); + for (int cutoff : CUTOFFS) { + cutoffs.add(new JsonPrimitive(cutoff)); + } + manifest.add("cutoffs", cutoffs); + manifest.add("parameters", parameters()); + manifest.add("comparison", comparisons); + manifest.add("profileTimings", timings); + manifest.addProperty("startedAtUtc", started.toString()); + manifest.addProperty("javaVersion", System.getProperty("java.version")); + manifest.addProperty("javaVendor", System.getProperty("java.vendor")); + manifest.addProperty("osName", System.getProperty("os.name")); + manifest.addProperty("osArch", System.getProperty("os.arch")); + manifest.addProperty("availableProcessors", Runtime.getRuntime().availableProcessors()); + manifest.addProperty("maxHeapBytes", Runtime.getRuntime().maxMemory()); + final JsonObject hashes = new JsonObject(); + for (String file : OUTPUT_FILES) { + hashes.addProperty(file, sha256(destination.resolve(file))); + } + manifest.add("outputSha256", hashes); + manifest.addProperty("completedAtUtc", Instant.now().toString()); + manifest.addProperty("wallMillis", (System.nanoTime() - startedNanos) / 1_000_000.0); + try (BufferedWriter file = writer(destination.resolve("manifest.json"))) { + file.write(JSON.toJson(manifest)); + file.newLine(); + } + return manifest; + } + + private static void evaluateProfile(ClimateFeverDataset dataset, RetrievalProfile profile, + BufferedWriter queries, BufferedWriter candidates, + JsonArray comparisons, JsonArray timings) throws IOException { + final long startedNanos = System.nanoTime(); + final RetrievalBaselineEvaluation.Metrics[] metrics = + new RetrievalBaselineEvaluation.Metrics[CUTOFFS.length]; + for (int index = 0; index < metrics.length; index++) { + metrics[index] = new RetrievalBaselineEvaluation.Metrics(); + } + long searchNanos = 0; + long candidatePairCount = 0; + final long buildStartedNanos = System.nanoTime(); + final long buildNanos; + try (EvidenceRetriever retriever = new EvidenceRetriever(dataset.getCorpus(), profile)) { + buildNanos = System.nanoTime() - buildStartedNanos; + for (ClimateFeverDataset.Claim claim : dataset.getClaims()) { + final long searchStartedNanos = System.nanoTime(); + final List ranking = retriever.search(claim.getText(), MAX_CANDIDATES); + searchNanos += System.nanoTime() - searchStartedNanos; + candidatePairCount += ranking.size(); + final int[] prefixHits = new int[ranking.size() + 1]; + for (int index = 0; index < ranking.size(); index++) { + final EvidenceCandidate candidate = ranking.get(index); + final String evidenceId = candidate.getEvidence().getId(); + final boolean gold = claim.getGoldIds().contains(evidenceId); + prefixHits[index + 1] = prefixHits[index] + (gold ? 1 : 0); + csv(candidates, profile.name(), claim.getId(), index + 1, evidenceId, + candidate.getScore(), JSON.toJson(candidate.getChannelScores()), + JSON.toJson(candidate.getChannelRanks()), gold); + } + final int poolHits = prefixHits[ranking.size()]; + for (int index = 0; index < CUTOFFS.length; index++) { + final int returned = Math.min(CUTOFFS[index], ranking.size()); + final int hits = prefixHits[returned]; + metrics[index].add(claim.getGoldIds().size(), hits, poolHits, ranking.size()); + csv(queries, profile.name(), CUTOFFS[index], claim.getId(), claim.getLabel(), + claim.getGoldIds().size(), ranking.size(), returned, hits, poolHits, + fraction(hits, claim.getGoldIds().size())); + } + } + } + for (int index = 0; index < CUTOFFS.length; index++) { + final JsonObject summary = metrics[index].summary(); + summary.addProperty("profile", profile.name()); + summary.remove("depth"); + summary.remove("finalK"); + summary.addProperty("k", CUTOFFS[index]); + comparisons.add(summary); + } + final JsonObject timing = new JsonObject(); + timing.addProperty("profile", profile.name()); + timing.addProperty("queryCount", dataset.getClaims().size()); + timing.addProperty("candidatePairCount", candidatePairCount); + timing.addProperty("indexBuildMillis", buildNanos / 1_000_000.0); + timing.addProperty("querySearchMillis", searchNanos / 1_000_000.0); + timing.addProperty("wallMillis", (System.nanoTime() - startedNanos) / 1_000_000.0); + timings.add(timing); + } + + private static JsonObject parameters() { + final JsonObject result = new JsonObject(); + result.addProperty("rule", "NFC, Locale.ROOT lowercase, collapsed Unicode whitespace, full-body equality"); + result.addProperty("bm25Analyzer", RetrievalProfile.RULE_BM25.getAnalyzerName()); + result.addProperty("bm25K1", Bm25Index.K1); + result.addProperty("bm25B", Bm25Index.B); + result.addProperty("indexedField", "evidence"); + result.addProperty("fieldWeight", 1.0); + result.addProperty("candidatePoolSize", MAX_CANDIDATES); + result.addProperty("rrfK", RetrievalProfile.RRF_K); + result.addProperty("mergePolicy", "Rule candidates first; optional candidates by equal-weight RRF, then evidence ID"); + result.addProperty("minHashShingleSize", MinHashIndex.SHINGLE_SIZE); + result.addProperty("minHashHashCount", MinHashIndex.HASH_COUNT); + result.addProperty("minHashBands", MinHashIndex.BANDS); + result.addProperty("minHashRowsPerBand", MinHashIndex.ROWS_PER_BAND); + result.addProperty("minHashSeed", MinHashIndex.SEED); + result.addProperty("minHashHashAlgorithm", MinHashIndex.HASH_ALGORITHM); + result.addProperty("minHashTokenization", MinHashIndex.TOKENIZATION); + result.addProperty("jaccardThreshold", MinHashIndex.JACCARD_THRESHOLD); + result.addProperty("minHashPolicy", "LSH candidate recall, then exact Jaccard verification; not an exact all-pairs scan"); + return result; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewEvaluation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewEvaluation.java new file mode 100644 index 000000000..722aaad45 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewEvaluation.java @@ -0,0 +1,312 @@ +/* + * 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.benchmark; + +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.array; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.csv; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.exhausted; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.fraction; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.integer; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.nextJson; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.readJson; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.reader; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.require; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.sha256; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.text; +import static org.apache.geaflow.ai.temporal.benchmark.RetrievalBaselineEvaluation.writer; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** Compares one fixed title/body RRF experiment with the unchanged title baseline. */ +public final class RetrievalDualViewEvaluation { + + static final String PROFILE = "CE_TITLE_BODY_RRF_75_25"; + static final double TITLE_WEIGHT = 0.75; + static final double BODY_WEIGHT = 0.25; + static final int RRF_CONSTANT = 60; + static final int DEPTH = 200; + static final int FINAL_K = 20; + + public JsonObject evaluate(Path preparedDirectory, Path titleScoresPath, Path titleMetadataPath, + Path bodyInputPath, Path bodyScoresPath, Path bodyMetadataPath, + Path output) throws IOException { + final Path prepared = preparedDirectory.toRealPath(); + final Path titleScores = titleScoresPath.toRealPath(); + final Path titleMetadataFile = titleMetadataPath.toRealPath(); + final Path destination = RetrievalBaselineRunner.canonical(output); + for (Path input : new Path[] {prepared, titleScores, titleMetadataFile, + bodyInputPath.toRealPath(), bodyScoresPath.toRealPath(), bodyMetadataPath.toRealPath()}) { + require(!destination.startsWith(input) && !input.startsWith(destination), + "Experiment output overlaps an input: " + input); + } + final String preparedHash = sha256(prepared.resolve("manifest.json")); + final RetrievalBaselineEvaluation.Prepared data = RetrievalBaselineEvaluation.loadPrepared(prepared); + final String metadataHash = sha256(titleMetadataFile); + final JsonObject titleMetadata = readJson(titleMetadataFile); + RetrievalBaselineEvaluation.validateMetadata(titleMetadata, data.manifest); + require(sha256(titleScores).equals(text(titleMetadata, "scoresSha256")), "Title scores SHA-256 mismatch"); + final RetrievalBaselineEvaluation.Metrics baselineTotals = new RetrievalBaselineEvaluation.Metrics(); + final RetrievalBaselineEvaluation.Metrics fusionTotals = new RetrievalBaselineEvaluation.Metrics(); + final Map groups = new TreeMap<>(); + long titleElapsed = 0; + long pairCount = 0; + long newGold = 0; + long lostGold = 0; + int improved = 0; + int regressed = 0; + int rescued = 0; + int newlyZero = 0; + final JsonObject bodySource; + try (RetrievalBodyViewScores bodyScores = new RetrievalBodyViewScores(bodyInputPath, bodyScoresPath, + bodyMetadataPath, data.corpus, data.claims.size())) { + RetrievalBaselineEvaluation.emptyDirectory(destination); + try (BufferedReader inputs = reader(prepared.resolve("title-input.jsonl")); + BufferedReader titles = reader(titleScores); + BufferedReader annotations = reader(prepared.resolve("candidates.csv")); + BufferedWriter queries = writer(destination.resolve("query-results.csv")); + BufferedWriter candidatesFile = writer(destination.resolve("final-candidates.csv"))) { + require("query_id,pool_rank,evidence_id,rule_exact".equals(annotations.readLine()), + "Unexpected candidate CSV columns"); + csv(queries, "profile", "query_id", "claim_label", "gold_count", "actual_pool_count", + "pool_hits", "pool_recall", "final_hits", "recall", "title_final_hits", + "delta_hits_vs_title", "new_gold_hits", "lost_gold_hits"); + csv(candidatesFile, "profile", "query_id", "rank", "evidence_id", "rule_exact", "is_gold", + "title_cross_encoder_logit", "body_cross_encoder_logit", "title_ce_rank", "body_ce_rank", + "rrf_score", "source_pool_rank", "title_final_rank", "selected_top20", "selected_title_top20"); + for (RetrievalBaselineEvaluation.Claim claim : data.claims) { + final JsonObject query = nextJson(inputs, "input query"); + RetrievalBaselineEvaluation.validateQuery(query); + require(text(query, "queryId").equals(claim.id) + && text(query, "queryText").equals(claim.text), "Input query order/text mismatch"); + final JsonArray candidates = array(query, "candidates"); + final boolean[] rules = RetrievalBaselineEvaluation.validateCandidates(query, + data.corpus, claim, annotations); + final RetrievalBaselineEvaluation.ScoredPool title = RetrievalBaselineEvaluation.scoreRow( + nextJson(titles, "title score query"), query); + titleElapsed = Math.addExact(titleElapsed, title.elapsedNanos); + pairCount = Math.addExact(pairCount, candidates.size()); + final double[] body = bodyScores.project(query); + final int[] titleRanks = ceRanks(candidates, title.logits); + final int[] bodyRanks = ceRanks(candidates, body); + final List titleOrder = RetrievalBaselineEvaluation.rankCandidates(candidates, + title.logits, rules); + final List order = rankFromRanks(candidates, titleRanks, bodyRanks, rules, TITLE_WEIGHT); + final int[] titleFinalRanks = ranks(titleOrder); + final int[] finalRanks = ranks(order); + int poolHits = 0; + int titleHits = 0; + int hits = 0; + int added = 0; + int dropped = 0; + // Gold is consulted only after both orders have been fixed. + for (int index = 0; index < candidates.size(); index++) { + if (claim.gold.contains(text(candidates.get(index).getAsJsonObject(), "evidenceId"))) { + poolHits++; + final boolean inTitle = titleFinalRanks[index] <= FINAL_K; + final boolean inFusion = finalRanks[index] <= FINAL_K; + titleHits += inTitle ? 1 : 0; + hits += inFusion ? 1 : 0; + added += inFusion && !inTitle ? 1 : 0; + dropped += inTitle && !inFusion ? 1 : 0; + } + } + baselineTotals.add(claim.gold.size(), titleHits, poolHits, candidates.size()); + fusionTotals.add(claim.gold.size(), hits, poolHits, candidates.size()); + final RetrievalBaselineEvaluation.Metrics[] group = groups.computeIfAbsent(claim.label, + ignored -> new RetrievalBaselineEvaluation.Metrics[] { + new RetrievalBaselineEvaluation.Metrics(), new RetrievalBaselineEvaluation.Metrics()}); + group[0].add(claim.gold.size(), titleHits, poolHits, candidates.size()); + group[1].add(claim.gold.size(), hits, poolHits, candidates.size()); + newGold += added; + lostGold += dropped; + improved += hits > titleHits ? 1 : 0; + regressed += hits < titleHits ? 1 : 0; + rescued += titleHits == 0 && hits > 0 ? 1 : 0; + newlyZero += titleHits > 0 && hits == 0 ? 1 : 0; + csv(queries, PROFILE, claim.id, claim.label, claim.gold.size(), candidates.size(), poolHits, + fraction(poolHits, claim.gold.size()), hits, fraction(hits, claim.gold.size()), + titleHits, hits - titleHits, added, dropped); + for (int position = 0; position < order.size(); position++) { + final int index = order.get(position); + final String evidenceId = text(candidates.get(index).getAsJsonObject(), "evidenceId"); + csv(candidatesFile, PROFILE, claim.id, position + 1, evidenceId, rules[index], + claim.gold.contains(evidenceId), title.logits[index], body[index], titleRanks[index], + bodyRanks[index], rrf(titleRanks[index], bodyRanks[index], TITLE_WEIGHT), index + 1, + titleFinalRanks[index], position < FINAL_K, titleFinalRanks[index] <= FINAL_K); + } + } + exhausted(inputs, "input query"); + exhausted(titles, "title score query"); + exhausted(annotations, "candidate annotation"); + } + bodySource = bodyScores.finish(); + } + require(pairCount == integer(data.manifest, "pairCount", 0) + && titleElapsed == integer(titleMetadata, "inferenceNanos", 0), "Title score pair/timing totals differ"); + RetrievalBaselineEvaluation.verifyFiles(prepared, data.manifest.getAsJsonObject("outputSha256")); + require(sha256(prepared.resolve("manifest.json")).equals(preparedHash) + && sha256(titleMetadataFile).equals(metadataHash) + && sha256(titleScores).equals(text(titleMetadata, "scoresSha256")), "Title/prepared inputs changed"); + final JsonArray comparisons = new JsonArray(); + comparisons.add(baselineTotals.summary()); + comparisons.add(summary(fusionTotals)); + writeComparison(destination.resolve("comparison.csv"), comparisons); + final JsonObject result = new JsonObject(); + result.addProperty("schema", "retrieval-dual-view-results-v1"); + result.addProperty("profile", PROFILE); + result.addProperty("titleWeight", TITLE_WEIGHT); + result.addProperty("bodyWeight", BODY_WEIGHT); + result.addProperty("rrfConstant", RRF_CONSTANT); + result.addProperty("depth", DEPTH); + result.addProperty("finalK", FINAL_K); + result.addProperty("queryCount", data.claims.size()); + result.addProperty("pairCount", pairCount); + result.addProperty("preparedDirectory", prepared.toString()); + result.addProperty("preparedManifestSha256", preparedHash); + result.add("preparedOutputSha256", data.manifest.get("outputSha256")); + result.addProperty("titleScoresPath", titleScores.toString()); + result.addProperty("titleMetadataPath", titleMetadataFile.toString()); + result.add("titleScoresSha256", titleMetadata.get("scoresSha256")); + result.addProperty("titleMetadataSha256", metadataHash); + result.add("titleInference", titleMetadata); + result.add("bodySource", bodySource); + result.add("comparison", comparisons); + final JsonArray grouped = new JsonArray(); + for (Map.Entry entry : groups.entrySet()) { + final JsonObject baseline = entry.getValue()[0].summary(); + final JsonObject fusion = summary(entry.getValue()[1]); + baseline.addProperty("claimLabel", entry.getKey()); + fusion.addProperty("claimLabel", entry.getKey()); + grouped.add(baseline); + grouped.add(fusion); + } + result.add("groupComparison", grouped); + final JsonObject changes = new JsonObject(); + changes.addProperty("improvedQueries", improved); + changes.addProperty("regressedQueries", regressed); + changes.addProperty("rescuedZeroQueries", rescued); + changes.addProperty("newZeroQueries", newlyZero); + changes.addProperty("newGoldPairs", newGold); + changes.addProperty("lostGoldPairs", lostGold); + result.add("changesVsTitle", changes); + result.addProperty("rankingPolicy", "RULE first in original pool order; otherwise " + + "0.75/(60+titleCErank)+0.25/(60+bodyCErank), descending; ties by titleCErank then evidence ID; " + + "both CE ranks are recomputed within the same at-most-200 pool, logits descending and ID ascending"); + result.addProperty("goldPolicy", "SUPPORTS and REFUTES are positive; zero-Gold queries are excluded " + + "from recall denominators; labels never affect ranks, weights or candidate selection"); + result.addProperty("candidateExportPolicy", "Full fixed pool, including unselected and negative-logit evidence"); + result.addProperty("latencyPolicy", "Body source metadata retains original pool timing; projected " + + "500-pool timing is not a 200-pool or online dual-view latency measurement"); + final JsonObject hashes = new JsonObject(); + for (String name : new String[] {"comparison.csv", "query-results.csv", "final-candidates.csv"}) { + hashes.addProperty(name, sha256(destination.resolve(name))); + } + result.add("outputSha256", hashes); + RetrievalBaselineRunner.writeJson(destination.resolve("manifest.json"), result, true); + return result; + } + + static int[] ceRanks(JsonArray candidates, double[] logits) { + require(candidates.size() <= DEPTH, "Candidate pool exceeds 200"); + return ranks(RetrievalBaselineEvaluation.rankCandidates(candidates, logits, new boolean[candidates.size()])); + } + + static List rankCandidates(JsonArray candidates, double[] title, double[] body, boolean[] rules) { + return rankCandidates(candidates, title, body, rules, TITLE_WEIGHT); + } + + static List rankCandidates(JsonArray candidates, double[] title, double[] body, + boolean[] rules, double titleWeight) { + require(rules.length == candidates.size(), "Candidate/RULE sizes differ"); + require(Double.isFinite(titleWeight) && titleWeight >= 0 && titleWeight <= 1, "Invalid title weight"); + return rankFromRanks(candidates, ceRanks(candidates, title), ceRanks(candidates, body), rules, titleWeight); + } + + private static List rankFromRanks(JsonArray candidates, int[] titleRanks, int[] bodyRanks, + boolean[] rules, double titleWeight) { + final List order = new ArrayList<>(); + for (int index = 0; index < candidates.size(); index++) { + order.add(index); + } + order.sort((left, right) -> { + if (rules[left] != rules[right]) { + return rules[left] ? -1 : 1; + } + if (rules[left]) { + return Integer.compare(left, right); + } + final int scoreOrder = Double.compare(rrf(titleRanks[right], bodyRanks[right], titleWeight), + rrf(titleRanks[left], bodyRanks[left], titleWeight)); + if (scoreOrder != 0) { + return scoreOrder; + } + final int titleOrder = Integer.compare(titleRanks[left], titleRanks[right]); + return titleOrder != 0 ? titleOrder : text(candidates.get(left).getAsJsonObject(), "evidenceId") + .compareTo(text(candidates.get(right).getAsJsonObject(), "evidenceId")); + }); + return order; + } + + private static double rrf(int titleRank, int bodyRank, double titleWeight) { + return titleWeight / (RRF_CONSTANT + titleRank) + (1.0 - titleWeight) / (RRF_CONSTANT + bodyRank); + } + + private static int[] ranks(List order) { + final int[] ranks = new int[order.size()]; + for (int index = 0; index < order.size(); index++) { + ranks[order.get(index)] = index + 1; + } + return ranks; + } + + private static JsonObject summary(RetrievalBaselineEvaluation.Metrics metrics) { + final JsonObject result = metrics.summary(); + result.addProperty("profile", PROFILE); + return result; + } + + private static void writeComparison(Path output, JsonArray rows) throws IOException { + try (BufferedWriter file = writer(output)) { + final List names = new ArrayList<>(); + for (Map.Entry entry : rows.get(0).getAsJsonObject().entrySet()) { + names.add(entry.getKey()); + } + csv(file, names.toArray()); + for (JsonElement row : rows) { + final List values = new ArrayList<>(); + for (Object name : names) { + final JsonElement value = row.getAsJsonObject().get(name.toString()); + values.add(value.isJsonNull() ? null : value.getAsString()); + } + csv(file, values.toArray()); + } + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewRunner.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewRunner.java new file mode 100644 index 000000000..adf5f149a --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewRunner.java @@ -0,0 +1,274 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Runs the independent fixed-pool title/body RRF experiment beside its title baseline. */ +public final class RetrievalDualViewRunner { + + public JsonObject run(Path moduleRoot, Path inputPath, Path output, String device, + Path python, Path reuseTitleInference, Path reuseBodyInference, + Path reuseBodyInput, boolean offline) throws IOException { + final Path module = RetrievalBaselineRunner.canonical(moduleRoot); + final Path input = RetrievalBaselineRunner.canonical(inputPath == null + ? module.resolve("target/climate-fever/climate-fever-1.0.1.jsonl") : inputPath); + final Path titleReuse = canonicalOptional(reuseTitleInference); + final Path bodyReuse = canonicalOptional(reuseBodyInference); + final Path bodyReuseInput = canonicalOptional(reuseBodyInput); + final String selectedDevice = device == null ? "cuda" : device.toLowerCase(Locale.ROOT); + if (!"cuda".equals(selectedDevice) && !"cpu".equals(selectedDevice)) { + throw new IllegalArgumentException("Device must be cpu or cuda"); + } + final String environment = "cuda".equals(selectedDevice) + ? "cross-encoder-cuda-venv" : "cross-encoder-venv"; + final boolean windows = System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("windows"); + final Path executable = RetrievalBaselineRunner.canonical(python == null + ? module.resolve("target/" + environment + (windows ? "/Scripts/python.exe" : "/bin/python")) : python); + final Path worker = module.resolve("bin/score-temporal-cross-encoder.py"); + final Path cache = module.resolve("target/cross-encoder-cache"); + final Path destination = initializeOutput(module, input, output, executable, cache, + titleReuse, bodyReuse, bodyReuseInput); + final Path baselineDirectory = destination.resolve("baseline"); + final Path prepared = baselineDirectory.resolve("prepared"); + final Path generatedBodyInput = destination.resolve("body-input.jsonl"); + final Path bodyInput = bodyReuseInput == null ? generatedBodyInput : bodyReuseInput; + final Path bodyInference = bodyReuse == null ? destination.resolve("body-inference") : bodyReuse; + final Path titleInference = titleReuse == null ? baselineDirectory.resolve("inference") : titleReuse; + final Path evaluated = destination.resolve("evaluation"); + final JsonArray processes = new JsonArray(); + final Map sourceHashes = new LinkedHashMap<>(); + final String started = Instant.now().toString(); + try { + Files.createDirectories(destination.resolve("logs")); + snapshot(sourceHashes, input); + if (titleReuse != null) { + snapshot(sourceHashes, titleReuse.resolve("scores.jsonl")); + snapshot(sourceHashes, titleReuse.resolve("metadata.json")); + } + if (bodyReuse != null) { + snapshot(sourceHashes, bodyInput); + snapshot(sourceHashes, bodyInference.resolve("scores.jsonl")); + snapshot(sourceHashes, bodyInference.resolve("metadata.json")); + } + if (titleReuse == null || bodyReuse == null) { + if (!Files.isRegularFile(executable) || !Files.isRegularFile(worker)) { + throw new IOException("An existing Python model environment and scorer are required: " + + executable + "; " + worker); + } + snapshot(sourceHashes, worker); + } + final JsonObject baseline = new RetrievalBaselineRunner().run(module, input, baselineDirectory, + selectedDevice, executable, titleReuse, offline); + final String preparedHash = sha256(prepared.resolve("manifest.json")); + final String baselineHash = sha256(baselineDirectory.resolve("manifest.json")); + System.err.println("Preparing the same 200-candidate body-only scoring input: " + generatedBodyInput); + RetrievalBodyViewScores.prepare(prepared, generatedBodyInput); + final String generatedBodyHash = sha256(generatedBodyInput); + sourceHashes.put(generatedBodyInput, generatedBodyHash); + sourceHashes.put(prepared.resolve("manifest.json"), preparedHash); + sourceHashes.put(baselineDirectory.resolve("manifest.json"), baselineHash); + if (bodyReuse == null) { + try { + RetrievalBaselineRunner.runScoring(bodyScoringCommand(executable, worker, + generatedBodyInput, bodyInference, cache, selectedDevice, offline), + module, destination.resolve("logs"), processes); + } finally { + if (processes.size() > 0) { + processes.get(0).getAsJsonObject().addProperty("name", "body-scoring"); + } + } + } else { + System.err.println("Reusing body scores with their original input; Java verifies and projects by ID: " + + bodyInference); + } + RetrievalBaselineRunner.writeJson(destination.resolve("processes.json"), processes, false); + System.err.println("Comparing CE_TITLE_BODY and CE_TITLE_BODY_RRF_75_25 at Top20: " + evaluated); + final Path titleScores = titleInference.resolve("scores.jsonl"); + final Path titleMetadata = titleInference.resolve("metadata.json"); + final JsonObject evaluation = new RetrievalDualViewEvaluation().evaluate(prepared, + titleScores, titleMetadata, bodyInput, bodyInference.resolve("scores.jsonl"), + bodyInference.resolve("metadata.json"), evaluated); + if (!"retrieval-dual-view-results-v1".equals(evaluation.get("schema").getAsString()) + || evaluation.get("queryCount").getAsInt() != ClimateFeverDataset.SUBSET_SIZE + || !preparedHash.equals(evaluation.get("preparedManifestSha256").getAsString()) + || !preparedHash.equals(sha256(prepared.resolve("manifest.json"))) + || !baselineHash.equals(sha256(baselineDirectory.resolve("manifest.json"))) + || !generatedBodyHash.equals(sha256(generatedBodyInput)) + || !sha256(titleScores).equals(evaluation.get("titleScoresSha256").getAsString()) + || !sha256(titleMetadata).equals(evaluation.get("titleMetadataSha256").getAsString()) + || !baseline.get("scoresSha256").equals(evaluation.get("titleScoresSha256")) + || !baseline.get("metadataSha256").equals(evaluation.get("titleMetadataSha256")) + || !ClimateFeverDataset.RELEASE_SHA256.equals(sha256(input))) { + throw new IOException("Dataset, prepared input, baseline or model artifacts changed during the experiment"); + } + final JsonObject bodySource = evaluation.getAsJsonObject("bodySource"); + sourceHashes.putIfAbsent(titleScores, evaluation.get("titleScoresSha256").getAsString()); + sourceHashes.putIfAbsent(titleMetadata, evaluation.get("titleMetadataSha256").getAsString()); + sourceHashes.putIfAbsent(bodyInput, bodySource.get("inputSha256").getAsString()); + sourceHashes.putIfAbsent(bodyInference.resolve("scores.jsonl"), + bodySource.get("scoresSha256").getAsString()); + sourceHashes.putIfAbsent(bodyInference.resolve("metadata.json"), + bodySource.get("metadataSha256").getAsString()); + Files.copy(evaluated.resolve("comparison.csv"), destination.resolve("comparison.csv")); + final JsonObject manifest = new JsonObject(); + manifest.addProperty("schema", "retrieval-dual-view-run-v1"); + manifest.addProperty("status", "COMPLETED"); + manifest.addProperty("profile", "CE_TITLE_BODY_RRF_75_25"); + manifest.addProperty("titleWeight", 0.75); + manifest.addProperty("bodyWeight", 0.25); + manifest.addProperty("rrfConstant", 60); + manifest.addProperty("depth", 200); + manifest.addProperty("finalK", 20); + manifest.addProperty("queryCount", ClimateFeverDataset.SUBSET_SIZE); + manifest.add("pairCount", evaluation.get("pairCount")); + manifest.addProperty("startedAtUtc", started); + manifest.addProperty("completedAtUtc", Instant.now().toString()); + manifest.addProperty("inputPath", input.toString()); + manifest.addProperty("rawSha256", ClimateFeverDataset.RELEASE_SHA256); + manifest.addProperty("releaseCommit", ClimateFeverDataset.RELEASE_COMMIT); + manifest.addProperty("datasetUrl", ClimateFeverDataset.SOURCE_URL); + manifest.addProperty("baselineDirectory", baselineDirectory.toString()); + manifest.addProperty("baselineManifestSha256", baselineHash); + manifest.addProperty("preparedDirectory", prepared.toString()); + manifest.addProperty("preparedManifestSha256", preparedHash); + manifest.addProperty("generatedBodyInputPath", generatedBodyInput.toString()); + manifest.addProperty("generatedBodyInputSha256", generatedBodyHash); + manifest.addProperty("evaluationDirectory", evaluated.toString()); + manifest.addProperty("evaluationManifestSha256", sha256(evaluated.resolve("manifest.json"))); + manifest.addProperty("titleInferenceReused", titleReuse != null); + manifest.addProperty("bodyInferenceReused", bodyReuse != null); + manifest.addProperty("titleInferenceDirectory", titleInference.toString()); + manifest.addProperty("bodyInferenceDirectory", bodyInference.toString()); + manifest.addProperty("requestedDevice", selectedDevice); + manifest.addProperty("javaVersion", System.getProperty("java.version")); + manifest.addProperty("modelDownloadAllowed", !offline && (titleReuse == null || bodyReuse == null)); + if (titleReuse == null || bodyReuse == null) { + manifest.addProperty("pythonExecutable", executable.toString()); + manifest.addProperty("scorerSha256", sourceHashes.get(worker)); + } + manifest.add("titleScoresPath", evaluation.get("titleScoresPath")); + manifest.add("titleMetadataPath", evaluation.get("titleMetadataPath")); + manifest.add("titleScoresSha256", evaluation.get("titleScoresSha256")); + manifest.add("titleMetadataSha256", evaluation.get("titleMetadataSha256")); + manifest.add("titleInference", evaluation.get("titleInference")); + manifest.add("bodySource", evaluation.get("bodySource")); + manifest.add("comparison", evaluation.get("comparison")); + manifest.add("baselineProcessResources", baseline.get("processResources")); + manifest.add("processResources", processes); + manifest.add("outputSha256", RetrievalBaselineRunner.artifactHashes(destination)); + verifySources(sourceHashes); + RetrievalBaselineRunner.writeJson(destination.resolve("manifest.json"), manifest, true); + System.err.println("Retrieval dual-view experiment completed: " + destination); + return manifest; + } catch (IOException | RuntimeException error) { + final JsonObject failure = new JsonObject(); + failure.addProperty("schema", "retrieval-dual-view-run-v1"); + failure.addProperty("status", "FAILED"); + failure.addProperty("error", error.toString()); + failure.addProperty("baselineDirectory", baselineDirectory.toString()); + failure.addProperty("preparedDirectory", prepared.toString()); + failure.addProperty("bodyInputPath", bodyInput.toString()); + failure.addProperty("titleInferenceDirectory", titleInference.toString()); + failure.addProperty("bodyInferenceDirectory", bodyInference.toString()); + failure.addProperty("evaluationDirectory", evaluated.toString()); + failure.add("processResources", processes); + try { + RetrievalBaselineRunner.writeJson(destination.resolve("processes.json"), processes, false); + RetrievalBaselineRunner.writeJson(destination.resolve("failure.json"), failure, true); + } catch (IOException recordingError) { + error.addSuppressed(recordingError); + } + throw error; + } + } + + static List bodyScoringCommand(Path python, Path worker, Path input, Path inference, + Path cache, String device, boolean offline) { + final List command = RetrievalBaselineRunner.scoringCommand(python, worker, + input.getParent(), inference, cache, device, offline); + command.set(command.indexOf("--input") + 1, input.toString()); + return command; + } + + static Path initializeOutput(Path module, Path input, Path output, Path python, Path cache, + Path reusedTitle, Path reusedBody, Path reusedBodyInput) throws IOException { + if ((reusedBody == null) != (reusedBodyInput == null)) { + throw new IllegalArgumentException("reuseBodyInference and reuseBodyInput must be supplied together"); + } + if (output == null) { + throw new IllegalArgumentException("An explicit new or empty experiment output directory is required"); + } + final Path destination = RetrievalBaselineRunner.canonical(output); + for (Path resource : Arrays.asList(reusedBody, reusedBodyInput == null ? null : reusedBodyInput.getParent(), + completedRun(reusedTitle), completedRun(reusedBody), completedRun(reusedBodyInput))) { + if (resource != null) { + final Path resolved = RetrievalBaselineRunner.canonical(resource); + if (destination.startsWith(resolved) || resolved.startsWith(destination)) { + throw new IllegalArgumentException("Output overlaps a reused experiment source: " + resource); + } + } + } + return RetrievalBaselineRunner.initializeOutput(module, input, destination, python, cache, reusedTitle); + } + + private static Path completedRun(Path resource) { + if (resource == null || resource.getParent() == null) { + return null; + } + final Path parent = resource.getParent(); + if (Files.isRegularFile(parent.resolve("manifest.json"))) { + return parent; + } + final Path grandparent = parent.getParent(); + return grandparent != null && Files.isRegularFile(grandparent.resolve("manifest.json")) + ? grandparent : null; + } + + private static Path canonicalOptional(Path path) throws IOException { + return path == null ? null : RetrievalBaselineRunner.canonical(path); + } + + private static void snapshot(Map hashes, Path path) throws IOException { + hashes.put(path, sha256(path)); + } + + private static void verifySources(Map hashes) throws IOException { + for (Map.Entry entry : hashes.entrySet()) { + if (!entry.getValue().equals(sha256(entry.getKey()))) { + throw new IOException("Experiment source changed during the run: " + entry.getKey()); + } + } + } + + private static String sha256(Path path) throws IOException { + return RetrievalBaselineEvaluation.sha256(path); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalFactGoldCli.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalFactGoldCli.java new file mode 100644 index 000000000..9fca611ef --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalFactGoldCli.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.benchmark; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; + +/** Command-line entry point for the fixed four-strategy fact Gold evaluation. */ +public final class TemporalFactGoldCli { + + public static void main(String[] arguments) throws IOException { + final PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8.name()); + final PrintStream err = new PrintStream(System.err, true, StandardCharsets.UTF_8.name()); + System.exit(new TemporalFactGoldCli().run(arguments, out, err)); + } + + public int run(String[] arguments, PrintStream out, PrintStream err) { + try { + final Map options = options(arguments); + final JsonObject manifest = new FactGoldEvaluation().evaluate( + Paths.get(options.get("--gold-directory")).toAbsolutePath().normalize(), + Paths.get(options.get("--output")).toAbsolutePath().normalize()); + out.println(new TemporalJsonCodec().toJson(manifest)); + return manifest.get("passed").getAsBoolean() ? 0 : 1; + } catch (IllegalArgumentException error) { + return error(err, 2, "REJECT_INPUT", error.getMessage()); + } catch (IOException | RuntimeException error) { + return error(err, 3, "REJECT_EXECUTION", error.getMessage()); + } + } + + private static Map options(String[] arguments) { + final Map result = new HashMap<>(); + if (arguments == null || arguments.length != 4) { + throw new IllegalArgumentException("Required: --gold-directory DIR --output EMPTY_DIR"); + } + for (int index = 0; index < arguments.length; index += 2) { + if (!Arrays.asList("--gold-directory", "--output").contains(arguments[index]) + || arguments[index + 1] == null || arguments[index + 1].trim().isEmpty() + || result.put(arguments[index], arguments[index + 1]) != null) { + throw new IllegalArgumentException("Required: --gold-directory DIR --output EMPTY_DIR"); + } + } + return result; + } + + private static int error(PrintStream err, int exitCode, String code, String message) { + final JsonObject json = new JsonObject(); + json.addProperty("code", code); + json.addProperty("message", message); + err.println(new TemporalJsonCodec().toJson(json)); + return exitCode; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineCli.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineCli.java new file mode 100644 index 000000000..d501a56ca --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineCli.java @@ -0,0 +1,95 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; + +/** Runs the complete fixed baseline; Python is used only for model inference. */ +public final class TemporalRetrievalBaselineCli { + + public static void main(String[] arguments) throws IOException { + final PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8.name()); + final PrintStream err = new PrintStream(System.err, true, StandardCharsets.UTF_8.name()); + System.exit(new TemporalRetrievalBaselineCli().run(arguments, out, err)); + } + + public int run(String[] arguments, PrintStream out, PrintStream err) { + try { + final Map options = options(arguments); + final String offline = options.getOrDefault("--offline", "true"); + require("true".equals(offline) || "false".equals(offline), + "offline must be true or false"); + final JsonObject manifest = new RetrievalBaselineRunner().run( + Paths.get(options.get("--module-root")), optionalPath(options, "--input"), + Paths.get(options.get("--output")), options.getOrDefault("--device", "cuda"), + optionalPath(options, "--python"), optionalPath(options, "--reuse-inference"), + Boolean.parseBoolean(offline)); + out.println(new TemporalJsonCodec().toJson(manifest)); + return 0; + } catch (IllegalArgumentException error) { + return error(err, 2, "REJECT_INPUT", error.getMessage()); + } catch (IOException | RuntimeException error) { + return error(err, 3, "REJECT_EXECUTION", error.getMessage()); + } + } + + private static Map options(String[] arguments) { + require(arguments != null && arguments.length > 0 && arguments.length % 2 == 0, + "Options must be name/value pairs"); + final Map options = new HashMap<>(); + for (int index = 0; index < arguments.length; index += 2) { + require(Arrays.asList("--module-root", "--input", "--output", "--device", "--python", + "--reuse-inference", "--offline").contains(arguments[index]) + && arguments[index + 1] != null && !arguments[index + 1].trim().isEmpty() + && options.put(arguments[index], arguments[index + 1]) == null, + "Unknown, empty or duplicate option"); + } + require(options.keySet().containsAll(Arrays.asList("--module-root", "--output")), + "module-root and output are required"); + return options; + } + + private static Path optionalPath(Map options, String name) { + return options.containsKey(name) ? Paths.get(options.get(name)) : null; + } + + private static int error(PrintStream err, int exitCode, String code, String message) { + final JsonObject json = new JsonObject(); + json.addProperty("code", code); + json.addProperty("message", message); + err.println(new TemporalJsonCodec().toJson(json)); + return exitCode; + } + + private static void require(boolean valid, String message) { + if (!valid) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesCli.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesCli.java new file mode 100644 index 000000000..0b5cacd43 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesCli.java @@ -0,0 +1,97 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; + +/** Runs the fixed four-profile Evidence evaluation using only local Java retrieval. */ +public final class TemporalRetrievalCandidatesCli { + + public static void main(String[] arguments) throws IOException { + final PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8.name()); + final PrintStream err = new PrintStream(System.err, true, StandardCharsets.UTF_8.name()); + System.exit(new TemporalRetrievalCandidatesCli().run(arguments, out, err)); + } + + public int run(String[] arguments, PrintStream out, PrintStream err) { + try { + final Map options = options(arguments); + final Path module = RetrievalBaselineRunner.canonical(Paths.get(options.get("--module-root"))); + require(Files.isDirectory(module), "module-root must be an existing directory"); + final Path input = options.containsKey("--input") ? Paths.get(options.get("--input")) + : module.resolve("target/climate-fever/climate-fever-1.0.1.jsonl"); + final Path output = RetrievalBaselineRunner.canonical(Paths.get(options.get("--output"))); + require(!module.startsWith(output), "Output must be separate from the module root"); + for (String name : new String[] {"src", "bin", "pom.xml", "target/classes", "target/test-classes", + "target/maven-status", "target/cross-encoder-cache", "target/cross-encoder-venv", + "target/cross-encoder-cuda-venv"}) { + final Path resource = RetrievalBaselineRunner.canonical(module.resolve(name)); + require(!output.startsWith(resource) && !resource.startsWith(output), + "Output overlaps source, compiled output, an environment or cache: " + name); + } + final JsonObject result = new RetrievalCandidateEvaluation().evaluate(input, output); + out.println(new TemporalJsonCodec().toJson(result)); + return 0; + } catch (IllegalArgumentException error) { + return error(err, 2, "REJECT_INPUT", error.getMessage()); + } catch (IOException | RuntimeException error) { + return error(err, 3, "REJECT_EXECUTION", error.getMessage()); + } + } + + private static Map options(String[] arguments) { + require(arguments != null && arguments.length > 0 && arguments.length % 2 == 0, + "Options must be name/value pairs"); + final Map options = new HashMap<>(); + for (int index = 0; index < arguments.length; index += 2) { + require(Arrays.asList("--module-root", "--input", "--output").contains(arguments[index]) + && arguments[index + 1] != null && !arguments[index + 1].trim().isEmpty() + && options.put(arguments[index], arguments[index + 1]) == null, + "Unknown, empty or duplicate option"); + } + require(options.keySet().containsAll(Arrays.asList("--module-root", "--output")), + "module-root and output are required"); + return options; + } + + private static int error(PrintStream err, int exitCode, String code, String message) { + final JsonObject json = new JsonObject(); + json.addProperty("code", code); + json.addProperty("message", message); + err.println(new TemporalJsonCodec().toJson(json)); + return exitCode; + } + + private static void require(boolean valid, String message) { + if (!valid) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/Bm25Index.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/Bm25Index.java new file mode 100644 index 000000000..1f7909219 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/Bm25Index.java @@ -0,0 +1,301 @@ +/* + * 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.candidate; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.SortedDocValuesField; +import org.apache.lucene.document.StringField; +import org.apache.lucene.document.TextField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.queryparser.classic.MultiFieldQueryParser; +import org.apache.lucene.queryparser.classic.ParseException; +import org.apache.lucene.queryparser.classic.QueryParser; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.Sort; +import org.apache.lucene.search.SortField; +import org.apache.lucene.search.TopDocs; +import org.apache.lucene.search.similarities.BM25Similarity; +import org.apache.lucene.store.ByteBuffersDirectory; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.IOUtils; + +/** A fixed, immutable corpus; callers rebuild it from the visible temporal slice. */ +public final class Bm25Index implements AutoCloseable { + + public static final int DEFAULT_TOP_K = 20; + public static final float K1 = 1.2f; + public static final float B = 0.75f; + private static final Sort ORDER = new Sort(SortField.FIELD_SCORE, + new SortField("sort_id", SortField.Type.STRING)); + + private static final Comparator CANDIDATE_ORDER = Comparator + .comparingDouble((Candidate candidate) -> candidate.score).reversed() + .thenComparing(candidate -> candidate.id); + private final RetrievalProfile profile; + private final ChannelIndex rawIndex; + private final ChannelIndex aliasIndex; + private boolean closed; + + public Bm25Index(Map> documents) throws IOException { + this(documents, RetrievalProfile.BASELINE); + } + + public Bm25Index(Map> documents, RetrievalProfile profile) + throws IOException { + Objects.requireNonNull(documents, "documents"); + this.profile = Objects.requireNonNull(profile, "profile"); + final ChannelIndex openedRaw = new ChannelIndex(documents, profile, false); + ChannelIndex openedAlias = null; + try { + if (profile.usesLexicalFusion()) { + openedAlias = new ChannelIndex(documents, RetrievalProfile.ENGLISH, true); + } + rawIndex = openedRaw; + aliasIndex = openedAlias; + } catch (IOException | RuntimeException | Error exception) { + closeAfterFailure(exception, openedRaw, openedAlias); + throw exception; + } + } + + public List search(String text, int topK) throws IOException { + if (closed) { + throw new IllegalStateException("Index is closed"); + } + if (topK <= 0) { + throw new IllegalArgumentException("topK must be positive"); + } + if (!profile.usesLexicalFusion()) { + return rawIndex.search(text, topK); + } + final int poolSize = profile.getCandidatePoolSize(topK); + final Map candidates = new TreeMap<>(); + addChannel(candidates, rawIndex.search(text, poolSize), "BM25"); + addChannel(candidates, aliasIndex.search(LexicalTerms.canonicalize(text), poolSize), + "ALIAS_BM25"); + final List ordered = new ArrayList<>(candidates.values()); + ordered.sort(CANDIDATE_ORDER); + for (int index = 0; index < ordered.size(); index++) { + Candidate candidate = ordered.get(index); + candidate.scores.put("RRF", candidate.score); + candidate.ranks.put("RRF", index + 1); + } + List result = new ArrayList<>(); + for (Candidate candidate : ordered.subList(0, Math.min(topK, ordered.size()))) { + int rank = result.size() + 1; + result.add(new Hit(candidate.id, candidate.score, rank, + candidate.scores, candidate.ranks)); + } + return Collections.unmodifiableList(result); + } + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + IOUtils.close(rawIndex, aliasIndex); + } + } + + private static void addChannel(Map candidates, List hits, + String channel) { + for (Hit hit : hits) { + Candidate candidate = candidates.computeIfAbsent(hit.id, Candidate::new); + candidate.scores.put(channel, hit.score); + candidate.ranks.put(channel, hit.rank); + candidate.score += 1.0 / (RetrievalProfile.RRF_K + hit.rank); + } + } + + private static void closeAfterFailure(Throwable failure, Closeable... resources) { + try { + IOUtils.close(resources); + } catch (IOException | RuntimeException closingFailure) { + failure.addSuppressed(closingFailure); + } + } + + private static final class ChannelIndex implements Closeable { + + private final ByteBuffersDirectory directory; + private final Analyzer analyzer; + private final Map fieldWeights; + private final String[] fields; + private final DirectoryReader reader; + private final IndexSearcher searcher; + private final boolean canonical; + + private ChannelIndex(Map> documents, RetrievalProfile profile, + boolean canonical) throws IOException { + this.canonical = canonical; + fieldWeights = profile.getFieldWeights(); + fields = fieldWeights.keySet().toArray(new String[0]); + directory = new ByteBuffersDirectory(); + Analyzer openedAnalyzer = null; + DirectoryReader openedReader = null; + try { + openedAnalyzer = profile.createAnalyzer(); + openedReader = openReader(documents, openedAnalyzer); + searcher = new IndexSearcher(openedReader); + searcher.setSimilarity(new BM25Similarity(K1, B)); + reader = openedReader; + analyzer = openedAnalyzer; + } catch (IOException | RuntimeException | Error exception) { + closeAfterFailure(exception, openedReader, openedAnalyzer, directory); + throw exception; + } + } + + private DirectoryReader openReader(Map> documents, + Analyzer openedAnalyzer) throws IOException { + IndexWriterConfig config = new IndexWriterConfig(openedAnalyzer); + config.setOpenMode(IndexWriterConfig.OpenMode.CREATE); + config.setSimilarity(new BM25Similarity(K1, B)); + try (IndexWriter writer = new IndexWriter(directory, config)) { + for (Map.Entry> entry + : new TreeMap<>(documents).entrySet()) { + writer.addDocument(document(entry.getKey(), entry.getValue())); + } + } + return DirectoryReader.open(directory); + } + + private Document document(String id, Map values) { + Objects.requireNonNull(id, "document id"); + if (id.trim().isEmpty()) { + throw new IllegalArgumentException("document id must not be blank"); + } + Objects.requireNonNull(values, "fields"); + for (String field : values.keySet()) { + if (!fieldWeights.containsKey(field)) { + throw new IllegalArgumentException("Unknown retrieval field: " + field); + } + } + Document document = new Document(); + document.add(new StringField("id", id, Field.Store.YES)); + document.add(new SortedDocValuesField("sort_id", new BytesRef(id))); + for (String field : fields) { + if (values.containsKey(field)) { + String value = values.get(field); + String indexed = canonical ? LexicalTerms.canonicalize(value) + : TextNormalizer.normalize(value); + document.add(new TextField(field, indexed, Field.Store.NO)); + } + } + return document; + } + + private List search(String text, int topK) throws IOException { + String normalized = TextNormalizer.normalize(text); + if (normalized.isEmpty() || reader.numDocs() == 0) { + return Collections.emptyList(); + } + Query query; + try { + query = new MultiFieldQueryParser(fields, analyzer, fieldWeights) + .parse(QueryParser.escape(normalized)); + } catch (ParseException exception) { + throw new IllegalArgumentException("Invalid retrieval text", exception); + } + TopDocs matches = searcher.search(query, Math.min(topK, reader.numDocs()), ORDER, true); + List result = new ArrayList<>(); + for (ScoreDoc match : matches.scoreDocs) { + result.add(new Hit(searcher.doc(match.doc).get("id"), match.score, result.size() + 1)); + } + return Collections.unmodifiableList(result); + } + + @Override + public void close() throws IOException { + IOUtils.close(reader, analyzer, directory); + } + } + + private static final class Candidate { + + private final String id; + private final Map scores = new LinkedHashMap<>(); + private final Map ranks = new LinkedHashMap<>(); + private double score; + + private Candidate(String id) { + this.id = id; + } + } + + public static final class Hit { + + private final String id; + private final double score; + private final int rank; + private final Map channelScores; + private final Map channelRanks; + + private Hit(String id, double score, int rank) { + this(id, score, rank, Collections.singletonMap("BM25", score), + Collections.singletonMap("BM25", rank)); + } + + Hit(String id, double score, int rank, Map scores, + Map ranks) { + this.id = id; + this.score = score; + this.rank = rank; + channelScores = Collections.unmodifiableMap(new LinkedHashMap<>(scores)); + channelRanks = Collections.unmodifiableMap(new LinkedHashMap<>(ranks)); + } + + public String getId() { + return id; + } + + public double getScore() { + return score; + } + + public int getRank() { + return rank; + } + + public Map getChannelScores() { + return channelScores; + } + + public Map getChannelRanks() { + return channelRanks; + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/CandidateMerger.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/CandidateMerger.java new file mode 100644 index 000000000..7fb1dbca0 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/CandidateMerger.java @@ -0,0 +1,126 @@ +/* + * 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.candidate; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** Deterministic RRF over independent rankings, with complete mandatory candidates first. */ +public final class CandidateMerger { + + private CandidateMerger() { + } + + public static List merge(Map> channels, + Collection mandatoryIds, int optionalTopK) { + Objects.requireNonNull(channels, "channels"); + Objects.requireNonNull(mandatoryIds, "mandatoryIds"); + if (optionalTopK <= 0) { + throw new IllegalArgumentException("optionalTopK must be positive"); + } + final Map byId = new TreeMap<>(); + for (Map.Entry> entry : new TreeMap<>(channels).entrySet()) { + String channel = validName(entry.getKey(), "channel"); + if ("RULE".equals(channel) || "RRF".equals(channel)) { + throw new IllegalArgumentException("Reserved candidate channel: " + channel); + } + final Set seenIds = new HashSet<>(); + final Set seenRanks = new HashSet<>(); + for (Bm25Index.Hit hit : Objects.requireNonNull(entry.getValue(), "channel hits")) { + Objects.requireNonNull(hit, "hit"); + String id = validName(hit.getId(), "candidate ID"); + if (!Double.isFinite(hit.getScore()) || hit.getScore() < 0 || hit.getRank() <= 0 + || !seenIds.add(id) || !seenRanks.add(hit.getRank())) { + throw new IllegalArgumentException("Invalid or duplicate candidate hit"); + } + Candidate candidate = byId.computeIfAbsent(id, Candidate::new); + candidate.add(channel, hit.getScore(), hit.getRank()); + } + } + final TreeSet mandatory = new TreeSet<>(); + for (String id : mandatoryIds) { + mandatory.add(validName(id, "mandatory ID")); + } + int ruleRank = 0; + for (String id : mandatory) { + byId.computeIfAbsent(id, Candidate::new).add("RULE", 1.0, ++ruleRank); + } + final List ordered = new ArrayList<>(byId.values()); + ordered.sort(Comparator.comparingDouble((Candidate candidate) -> candidate.score) + .reversed().thenComparing(candidate -> candidate.id)); + for (int i = 0; i < ordered.size(); i++) { + Candidate candidate = ordered.get(i); + candidate.scores.put("RRF", candidate.score); + candidate.ranks.put("RRF", i + 1); + } + final List result = new ArrayList<>(); + for (Candidate candidate : ordered) { + if (mandatory.contains(candidate.id)) { + result.add(candidate.hit(result.size() + 1)); + } + } + int optionalCount = 0; + for (Candidate candidate : ordered) { + if (!mandatory.contains(candidate.id) && optionalCount < optionalTopK) { + result.add(candidate.hit(result.size() + 1)); + optionalCount++; + } + } + return Collections.unmodifiableList(result); + } + + private static String validName(String value, String name) { + if (Objects.requireNonNull(value, name).trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + private static final class Candidate { + + private final String id; + private final Map scores = new TreeMap<>(); + private final Map ranks = new TreeMap<>(); + private double score; + + private Candidate(String id) { + this.id = id; + } + + private void add(String channel, double channelScore, int rank) { + scores.put(channel, channelScore); + ranks.put(channel, rank); + score += 1.0 / (RetrievalProfile.RRF_K + (double) rank); + } + + private Bm25Index.Hit hit(int rank) { + return new Bm25Index.Hit(id, score, rank, scores, ranks); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/EvidenceCandidate.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/EvidenceCandidate.java new file mode 100644 index 000000000..18597f72d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/EvidenceCandidate.java @@ -0,0 +1,110 @@ +/* + * 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.candidate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.model.Evidence; + +public final class EvidenceCandidate { + + private final Evidence evidence; + private final List versionIds; + private final double score; + private final int rank; + private final Map channelScores; + private final Map channelRanks; + + public EvidenceCandidate(Evidence evidence, List versionIds, double score, int rank) { + this(evidence, versionIds, score, rank, Collections.singletonMap("BM25", score), + Collections.singletonMap("BM25", rank)); + } + + public EvidenceCandidate(Evidence evidence, List versionIds, double score, int rank, + Map channelScores, + Map channelRanks) { + this.evidence = Objects.requireNonNull(evidence, "evidence"); + TreeSet ordered = new TreeSet<>(); + for (String id : Objects.requireNonNull(versionIds, "versionIds")) { + if (Objects.requireNonNull(id, "versionId").trim().isEmpty()) { + throw new IllegalArgumentException("versionId must not be blank"); + } + ordered.add(id); + } + if (!Double.isFinite(score) || score < 0 || rank <= 0) { + throw new IllegalArgumentException( + "score must be finite and nonnegative; rank positive"); + } + this.versionIds = Collections.unmodifiableList(new ArrayList<>(ordered)); + this.score = score; + this.rank = rank; + Map scores = new TreeMap<>(); + for (Map.Entry entry + : Objects.requireNonNull(channelScores, "channelScores").entrySet()) { + String channel = Objects.requireNonNull(entry.getKey(), "channel"); + double value = Objects.requireNonNull(entry.getValue(), "channel score"); + if (channel.trim().isEmpty() || !Double.isFinite(value) || value < 0) { + throw new IllegalArgumentException("Invalid evidence channel score"); + } + scores.put(channel, value); + } + Map ranks = new TreeMap<>(); + for (Map.Entry entry + : Objects.requireNonNull(channelRanks, "channelRanks").entrySet()) { + String channel = Objects.requireNonNull(entry.getKey(), "channel"); + int value = Objects.requireNonNull(entry.getValue(), "channel rank"); + if (!scores.containsKey(channel) || value <= 0) { + throw new IllegalArgumentException("Invalid evidence channel rank"); + } + ranks.put(channel, value); + } + this.channelScores = Collections.unmodifiableMap(scores); + this.channelRanks = Collections.unmodifiableMap(ranks); + } + + public Evidence getEvidence() { + return evidence; + } + + public List getVersionIds() { + return versionIds; + } + + public double getScore() { + return score; + } + + public int getRank() { + return rank; + } + + public Map getChannelScores() { + return channelScores; + } + + public Map getChannelRanks() { + return channelRanks; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/EvidenceRetriever.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/EvidenceRetriever.java new file mode 100644 index 000000000..abd6e10a3 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/EvidenceRetriever.java @@ -0,0 +1,115 @@ +/* + * 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.candidate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.Evidence; + +/** Evidence-only retrieval, also used by the independent public-data evaluation. */ +public final class EvidenceRetriever implements AutoCloseable { + + private final Map evidenceById = new TreeMap<>(); + private final Map> exactIds = new TreeMap<>(); + private final RetrievalProfile profile; + private final Bm25Index index; + private final MinHashIndex minHash; + private boolean closed; + + public EvidenceRetriever(Collection corpus) throws IOException { + this(corpus, RetrievalProfile.BASELINE); + } + + public EvidenceRetriever(Collection corpus, RetrievalProfile profile) + throws IOException { + Objects.requireNonNull(corpus, "corpus"); + this.profile = Objects.requireNonNull(profile, "profile"); + Map> documents = new TreeMap<>(); + for (Evidence evidence : corpus) { + Objects.requireNonNull(evidence, "evidence"); + Evidence previous = evidenceById.put(evidence.getId(), evidence); + if (previous != null && !previous.equals(evidence)) { + throw new IllegalArgumentException("Evidence ID reused: " + evidence.getId()); + } + Map fields = new TreeMap<>(); + fields.put("evidence", evidence.getContent()); + documents.put(evidence.getId(), fields); + } + if (profile.isCandidateStrategy()) { + for (Evidence evidence : evidenceById.values()) { + String normalized = TextNormalizer.normalize(evidence.getContent()); + exactIds.computeIfAbsent(normalized, ignored -> new ArrayList<>()) + .add(evidence.getId()); + } + } + minHash = profile.usesMinHash() ? new MinHashIndex(documents) : null; + index = profile.usesBm25() ? new Bm25Index(documents, profile) : null; + } + + public List search(String text, int topK) throws IOException { + if (closed) { + throw new IllegalStateException("Retriever is closed"); + } + if (topK <= 0) { + throw new IllegalArgumentException("topK must be positive"); + } + List hits; + if (profile.isCandidateStrategy()) { + final String normalized = TextNormalizer.normalize(text); + final int poolSize = profile.getCandidatePoolSize(topK); + Map> channels = new TreeMap<>(); + if (index != null) { + channels.put("BM25", index.search(text, poolSize)); + } + if (minHash != null) { + channels.put("MINHASH", minHash.search(text, poolSize)); + } + List rules = normalized.isEmpty() ? Collections.emptyList() + : exactIds.getOrDefault(normalized, Collections.emptyList()); + List merged = CandidateMerger.merge(channels, rules, topK); + hits = merged.subList(0, Math.min(topK, merged.size())); + } else { + hits = index.search(text, topK); + } + List result = new ArrayList<>(); + for (Bm25Index.Hit hit : hits) { + result.add(new EvidenceCandidate(evidenceById.get(hit.getId()), + Collections.emptyList(), hit.getScore(), hit.getRank(), + hit.getChannelScores(), hit.getChannelRanks())); + } + return Collections.unmodifiableList(result); + } + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + if (index != null) { + index.close(); + } + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/FactCandidate.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/FactCandidate.java new file mode 100644 index 000000000..79c06c992 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/FactCandidate.java @@ -0,0 +1,112 @@ +/* + * 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.candidate; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; + +public final class FactCandidate { + + private final FactKey factKey; + private final MemoryFactVersion version; + private final boolean mandatory; + private final Map scores; + private final int rank; + private final double score; + private final Map channelRanks; + + public FactCandidate(FactKey factKey, MemoryFactVersion version, boolean mandatory, + Map scores, int rank) { + this(factKey, version, mandatory, scores.getOrDefault("RRF", + scores.getOrDefault("BM25", scores.getOrDefault("RULE", 0.0))), rank, + scores, Collections.emptyMap()); + } + + public FactCandidate(FactKey factKey, MemoryFactVersion version, boolean mandatory, + double score, int rank, Map scores, + Map channelRanks) { + this.factKey = Objects.requireNonNull(factKey, "factKey"); + this.version = Objects.requireNonNull(version, "version"); + if (!factKey.getSubjectId().equals(version.getFact().getSubject().getId()) + || !factKey.getPredicate().equals(version.getFact().getPredicate())) { + throw new IllegalArgumentException("Fact key does not match candidate version"); + } + if (rank <= 0 || !Double.isFinite(score) || score < 0) { + throw new IllegalArgumentException("rank must be positive; score finite and nonnegative"); + } + Map copy = new TreeMap<>(); + for (Map.Entry entry + : Objects.requireNonNull(scores, "scores").entrySet()) { + String channel = Objects.requireNonNull(entry.getKey(), "channel"); + double channelScore = Objects.requireNonNull(entry.getValue(), "score"); + if (channel.trim().isEmpty() || !Double.isFinite(channelScore) || channelScore < 0) { + throw new IllegalArgumentException("Invalid candidate channel or score"); + } + copy.put(channel, channelScore); + } + this.mandatory = mandatory; + this.scores = Collections.unmodifiableMap(copy); + this.rank = rank; + this.score = score; + Map ranks = new TreeMap<>(); + for (Map.Entry entry + : Objects.requireNonNull(channelRanks, "channelRanks").entrySet()) { + String channel = Objects.requireNonNull(entry.getKey(), "channel"); + int channelRank = Objects.requireNonNull(entry.getValue(), "channel rank"); + if (!copy.containsKey(channel) || channelRank <= 0) { + throw new IllegalArgumentException("Invalid candidate channel rank"); + } + ranks.put(channel, channelRank); + } + this.channelRanks = Collections.unmodifiableMap(ranks); + } + + public FactKey getFactKey() { + return factKey; + } + + public MemoryFactVersion getVersion() { + return version; + } + + public boolean isMandatory() { + return mandatory; + } + + public Map getScores() { + return scores; + } + + public int getRank() { + return rank; + } + + public double getScore() { + return score; + } + + public Map getChannelRanks() { + return channelRanks; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/LexicalTerms.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/LexicalTerms.java new file mode 100644 index 000000000..76f58abf1 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/LexicalTerms.java @@ -0,0 +1,91 @@ +/* + * 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.candidate; + +import java.text.Normalizer; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** A fixed, label-independent alias table for retrieval copies only. */ +public final class LexicalTerms { + + private static final Map ALIASES; + private static final Map CANONICAL_ALIASES; + private static final Pattern ALIAS_PATTERN; + + static { + Map aliases = new LinkedHashMap<>(); + aliases.put("co2", "carbon dioxide"); + aliases.put("co 2", "carbon dioxide"); + aliases.put("co₂", "carbon dioxide"); + aliases.put("carbon dioxide", "carbon dioxide"); + aliases.put("ch4", "methane"); + aliases.put("methane", "methane"); + aliases.put("n2o", "nitrous oxide"); + aliases.put("nitrous oxide", "nitrous oxide"); + ALIASES = Collections.unmodifiableMap(aliases); + Map canonicalAliases = new LinkedHashMap<>(); + for (Map.Entry alias : aliases.entrySet()) { + canonicalAliases.put(normalize(alias.getKey()), alias.getValue()); + } + CANONICAL_ALIASES = Collections.unmodifiableMap(canonicalAliases); + StringBuilder alternatives = new StringBuilder(); + for (String alias : canonicalAliases.keySet()) { + if (alternatives.length() > 0) { + alternatives.append('|'); + } + alternatives.append(Pattern.quote(alias)); + } + String boundary = "[\\p{L}\\p{M}\\p{N}_]"; + ALIAS_PATTERN = Pattern.compile("(? getAliases() { + return ALIASES; + } + + private static String normalize(String text) { + return TextNormalizer.normalize(Normalizer.normalize(Objects.requireNonNull(text, "text"), + Normalizer.Form.NFKC)); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/MinHashIndex.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/MinHashIndex.java new file mode 100644 index 000000000..13bb4880b --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/MinHashIndex.java @@ -0,0 +1,206 @@ +/* + * 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.candidate; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** An immutable near-duplicate index with LSH recall and exact Jaccard verification. */ +public final class MinHashIndex { + + public static final int SHINGLE_SIZE = 3; + public static final int HASH_COUNT = 128; + public static final int BANDS = 32; + public static final int ROWS_PER_BAND = 4; + public static final long SEED = 0x6a09e667f3bcc909L; + public static final double JACCARD_THRESHOLD = 0.5; + public static final String HASH_ALGORITHM = "UTF8_FNV1A64_SEEDED_SPLITMIX64_V1"; + public static final String TOKENIZATION = "NFC_ROOT_LOWERCASE_UNICODE_LETTER_MARK_NUMBER"; + + private static final Pattern TOKEN = Pattern.compile("[\\p{L}\\p{M}\\p{N}]+"); + private static final Set FIELDS = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("subject", "predicate", "object", "evidence", "title"))); + private static final long HASH_STEP = 0x9e3779b97f4a7c15L; + private static final Comparator ORDER = Comparator + .comparingDouble(Bm25Index.Hit::getScore).reversed() + .thenComparing(Bm25Index.Hit::getId); + + private final Map> documentShingles; + private final Map> buckets; + + public MinHashIndex(Map> documents) { + Objects.requireNonNull(documents, "documents"); + final Map> indexed = new TreeMap<>(); + final Map> mutableBuckets = new HashMap<>(); + for (Map.Entry> document : documents.entrySet()) { + final String id = Objects.requireNonNull(document.getKey(), "document id"); + if (id.trim().isEmpty()) { + throw new IllegalArgumentException("document id must not be blank"); + } + Objects.requireNonNull(document.getValue(), "fields"); + final Set features = new HashSet<>(); + for (Map.Entry field : document.getValue().entrySet()) { + if (!FIELDS.contains(field.getKey())) { + throw new IllegalArgumentException("Unknown retrieval field: " + field.getKey()); + } + features.addAll(shingles(field.getValue())); + } + if (!features.isEmpty()) { + indexed.put(id, Collections.unmodifiableSet(features)); + final long[] signature = signature(features); + for (int band = 0; band < BANDS; band++) { + mutableBuckets.computeIfAbsent(new BandKey(band, signature), + key -> new TreeSet<>()).add(id); + } + } + } + documentShingles = Collections.unmodifiableMap(indexed); + final Map> immutableBuckets = new HashMap<>(); + for (Map.Entry> bucket : mutableBuckets.entrySet()) { + immutableBuckets.put(bucket.getKey(), Collections.unmodifiableSet(bucket.getValue())); + } + buckets = Collections.unmodifiableMap(immutableBuckets); + } + + public List search(String text, int topK) { + if (topK <= 0) { + throw new IllegalArgumentException("topK must be positive"); + } + final Set query = shingles(text); + if (query.isEmpty()) { + return Collections.emptyList(); + } + final Set candidateIds = new TreeSet<>(); + final long[] signature = signature(query); + for (int band = 0; band < BANDS; band++) { + candidateIds.addAll(buckets.getOrDefault(new BandKey(band, signature), + Collections.emptySet())); + } + final List ordered = new ArrayList<>(); + for (String id : candidateIds) { + double score = jaccard(query, documentShingles.get(id)); + if (score >= JACCARD_THRESHOLD) { + ordered.add(hit(id, score, 1)); + } + } + ordered.sort(ORDER); + final List result = new ArrayList<>(); + for (int index = 0; index < Math.min(topK, ordered.size()); index++) { + Bm25Index.Hit candidate = ordered.get(index); + result.add(hit(candidate.getId(), candidate.getScore(), index + 1)); + } + return Collections.unmodifiableList(result); + } + + static Set shingles(String text) { + final Matcher matcher = TOKEN.matcher(TextNormalizer.normalize(text)); + final List tokens = new ArrayList<>(); + while (matcher.find()) { + tokens.add(matcher.group()); + } + final Set result = new HashSet<>(); + for (int index = 0; index + SHINGLE_SIZE <= tokens.size(); index++) { + result.add(tokens.get(index) + " " + tokens.get(index + 1) + " " + + tokens.get(index + 2)); + } + return result; + } + + static double jaccard(Set left, Set right) { + if (left.isEmpty() || right.isEmpty()) { + return 0.0; + } + int intersection = 0; + for (String shingle : left) { + if (right.contains(shingle)) { + intersection++; + } + } + return (double) intersection / (left.size() + right.size() - intersection); + } + + static long[] signature(Set shingles) { + final long[] result = new long[HASH_COUNT]; + Arrays.fill(result, -1L); + for (String shingle : shingles) { + long hash = 0xcbf29ce484222325L; + for (byte value : shingle.getBytes(StandardCharsets.UTF_8)) { + hash ^= value & 0xffL; + hash *= 0x100000001b3L; + } + for (int index = 0; index < HASH_COUNT; index++) { + long value = mix(hash + SEED + HASH_STEP * (index + 1)); + if (Long.compareUnsigned(value, result[index]) < 0) { + result[index] = value; + } + } + } + return result; + } + + private static long mix(long value) { + long mixed = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL; + return mixed ^ (mixed >>> 31); + } + + private static Bm25Index.Hit hit(String id, double score, int rank) { + return new Bm25Index.Hit(id, score, rank, Collections.singletonMap("MINHASH", score), + Collections.singletonMap("MINHASH", rank)); + } + + private static final class BandKey { + private final int band; + private final long[] values; + + private BandKey(int band, long[] signature) { + this.band = band; + values = Arrays.copyOfRange(signature, band * ROWS_PER_BAND, + (band + 1) * ROWS_PER_BAND); + } + + @Override + public int hashCode() { + return 31 * band + Arrays.hashCode(values); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof BandKey)) { + return false; + } + BandKey key = (BandKey) other; + return band == key.band && Arrays.equals(values, key.values); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/RetrievalProfile.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/RetrievalProfile.java new file mode 100644 index 000000000..3b5283f3c --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/RetrievalProfile.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.candidate; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.en.EnglishAnalyzer; +import org.apache.lucene.analysis.standard.StandardAnalyzer; + +/** Retrieval channels used by temporal candidates and the evidence baseline. */ +public enum RetrievalProfile { + + BASELINE, + ENGLISH, + LEXICAL_FUSION, + RULE_ONLY, + RULE_BM25, + RULE_MINHASH, + RULE_ALL; + + public static final int LEXICAL_POOL_SIZE = 100; + public static final int RRF_K = 60; + private final Map fieldWeights; + + RetrievalProfile() { + Map weights = new LinkedHashMap<>(); + weights.put("subject", 1.0f); + weights.put("predicate", 1.0f); + weights.put("object", 1.0f); + weights.put("evidence", 1.0f); + fieldWeights = Collections.unmodifiableMap(weights); + } + + public String getAnalyzerName() { + return this == BASELINE ? StandardAnalyzer.class.getName() : EnglishAnalyzer.class.getName(); + } + + public boolean usesLexicalFusion() { + return this == LEXICAL_FUSION; + } + + public boolean isCandidateStrategy() { + return this == RULE_ONLY || this == RULE_BM25 + || this == RULE_MINHASH || this == RULE_ALL; + } + + public boolean usesBm25() { + return this != RULE_ONLY && this != RULE_MINHASH; + } + + public boolean usesMinHash() { + return this == RULE_MINHASH || this == RULE_ALL; + } + + public int getCandidatePoolSize(int topK) { + if (topK <= 0) { + throw new IllegalArgumentException("topK must be positive"); + } + return usesLexicalFusion() || isCandidateStrategy() + ? Math.max(LEXICAL_POOL_SIZE, topK) : topK; + } + + public Map getFieldWeights() { + return fieldWeights; + } + + public Analyzer createAnalyzer() { + return this == BASELINE ? new StandardAnalyzer() : new EnglishAnalyzer(); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/TemporalCandidateService.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/TemporalCandidateService.java new file mode 100644 index 000000000..49af0865b --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/TemporalCandidateService.java @@ -0,0 +1,306 @@ +/* + * 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.candidate; + +import java.io.IOException; +import java.time.Instant; +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 java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.query.TemporalQueryService; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; + +/** Read-only retrieval over one scope and one temporal slice. */ +public final class TemporalCandidateService { + + private final TemporalQueryService query = new TemporalQueryService(); + private final RetrievalProfile profile; + + public TemporalCandidateService() { + this(RetrievalProfile.BASELINE); + } + + public TemporalCandidateService(RetrievalProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + } + + /** Same-key versions are complete; topK limits only additional soft candidates. */ + public List facts(CanonicalSnapshot snapshot, QueryMode mode, FactKey key, + Instant validAt, Instant systemAt, String text, int topK) + throws IOException { + validateRetrieval(text, topK); + Map keys = versionKeys(snapshot); + List visible = visible(snapshot, mode, key, validAt, systemAt, keys); + final Map byId = new HashMap<>(); + for (MemoryFactVersion version : visible) { + byId.put(version.getId(), version); + } + Map> documents = documents(visible); + if (profile.isCandidateStrategy()) { + return strategyFacts(documents, visible, byId, keys, key, text, topK); + } + List hits; + try (Bm25Index index = new Bm25Index(documents, profile)) { + hits = index.search(text, Math.max(1, documents.size())); + } + Map rankingScores = new HashMap<>(); + Map hitsById = new HashMap<>(); + for (Bm25Index.Hit hit : hits) { + rankingScores.put(hit.getId(), hit.getScore()); + hitsById.put(hit.getId(), hit); + } + visible.sort(Comparator.comparingDouble((MemoryFactVersion version) -> + rankingScores.getOrDefault(version.getId(), 0.0)).reversed() + .thenComparing(MemoryFactVersion::getId)); + List result = new ArrayList<>(); + for (MemoryFactVersion version : visible) { + if (key.equals(keys.get(version.getId()))) { + Map scores = new TreeMap<>(); + scores.put("RULE", 1.0); + if (hitsById.containsKey(version.getId())) { + scores.putAll(hitsById.get(version.getId()).getChannelScores()); + } + result.add(new FactCandidate(key, version, true, scores, result.size() + 1)); + } + } + int softCount = 0; + for (Bm25Index.Hit hit : hits) { + FactKey candidateKey = keys.get(hit.getId()); + if (!key.equals(candidateKey)) { + result.add(new FactCandidate(candidateKey, byId.get(hit.getId()), false, + hit.getChannelScores(), result.size() + 1)); + if (++softCount == topK) { + break; + } + } + } + return Collections.unmodifiableList(result); + } + + private Map> documents(List versions) { + Map> documents = new TreeMap<>(); + for (MemoryFactVersion version : versions) { + MemoryFact fact = version.getFact(); + Map fields = new TreeMap<>(); + fields.put("subject", fact.getSubject().getId() + " " + fact.getSubject().getLabel()); + fields.put("predicate", fact.getPredicate()); + fields.put("object", fact.isRelationship() + ? fact.getTarget().get().getId() + " " + fact.getTarget().get().getLabel() + : fact.getLiteralValue().get()); + fields.put("evidence", version.getEvidence().stream() + .map(Evidence::getContent).collect(Collectors.joining(" "))); + documents.put(version.getId(), fields); + } + return documents; + } + + private List strategyFacts(Map> documents, + List visible, + Map byId, + Map keys, FactKey key, + String text, int topK) throws IOException { + final Map> channels = new TreeMap<>(); + final int poolSize = Math.max(1, documents.size()); + if (profile.usesBm25()) { + try (Bm25Index index = new Bm25Index(documents, profile)) { + channels.put("BM25", index.search(text, poolSize)); + } + } + if (profile.usesMinHash()) { + channels.put("MINHASH", new MinHashIndex(documents).search(text, poolSize)); + } + TreeSet mandatoryIds = new TreeSet<>(); + for (MemoryFactVersion version : visible) { + if (key.equals(keys.get(version.getId()))) { + mandatoryIds.add(version.getId()); + } + } + List result = new ArrayList<>(); + for (Bm25Index.Hit hit : CandidateMerger.merge(channels, mandatoryIds, topK)) { + result.add(new FactCandidate(keys.get(hit.getId()), byId.get(hit.getId()), + mandatoryIds.contains(hit.getId()), hit.getScore(), hit.getRank(), + hit.getChannelScores(), hit.getChannelRanks())); + } + return Collections.unmodifiableList(result); + } + + /** Evidence and corpus statistics come only from visible versions in key.scope. */ + public List evidence(CanonicalSnapshot snapshot, QueryMode mode, FactKey key, + Instant validAt, Instant systemAt, String text, int topK) + throws IOException { + validateRetrieval(text, topK); + List visible = visible(snapshot, mode, key, validAt, systemAt, + versionKeys(snapshot)); + Map corpus = new TreeMap<>(); + Map> versionIds = new TreeMap<>(); + for (MemoryFactVersion version : visible) { + for (Evidence evidence : version.getEvidence()) { + Evidence previous = corpus.put(evidence.getId(), evidence); + if (previous != null && !previous.equals(evidence)) { + throw new IllegalArgumentException("Evidence ID reused: " + evidence.getId()); + } + versionIds.computeIfAbsent(evidence.getId(), ignored -> new ArrayList<>()) + .add(version.getId()); + } + } + List result = new ArrayList<>(); + try (EvidenceRetriever retriever = new EvidenceRetriever(corpus.values(), profile)) { + for (EvidenceCandidate candidate : retriever.search(text, topK)) { + result.add(new EvidenceCandidate(candidate.getEvidence(), + versionIds.get(candidate.getEvidence().getId()), + candidate.getScore(), candidate.getRank(), candidate.getChannelScores(), + candidate.getChannelRanks())); + } + } + return Collections.unmodifiableList(result); + } + + /** Use the committed prefix before applying the event; events select overlapping intervals. */ + public List eventFacts(CanonicalSnapshot snapshot, NormalizedMemoryEvent event) { + Objects.requireNonNull(snapshot, "snapshot"); + Objects.requireNonNull(event, "event"); + List result = new ArrayList<>(); + List versions = new ArrayList<>(snapshot.getState() + .getVersionsByFactKey().getOrDefault(event.getFactKey(), Collections.emptyList())); + versions.sort(Comparator.comparing(MemoryFactVersion::getId)); + for (MemoryFactVersion version : versions) { + if (version.getStatus() == MemoryFactVersionStatus.ACTIVE + && version.getTransactionTime().contains(event.getRecordedAt()) + && version.getValidTime().overlaps(event.getValidTime())) { + result.add(new FactCandidate(event.getFactKey(), version, true, + Collections.singletonMap("RULE", 1.0), result.size() + 1)); + } + } + return Collections.unmodifiableList(result); + } + + /** Retrieval explains a resolved event against its actual committed prefix only. */ + public List eventFacts(TemporalState state, NormalizedMemoryEvent event, + TimeInterval effectiveInterval, int topK) + throws IOException { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(event, "event"); + Objects.requireNonNull(effectiveInterval, "effectiveInterval"); + if (!profile.isCandidateStrategy()) { + throw new IllegalArgumentException("Event retrieval requires a RULE candidate profile"); + } + String text = eventText(event); + validateRetrieval(text, topK); + final Map keys = new HashMap<>(); + final Map byId = new HashMap<>(); + final List visible = new ArrayList<>(); + for (Map.Entry> entry + : state.getVersionsByFactKey().entrySet()) { + if (!event.getFactKey().getScope().equals(entry.getKey().getScope())) { + continue; + } + for (MemoryFactVersion version : entry.getValue()) { + if (version.getStatus() == MemoryFactVersionStatus.ACTIVE + && version.getTransactionTime().contains(event.getRecordedAt()) + && version.getValidTime().overlaps(effectiveInterval)) { + keys.put(version.getId(), entry.getKey()); + byId.put(version.getId(), version); + visible.add(version); + } + } + } + return strategyFacts(documents(visible), visible, byId, keys, + event.getFactKey(), text, topK); + } + + private static String eventText(NormalizedMemoryEvent event) { + StringBuilder text = new StringBuilder(event.getFactKey().getSubjectId()) + .append(' ').append(event.getFactKey().getPredicate()); + if (event.getEvent().getFact().isPresent()) { + MemoryFact fact = event.getEvent().getFact().get(); + text.append(' ').append(fact.getSubject().getLabel()).append(' ') + .append(fact.isRelationship() + ? fact.getTarget().get().getId() + " " + fact.getTarget().get().getLabel() + : fact.getLiteralValue().get()); + } + for (Evidence evidence : event.getEvidence()) { + text.append(' ').append(evidence.getContent()); + } + return text.toString(); + } + + private List visible(CanonicalSnapshot snapshot, QueryMode mode, FactKey key, + Instant validAt, Instant systemAt, + Map keys) { + Objects.requireNonNull(snapshot, "snapshot"); + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(key, "key"); + if (mode != QueryMode.AS_OF && systemAt != null) { + throw new IllegalArgumentException("systemAt is only valid for AS_OF"); + } + List versions; + switch (mode) { + case CURRENT: + versions = query.current(snapshot.getState(), validAt); + break; + case HISTORICAL: + versions = query.historical(snapshot.getState(), validAt); + break; + case AS_OF: + versions = query.asOf(snapshot.getState(), validAt, systemAt); + break; + default: + throw new IllegalArgumentException("Unsupported query mode: " + mode); + } + return versions.stream() + .filter(version -> key.getScope().equals(keys.get(version.getId()).getScope())) + .collect(Collectors.toList()); + } + + private static Map versionKeys(CanonicalSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + final Map keys = new HashMap<>(); + for (Map.Entry> entry + : snapshot.getState().getVersionsByFactKey().entrySet()) { + for (MemoryFactVersion version : entry.getValue()) { + keys.put(version.getId(), entry.getKey()); + } + } + return keys; + } + + private static void validateRetrieval(String text, int topK) { + Objects.requireNonNull(text, "text"); + if (topK <= 0) { + throw new IllegalArgumentException("topK must be positive"); + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/TextNormalizer.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/TextNormalizer.java new file mode 100644 index 000000000..18a901d93 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/candidate/TextNormalizer.java @@ -0,0 +1,52 @@ +/* + * 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.candidate; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.Objects; + +/** Normalizes retrieval text without changing domain identities or stored values. */ +public final class TextNormalizer { + + private TextNormalizer() { + } + + public static String normalize(String text) { + String canonical = Normalizer.normalize(Objects.requireNonNull(text, "text"), + Normalizer.Form.NFC).toLowerCase(Locale.ROOT); + StringBuilder result = new StringBuilder(); + boolean space = false; + for (int offset = 0; offset < canonical.length();) { + int point = canonical.codePointAt(offset); + offset += Character.charCount(point); + if (Character.isWhitespace(point) || Character.isSpaceChar(point)) { + space = result.length() > 0; + } else { + if (space) { + result.append(' '); + } + result.appendCodePoint(point); + space = false; + } + } + return Normalizer.normalize(result.toString(), Normalizer.Form.NFC); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/cli/TemporalMemoryCli.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/cli/TemporalMemoryCli.java new file mode 100644 index 000000000..33f3d6157 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/cli/TemporalMemoryCli.java @@ -0,0 +1,367 @@ +/* + * 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.cli; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.QueryResult; +import org.apache.geaflow.ai.temporal.api.ReplayResult; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.api.ValidationResult; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; +import org.apache.geaflow.ai.temporal.trace.QueryTrace; + +/** Independent finite-file CLI; each invocation restores its run from disk. */ +public final class TemporalMemoryCli { + + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + private final TemporalRunStore store = new TemporalRunStore(); + + public static void main(String[] args) throws IOException { + PrintStream out = new PrintStream(System.out, true, StandardCharsets.UTF_8.name()); + PrintStream err = new PrintStream(System.err, true, StandardCharsets.UTF_8.name()); + System.exit(new TemporalMemoryCli().run(args, out, err)); + } + + public int run(String[] args, PrintStream out, PrintStream err) { + try { + if (args == null || args.length == 0) { + throw invalid("Expected command: replay, query, candidates, trace, or validate"); + } + JsonObject result; + switch (args[0]) { + case "replay": + result = replay(options(args, "input", "output", "candidate-profile", "candidate-top-k")); + break; + case "query": + result = query(options(args, "run", "mode", "subject", "predicate", "scope", + "valid-at", "system-at", "output")); + break; + case "candidates": + result = candidates(options(args, "run", "mode", "subject", "predicate", + "scope", "valid-at", "system-at", "text", "top-k", "profile")); + break; + case "trace": + result = trace(options(args, "run", "id")); + break; + case "validate": + result = validate(options(args, "run")); + break; + default: + throw invalid("Unknown command: " + args[0]); + } + out.println(codec.toJson(result)); + return "validate".equals(args[0]) && !result.get("valid").getAsBoolean() ? 3 : 0; + } catch (BatchReplayException exception) { + JsonObject failure = error(exception.failure.getCode().name(), + exception.failure.getMessage()); + failure.add("decisionTraces", decisions(exception.traces)); + err.println(codec.toJson(failure)); + return 2; + } catch (InputLineException exception) { + JsonObject failure = error(exception.failure.getCode().name(), + exception.failure.getMessage()); + failure.addProperty("input", exception.input.toString()); + failure.addProperty("line", exception.line); + err.println(codec.toJson(failure)); + return 2; + } catch (TemporalValidationException exception) { + err.println(codec.toJson(error(exception.getCode().name(), exception.getMessage()))); + return 2; + } catch (IllegalArgumentException exception) { + err.println(codec.toJson(error("REJECT_INPUT", exception.getMessage()))); + return 2; + } catch (IOException | RuntimeException exception) { + err.println(codec.toJson(error("REJECT_EXECUTION", exception.getMessage()))); + return 3; + } + } + + private JsonObject replay(Map options) throws IOException { + final RetrievalProfile candidateProfile; + final int candidateTopK; + if (options.containsKey("candidate-profile")) { + try { + candidateProfile = RetrievalProfile.valueOf(options.get("candidate-profile")); + candidateTopK = Integer.parseInt(options.getOrDefault("candidate-top-k", "20")); + } catch (IllegalArgumentException exception) { + throw invalid("candidate-profile must be RULE_ONLY, RULE_BM25, RULE_MINHASH or RULE_ALL; " + + "candidate-top-k must be a positive integer"); + } + if (!candidateProfile.isCandidateStrategy() || candidateTopK <= 0) { + throw invalid("candidate-profile requires a candidate strategy and positive candidate-top-k"); + } + } else { + if (options.containsKey("candidate-top-k")) { + throw invalid("candidate-top-k requires candidate-profile"); + } + candidateProfile = null; + candidateTopK = 20; + } + Path input = Paths.get(required(options, "input")); + Path output = Paths.get(required(options, "output")); + distinct(input, output, "input and output must refer to different files"); + List events = new ArrayList<>(); + try (BufferedReader reader = Files.newBufferedReader(input, StandardCharsets.UTF_8)) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + try { + events.add(codec.decodeEvent(line)); + } catch (TemporalValidationException exception) { + throw new InputLineException(input, lineNumber, exception); + } + } + } + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + ReplayResult replay; + try { + replay = candidateProfile == null ? facade.replay(events) + : facade.replay(events, candidateProfile, candidateTopK); + } catch (TemporalValidationException exception) { + throw new BatchReplayException(exception, facade.getDecisionTraces()); + } + JsonObject result = json(); + result.addProperty("method", replay.getMethod()); + result.addProperty("fallbackStatus", replay.getFallbackStatus()); + JsonObject statistics = new JsonObject(); + for (Map.Entry entry : replay.getStatistics().entrySet()) { + statistics.addProperty(entry.getKey(), entry.getValue()); + } + result.add("statistics", statistics); + result.add("snapshot", codec.snapshotToJson(replay.getSnapshot())); + result.add("decisionTraces", decisions(replay.getDecisionTraces())); + store.save(output, facade); + return result; + } + + private JsonObject query(Map options) throws IOException { + Path run = Paths.get(required(options, "run")); + QueryMode mode; + try { + mode = QueryMode.valueOf(required(options, "mode")); + } catch (IllegalArgumentException exception) { + throw invalid("mode must be CURRENT, HISTORICAL, or AS_OF"); + } + FactKey key = new FactKey(required(options, "subject"), required(options, "predicate"), + required(options, "scope")); + Instant validAt = TemporalRunStore.parseTime(required(options, "valid-at"), "valid-at"); + if ((mode == QueryMode.AS_OF) != options.containsKey("system-at")) { + throw invalid("system-at is required only for AS_OF"); + } + Instant systemAt = mode == QueryMode.AS_OF + ? TemporalRunStore.parseTime(options.get("system-at"), "system-at") : null; + Path output = options.containsKey("output") ? Paths.get(options.get("output")) : null; + if (output != null) { + distinct(run, output, "run and query output must refer to different files"); + } + TemporalMemoryFacade facade = store.load(run); + QueryResult query = facade.query(mode, key, validAt, systemAt); + JsonObject result = json(); + result.addProperty("queryId", query.getTrace().getId()); + result.addProperty("mode", query.getTrace().getMode().name()); + JsonArray versions = new JsonArray(); + for (MemoryFactVersion version : query.getVersions()) { + versions.add(codec.versionToJson(key, version, + facade.getSnapshot().getGeneratingEventIds().get(version.getId()))); + } + result.add("versions", versions); + result.add("trace", query.getTrace().toJson()); + store.save(run, facade); + if (output != null) { + TemporalRunStore.writeAtomic(output, codec.toJson(result)); + } + return result; + } + + private JsonObject trace(Map options) throws IOException { + String id = required(options, "id"); + TemporalMemoryFacade facade = store.load(Paths.get(required(options, "run"))); + JsonObject result = json(); + result.addProperty("id", id); + result.add("decisionTraces", decisions(facade.getTrace(id))); + JsonArray queries = new JsonArray(); + QueryTrace query = facade.getQueryTrace(id); + if (query != null) { + queries.add(query.toJson()); + } + result.add("queryTraces", queries); + return result; + } + + private JsonObject candidates(Map options) throws IOException { + QueryMode mode; + try { + mode = QueryMode.valueOf(required(options, "mode")); + } catch (IllegalArgumentException exception) { + throw invalid("mode must be CURRENT, HISTORICAL, or AS_OF"); + } + final FactKey key = new FactKey(required(options, "subject"), required(options, "predicate"), + required(options, "scope")); + final Instant validAt = TemporalRunStore.parseTime(required(options, "valid-at"), "valid-at"); + if ((mode == QueryMode.AS_OF) != options.containsKey("system-at")) { + throw invalid("system-at is required only for AS_OF"); + } + final Instant systemAt = mode == QueryMode.AS_OF + ? TemporalRunStore.parseTime(options.get("system-at"), "system-at") : null; + final String text = required(options, "text"); + int topK; + try { + topK = Integer.parseInt(options.getOrDefault("top-k", "20")); + } catch (NumberFormatException exception) { + throw invalid("top-k must be a positive integer"); + } + if (topK <= 0) { + throw invalid("top-k must be a positive integer"); + } + RetrievalProfile profile; + try { + profile = RetrievalProfile.valueOf(options.getOrDefault("profile", "BASELINE")); + } catch (IllegalArgumentException exception) { + throw invalid("profile must be BASELINE, ENGLISH, LEXICAL_FUSION, " + + "RULE_ONLY, RULE_BM25, RULE_MINHASH, or RULE_ALL"); + } + TemporalMemoryFacade facade = store.load(Paths.get(required(options, "run"))); + return facade.candidates(mode, key, text, validAt, systemAt, topK, profile).toJson(); + } + + private JsonObject validate(Map options) throws IOException { + TemporalMemoryFacade facade = store.load(Paths.get(required(options, "run"))); + ValidationResult validation = facade.validateInvariants(); + JsonObject result = json(); + result.addProperty("valid", validation.isValid()); + JsonArray violations = new JsonArray(); + for (String violation : validation.getViolations()) { + violations.add(new JsonPrimitive(violation)); + } + result.add("violations", violations); + return result; + } + + private JsonArray decisions(List traces) { + JsonArray result = new JsonArray(); + for (DecisionTrace trace : traces) { + result.add(trace.toJson()); + } + return result; + } + + private static Map options(String[] args, String... allowed) { + Set names = new HashSet<>(Arrays.asList(allowed)); + Map values = new HashMap<>(); + for (int index = 1; index < args.length; index += 2) { + String option = args[index]; + if (option == null || !option.startsWith("--") || !names.contains(option.substring(2))) { + throw invalid("Unknown option: " + option); + } + if (index + 1 >= args.length || args[index + 1] == null + || args[index + 1].trim().isEmpty() || args[index + 1].startsWith("--")) { + throw invalid("Missing value for " + option); + } + if (values.put(option.substring(2), args[index + 1]) != null) { + throw invalid("Duplicate option: " + option); + } + } + return values; + } + + private static String required(Map options, String name) { + String value = options.get(name); + if (value == null) { + throw invalid("Missing required option: --" + name); + } + return value; + } + + private static void distinct(Path first, Path second, String message) throws IOException { + if (first.toAbsolutePath().normalize().equals(second.toAbsolutePath().normalize()) + || (Files.exists(first) && Files.exists(second) && Files.isSameFile(first, second))) { + throw invalid(message); + } + } + + private static JsonObject json() { + JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + return result; + } + + private static JsonObject error(String code, String message) { + JsonObject result = json(); + result.addProperty("code", code); + result.addProperty("message", message); + return result; + } + + private static TemporalValidationException invalid(String message) { + return new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, message); + } + + private static final class InputLineException extends RuntimeException { + + private final Path input; + private final int line; + private final TemporalValidationException failure; + + private InputLineException(Path input, int line, TemporalValidationException failure) { + super(failure); + this.input = input; + this.line = line; + this.failure = failure; + } + } + + private static final class BatchReplayException extends RuntimeException { + + private final TemporalValidationException failure; + private final List traces; + + private BatchReplayException(TemporalValidationException failure, List traces) { + super(failure); + this.failure = failure; + this.traces = traces; + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/cli/TemporalRunStore.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/cli/TemporalRunStore.java new file mode 100644 index 000000000..e5c64dd51 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/cli/TemporalRunStore.java @@ -0,0 +1,281 @@ +/* + * 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.cli; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; +import org.apache.geaflow.ai.temporal.trace.QueryTrace; + +/** Stores a reproducible journal and verifies every persisted result when loading. */ +public final class TemporalRunStore { + + public static final int ARTIFACT_VERSION = 1; + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + + public void save(Path path, TemporalMemoryFacade facade) throws IOException { + writeAtomic(path, codec.toJson(artifact(facade))); + } + + public TemporalMemoryFacade load(Path path) throws IOException { + JsonObject stored = codec.parseObject(new String(Files.readAllBytes(path), + StandardCharsets.UTF_8)); + fields(stored, "run", "artifactVersion", "schemaVersion", "operations", "snapshot", + "decisionTraces", "queryTraces"); + version(stored, "artifactVersion", ARTIFACT_VERSION); + version(stored, "schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + JsonArray operations = array(stored.get("operations"), "operations"); + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + for (JsonElement element : operations) { + replay(facade, object(element, "operation")); + } + if (!stored.equals(artifact(facade))) { + throw invalid("Run journal does not reproduce its snapshot and traces"); + } + return facade; + } + + private JsonObject artifact(TemporalMemoryFacade facade) { + JsonObject value = new JsonObject(); + value.addProperty("artifactVersion", ARTIFACT_VERSION); + value.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + value.add("operations", facade.getOperations()); + value.add("snapshot", codec.snapshotToJson(facade.getSnapshot())); + JsonArray decisions = new JsonArray(); + for (DecisionTrace trace : facade.getDecisionTraces()) { + decisions.add(trace.toJson()); + } + value.add("decisionTraces", decisions); + JsonArray queries = new JsonArray(); + for (QueryTrace trace : facade.getQueryTraces()) { + queries.add(trace.toJson()); + } + value.add("queryTraces", queries); + return value; + } + + private void replay(TemporalMemoryFacade facade, JsonObject operation) { + String method = text(operation, "method"); + if ("QUERY".equals(method)) { + fields(operation, "query operation", "method", "mode", "factKey", "validAt", "systemAt"); + JsonObject key = object(operation.get("factKey"), "factKey"); + fields(key, "factKey", "subjectId", "predicate", "scope"); + QueryMode mode; + try { + mode = QueryMode.valueOf(text(operation, "mode")); + } catch (IllegalArgumentException exception) { + throw invalid("Unknown query mode in run journal"); + } + String systemAt = nullableText(operation, "systemAt"); + facade.query(mode, new FactKey(text(key, "subjectId"), text(key, "predicate"), + text(key, "scope")), parseTime(text(operation, "validAt"), "validAt"), + systemAt == null ? null : parseTime(systemAt, "systemAt")); + return; + } + if (!"REPLAY".equals(method) && !"APPLY_BATCH".equals(method)) { + throw invalid("Unknown run operation: " + method); + } + final boolean decisionRetrieval = operation.has("candidateProfile") || operation.has("candidateTopK"); + final RetrievalProfile candidateProfile; + final int candidateTopK; + if (decisionRetrieval) { + fields(operation, "event operation", "method", "events", "expectedError", + "candidateProfile", "candidateTopK"); + try { + candidateProfile = RetrievalProfile.valueOf(text(operation, "candidateProfile")); + JsonElement topK = operation.get("candidateTopK"); + if (!topK.isJsonPrimitive() || !topK.getAsJsonPrimitive().isNumber()) { + throw invalid("candidateTopK must be a positive integer"); + } + candidateTopK = new BigDecimal(topK.getAsString()).intValueExact(); + } catch (IllegalArgumentException | ArithmeticException exception) { + throw invalid("Invalid decision retrieval parameters in run journal"); + } + if (!candidateProfile.isCandidateStrategy() || candidateTopK <= 0) { + throw invalid("Invalid decision retrieval strategy or topK in run journal"); + } + } else { + fields(operation, "event operation", "method", "events", "expectedError"); + candidateProfile = null; + candidateTopK = 20; + } + List events = new ArrayList<>(); + for (JsonElement element : array(operation.get("events"), "events")) { + events.add(codec.decodeEvent(object(element, "event"))); + } + String expectedError = nullableText(operation, "expectedError"); + if (expectedError != null) { + try { + TemporalErrorCode.valueOf(expectedError); + } catch (IllegalArgumentException exception) { + throw invalid("Unknown expectedError in run journal: " + expectedError); + } + } + String actualError = null; + try { + if ("REPLAY".equals(method)) { + if (decisionRetrieval) { + facade.replay(events, candidateProfile, candidateTopK); + } else { + facade.replay(events); + } + } else { + if (decisionRetrieval) { + facade.applyBatch(events, candidateProfile, candidateTopK); + } else { + facade.applyBatch(events); + } + } + } catch (TemporalValidationException exception) { + actualError = exception.getCode().name(); + } + if (expectedError == null ? actualError != null : !expectedError.equals(actualError)) { + throw invalid("Run operation outcome differs from expectedError: " + method); + } + } + + static void writeAtomic(Path path, String json) throws IOException { + Path target = path.toAbsolutePath().normalize(); + if (Files.exists(target) && !Files.isRegularFile(target)) { + throw new IOException("Output must be a regular file: " + target); + } + Path directory = target.getParent(); + Files.createDirectories(directory); + Path temporary = Files.createTempFile(directory, ".temporal-run-", ".tmp"); + try { + Files.write(temporary, (json + "\n").getBytes(StandardCharsets.UTF_8)); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + static Instant parseTime(String value, String name) { + if (!value.endsWith("Z")) { + throw invalid(name + " must be an ISO-8601 UTC timestamp ending in Z"); + } + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (DateTimeParseException exception) { + throw invalid(name + " is not a valid ISO-8601 UTC timestamp"); + } + } + + private static void fields(JsonObject object, String context, String... allowed) { + Set names = new HashSet<>(Arrays.asList(allowed)); + for (Map.Entry entry : object.entrySet()) { + if (!names.contains(entry.getKey())) { + throw invalid("Unknown " + context + " field: " + entry.getKey()); + } + } + for (String name : allowed) { + if (!object.has(name)) { + throw invalid("Missing " + context + " field: " + name); + } + } + } + + private static JsonObject object(JsonElement value, String name) { + if (value == null || !value.isJsonObject()) { + throw invalid(name + " must be an object"); + } + return value.getAsJsonObject(); + } + + private static JsonArray array(JsonElement value, String name) { + if (value == null || !value.isJsonArray()) { + throw invalid(name + " must be an array"); + } + return value.getAsJsonArray(); + } + + private static String text(JsonObject value, String name) { + String text = nullableText(value, name); + if (text == null) { + throw invalid(name + " must not be null"); + } + return text; + } + + private static String nullableText(JsonObject value, String name) { + JsonElement element = value.get(name); + if (element == null) { + throw invalid("Missing required field: " + name); + } + if (element.isJsonNull()) { + return null; + } + if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString() + || element.getAsString().trim().isEmpty()) { + throw invalid(name + " must be a nonblank string"); + } + return element.getAsString(); + } + + private static void version(JsonObject value, String name, int expected) { + JsonElement element = value.get(name); + if (element != null && element.isJsonPrimitive() + && element.getAsJsonPrimitive().isNumber()) { + try { + if (element.getAsBigDecimal().intValueExact() == expected) { + return; + } + } catch (ArithmeticException exception) { + throw invalid(name + " must be an integer"); + } + } + throw new TemporalValidationException(TemporalErrorCode.REJECT_SCHEMA_VERSION, + "Unsupported " + name + "; expected " + expected); + } + + private static TemporalValidationException invalid(String message) { + return new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, message); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/codec/TemporalJsonCodec.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/codec/TemporalJsonCodec.java new file mode 100644 index 000000000..624834b7b --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/codec/TemporalJsonCodec.java @@ -0,0 +1,480 @@ +/* + * 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.codec; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import java.io.IOException; +import java.io.StringReader; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryEntity; +import org.apache.geaflow.ai.temporal.model.MemoryEvent; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +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.model.VersionRelation; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; + +/** Strict schema version 2 JSON boundary; immutable models use their public constructors. */ +public final class TemporalJsonCodec { + + private static final Gson GSON = new GsonBuilder().serializeNulls() + .disableHtmlEscaping().create(); + private final EventNormalizer normalizer = new EventNormalizer(); + + public NormalizedMemoryEvent decodeEvent(String json) { + return decodeEvent(parseObject(json)); + } + + public NormalizedMemoryEvent decodeEvent(JsonObject json) { + try { + Objects.requireNonNull(json, "event"); + fields(json, "event", "schemaVersion", "id", "operation", "subject", + "predicate", "scope", "factId", "value", "validFrom", "validTo", + "recordedAt", "evidence", "target", "payloadHash"); + schemaVersion(json); + String operation = text(json, "operation"); + if (!Arrays.asList("ASSERT", "CORRECT", "RETRACT").contains(operation)) { + throw invalid("operation must be ASSERT, CORRECT, or RETRACT"); + } + JsonObject subject = object(required(json, "subject"), "subject"); + fields(subject, "subject", "id", "label"); + String subjectId = text(subject, "id"); + String subjectLabel = "RETRACT".equals(operation) + ? nullableText(required(subject, "label"), "subject.label") : text(subject, "label"); + String predicate = text(json, "predicate"); + final FactKey key = new FactKey(subjectId, predicate, text(json, "scope")); + String factId = text(json, "factId"); + JsonElement value = required(json, "value"); + TimeInterval validTime = new TimeInterval(time(required(json, "validFrom"), "validFrom"), + nullableTime(required(json, "validTo"), "validTo")); + Instant recordedAt = time(required(json, "recordedAt"), "recordedAt"); + List evidence = decodeEvidence(required(json, "evidence")); + String target = json.has("target") ? nullableText(json.get("target"), "target") : null; + if ("ASSERT".equals(operation) && target != null) { + throw invalid("ASSERT must not contain a target"); + } + if (!"ASSERT".equals(operation) && target == null) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_TARGET_REQUIRED, + "CORRECT/RETRACT requires an explicit target version ID"); + } + MemoryEvent event; + if ("RETRACT".equals(operation)) { + if (!value.isJsonNull()) { + throw invalid("RETRACT value must be null"); + } + event = MemoryEvent.retract(text(json, "id"), factId, validTime, recordedAt, evidence); + } else { + MemoryFact fact = decodeFact(value, factId, + new MemoryEntity(subjectId, subjectLabel), predicate); + event = "ASSERT".equals(operation) + ? MemoryEvent.add(text(json, "id"), fact, validTime, recordedAt, evidence) + : MemoryEvent.correct(text(json, "id"), fact, validTime, recordedAt, evidence); + } + if (target != null) { + event = event.targeting(target); + } + NormalizedMemoryEvent normalized = normalizer.normalize(event, key); + if (json.has("payloadHash") + && !text(json, "payloadHash").equals(normalized.getPayloadHash())) { + throw invalid("payloadHash does not match the normalized event"); + } + return normalized; + } catch (TemporalValidationException exception) { + throw exception; + } catch (IllegalArgumentException | NullPointerException exception) { + throw invalid(exception.getMessage()); + } + } + + public JsonObject eventToJson(NormalizedMemoryEvent event) { + Objects.requireNonNull(event, "event"); + JsonObject json = new JsonObject(); + json.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + json.addProperty("id", event.getEventId()); + json.addProperty("operation", event.getOperation() == MemoryEventOperation.ADD + ? "ASSERT" : event.getOperation().name()); + MemoryFact fact = event.getEvent().getFact().orElse(null); + json.add("subject", subjectToJson(event.getFactKey().getSubjectId(), + fact == null ? null : fact.getSubject().getLabel())); + json.addProperty("predicate", event.getFactKey().getPredicate()); + json.addProperty("scope", event.getFactKey().getScope()); + json.addProperty("factId", event.getFactId()); + json.add("value", fact == null ? JsonNull.INSTANCE : valueToJson(fact)); + intervalToJson(json, "validFrom", "validTo", event.getValidTime()); + json.addProperty("recordedAt", event.getRecordedAt().toString()); + json.add("evidence", evidenceToJson(event.getEvidence())); + json.addProperty("target", event.getTargetVersionId().orElse(null)); + json.addProperty("payloadHash", event.getPayloadHash()); + return json; + } + + public JsonObject snapshotToJson(CanonicalSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + JsonObject json = new JsonObject(); + json.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + JsonArray events = new JsonArray(); + for (NormalizedMemoryEvent event : snapshot.getEvents()) { + events.add(eventToJson(event)); + } + json.add("events", events); + JsonArray versions = new JsonArray(); + for (Map.Entry> entry + : snapshot.getState().getVersionsByFactKey().entrySet()) { + for (MemoryFactVersion version : entry.getValue()) { + versions.add(versionToJson(entry.getKey(), version, + snapshot.getGeneratingEventIds().get(version.getId()))); + } + } + json.add("versions", versions); + JsonArray relations = new JsonArray(); + for (VersionRelation relation : snapshot.getState().getRelations()) { + relations.add(relationToJson(relation)); + } + json.add("relations", relations); + return json; + } + + public JsonObject versionToJson(FactKey key, MemoryFactVersion version, String eventId) { + JsonObject json = new JsonObject(); + json.addProperty("id", version.getId()); + json.addProperty("eventId", eventId); + json.add("factKey", factKeyToJson(key)); + MemoryFact fact = version.getFact(); + json.add("subject", subjectToJson(fact.getSubject().getId(), fact.getSubject().getLabel())); + json.addProperty("predicate", fact.getPredicate()); + json.addProperty("factId", fact.getId()); + json.add("value", valueToJson(fact)); + json.addProperty("status", version.getStatus().name()); + intervalToJson(json, "validFrom", "validTo", version.getValidTime()); + intervalToJson(json, "systemFrom", "systemTo", version.getTransactionTime()); + json.add("evidence", evidenceToJson(version.getEvidence())); + return json; + } + + public JsonObject factKeyToJson(FactKey key) { + JsonObject json = new JsonObject(); + json.addProperty("subjectId", key.getSubjectId()); + json.addProperty("predicate", key.getPredicate()); + json.addProperty("scope", key.getScope()); + return json; + } + + public JsonObject evidenceToJson(Evidence evidence) { + JsonObject json = new JsonObject(); + json.addProperty("id", evidence.getId()); + JsonObject source = new JsonObject(); + source.addProperty("id", evidence.getSource().getId()); + source.addProperty("name", evidence.getSource().getName()); + json.add("source", source); + json.addProperty("content", evidence.getContent()); + return json; + } + + public JsonObject relationToJson(VersionRelation relation) { + JsonObject json = new JsonObject(); + json.addProperty("type", relation.getType().name()); + json.addProperty("from", relation.getFromVersionId()); + json.addProperty("to", relation.getToVersionId()); + return json; + } + + public String toJson(JsonElement json) { + return GSON.toJson(Objects.requireNonNull(json, "json")); + } + + /** Rejects duplicate keys, trailing content, and Gson's optional lenient syntax. */ + public JsonObject parseObject(String json) { + if (json == null) { + throw invalid("JSON must not be null"); + } + validateLexicalJson(json); + try (JsonReader reader = new JsonReader(new StringReader(json))) { + reader.setLenient(false); + JsonObject object = object(readJson(reader, 0), "JSON"); + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw invalid("Unexpected trailing JSON content"); + } + return object; + } catch (TemporalValidationException exception) { + throw exception; + } catch (IOException | IllegalArgumentException | IllegalStateException exception) { + throw invalid("Malformed JSON: " + exception.getMessage()); + } + } + + // Gson 2.2.4 accepts invalid escapes, raw controls, and mixed-case literals even in strict mode. + private static void validateLexicalJson(String json) { + boolean quoted = false; + for (int index = 0; index < json.length(); index++) { + char value = json.charAt(index); + if (!quoted) { + if (value == '"') { + quoted = true; + } else if (value == 't' || value == 'f' || value == 'n') { + String literal = value == 't' ? "true" : value == 'f' ? "false" : "null"; + if (!json.startsWith(literal, index)) { + throw invalid("JSON literals must be lowercase true, false, or null"); + } + index += literal.length() - 1; + } else if ((value >= 'A' && value <= 'Z' && value != 'E') + || (value >= 'a' && value <= 'z' && value != 'e') + || (value < 0x20 && value != '\t' && value != '\n' && value != '\r')) { + throw invalid("Invalid character outside a JSON string"); + } + } else if (value < 0x20) { + throw invalid("JSON strings must escape control characters"); + } else if (value == '"') { + quoted = false; + } else if (value == '\\') { + if (++index >= json.length()) { + throw invalid("Unterminated JSON escape"); + } + char escape = json.charAt(index); + if (escape == 'u') { + if (index + 4 >= json.length()) { + throw invalid("Unicode escapes require four hexadecimal digits"); + } + for (int digit = 0; digit < 4; digit++) { + char hex = json.charAt(++index); + if (!((hex >= '0' && hex <= '9') || (hex >= 'a' && hex <= 'f') + || (hex >= 'A' && hex <= 'F'))) { + throw invalid("Unicode escapes require four hexadecimal digits"); + } + } + } else if ("\"\\/bfnrt".indexOf(escape) < 0) { + throw invalid("Invalid JSON escape"); + } + } + } + if (quoted) { + throw invalid("Unterminated JSON string"); + } + } + + private static JsonElement readJson(JsonReader reader, int depth) throws IOException { + if (depth > 64) { + throw invalid("JSON nesting exceeds 64 levels"); + } + switch (reader.peek()) { + case BEGIN_OBJECT: + JsonObject object = new JsonObject(); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (object.has(name)) { + throw invalid("Duplicate JSON field: " + name); + } + object.add(name, readJson(reader, depth + 1)); + } + reader.endObject(); + return object; + case BEGIN_ARRAY: + JsonArray array = new JsonArray(); + reader.beginArray(); + while (reader.hasNext()) { + array.add(readJson(reader, depth + 1)); + } + reader.endArray(); + return array; + case STRING: + return new JsonPrimitive(reader.nextString()); + case NUMBER: + return new JsonPrimitive(new BigDecimal(reader.nextString())); + case BOOLEAN: + return new JsonPrimitive(reader.nextBoolean()); + case NULL: + reader.nextNull(); + return JsonNull.INSTANCE; + default: + throw invalid("Expected a JSON value"); + } + } + + private static MemoryFact decodeFact(JsonElement value, String factId, + MemoryEntity subject, String predicate) { + JsonObject json = object(value, "value"); + String kind = text(json, "kind"); + if ("LITERAL".equals(kind)) { + fields(json, "value", "kind", "value"); + return MemoryFact.attribute(factId, subject, predicate, text(json, "value")); + } + if ("ENTITY_REF".equals(kind)) { + fields(json, "value", "kind", "value", "label"); + return MemoryFact.relationship(factId, subject, predicate, + new MemoryEntity(text(json, "value"), text(json, "label"))); + } + throw invalid("value.kind must be LITERAL or ENTITY_REF"); + } + + private static List decodeEvidence(JsonElement element) { + if (!element.isJsonArray()) { + throw invalid("evidence must be an array"); + } + List evidence = new ArrayList<>(); + for (JsonElement item : element.getAsJsonArray()) { + JsonObject json = object(item, "evidence item"); + fields(json, "evidence item", "id", "source", "content"); + JsonObject source = object(required(json, "source"), "source"); + fields(source, "source", "id", "name"); + evidence.add(new Evidence(text(json, "id"), + new Source(text(source, "id"), text(source, "name")), text(json, "content"))); + } + if (evidence.isEmpty()) { + throw invalid("evidence must not be empty"); + } + return evidence; + } + + private JsonArray evidenceToJson(List evidence) { + JsonArray json = new JsonArray(); + for (Evidence item : evidence) { + json.add(evidenceToJson(item)); + } + return json; + } + + private static JsonObject subjectToJson(String id, String label) { + JsonObject json = new JsonObject(); + json.addProperty("id", id); + json.addProperty("label", label); + return json; + } + + private static JsonObject valueToJson(MemoryFact fact) { + JsonObject json = new JsonObject(); + json.addProperty("kind", fact.isRelationship() ? "ENTITY_REF" : "LITERAL"); + json.addProperty("value", fact.isRelationship() + ? fact.getTarget().get().getId() : fact.getLiteralValue().get()); + if (fact.isRelationship()) { + json.addProperty("label", fact.getTarget().get().getLabel()); + } + return json; + } + + private static void intervalToJson(JsonObject json, String startName, String endName, + TimeInterval interval) { + json.addProperty(startName, interval.getStart().toString()); + json.addProperty(endName, interval.getEnd().map(Instant::toString).orElse(null)); + } + + private static void schemaVersion(JsonObject json) { + JsonElement version = required(json, "schemaVersion"); + if (!version.isJsonPrimitive() || !version.getAsJsonPrimitive().isNumber()) { + throw invalid("schemaVersion must be an integer"); + } + try { + if (version.getAsBigDecimal().intValueExact() == NormalizedMemoryEvent.SCHEMA_VERSION) { + return; + } + } catch (ArithmeticException exception) { + throw invalid("schemaVersion must be an integer"); + } + throw new TemporalValidationException(TemporalErrorCode.REJECT_SCHEMA_VERSION, + "Only schemaVersion 2 is supported; replay and rebind legacy targets before migration"); + } + + private static JsonElement required(JsonObject json, String name) { + if (!json.has(name)) { + throw invalid("Missing required field: " + name); + } + return json.get(name); + } + + private static String text(JsonObject json, String name) { + String value = nullableText(required(json, name), name); + if (value == null) { + throw invalid(name + " must not be null"); + } + return value; + } + + private static String nullableText(JsonElement element, String name) { + if (element.isJsonNull()) { + return null; + } + if (!element.isJsonPrimitive() || !element.getAsJsonPrimitive().isString() + || element.getAsString().trim().isEmpty()) { + throw invalid(name + " must be a nonblank string"); + } + return element.getAsString(); + } + + private static JsonObject object(JsonElement element, String name) { + if (element == null || !element.isJsonObject()) { + throw invalid(name + " must be an object"); + } + return element.getAsJsonObject(); + } + + private static Instant nullableTime(JsonElement element, String name) { + return element.isJsonNull() ? null : time(element, name); + } + + private static Instant time(JsonElement element, String name) { + String value = nullableText(element, name); + if (value == null || !value.endsWith("Z")) { + throw invalid(name + " must be an ISO-8601 UTC timestamp ending in Z"); + } + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (DateTimeParseException exception) { + throw invalid(name + " is not a valid ISO-8601 UTC timestamp"); + } + } + + private static void fields(JsonObject json, String context, String... allowed) { + Set names = new HashSet<>(Arrays.asList(allowed)); + for (Map.Entry entry : json.entrySet()) { + if (!names.contains(entry.getKey())) { + throw invalid("Unknown " + context + " field: " + entry.getKey()); + } + } + } + + private static TemporalValidationException invalid(String message) { + return new TemporalValidationException(TemporalErrorCode.REJECT_INPUT, message); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/ConflictResolver.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/ConflictResolver.java new file mode 100644 index 000000000..41e03d254 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/ConflictResolver.java @@ -0,0 +1,403 @@ +/* + * 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.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; + +/** Resolves normalized same-key events with deterministic temporal rules. */ +public final class ConflictResolver { + + private static final Comparator VALID_TIME_ORDER = + Comparator.comparing((MemoryFactVersion version) -> version.getValidTime().getStart()) + .thenComparing(MemoryFactVersion::getId); + + private static final Comparator EVIDENCE_ORDER = + Comparator.comparing(Evidence::getId) + .thenComparing(evidence -> evidence.getSource().getId()) + .thenComparing(evidence -> evidence.getSource().getName()) + .thenComparing(Evidence::getContent); + + /** The input must contain the complete state partition for the event's fact key. */ + public ResolutionDecision resolve(NormalizedMemoryEvent event, + List sameKeyVersions) { + Objects.requireNonNull(event, "event"); + Objects.requireNonNull(sameKeyVersions, "sameKeyVersions"); + if (event.getOperation() == MemoryEventOperation.ADD) { + return resolveAdd(event, sameKeyVersions); + } + if (event.getOperation() == MemoryEventOperation.CORRECT + || event.getOperation() == MemoryEventOperation.RETRACT) { + return resolveChange(event, sameKeyVersions); + } + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + event.getOperation()); + } + + private static ResolutionDecision resolveAdd(NormalizedMemoryEvent event, + List versions) { + final List duplicates = new ArrayList<>(); + TimeInterval validTime = event.getValidTime(); + boolean expanded; + do { + expanded = false; + for (MemoryFactVersion version : versions) { + if (isCurrentActive(version) + && !duplicates.contains(version) + && version.getValidTime().overlaps(validTime) + && hasSameValue(event, version)) { + duplicates.add(version); + validTime = span(validTime, version.getValidTime()); + expanded = true; + } + } + } while (expanded); + Collections.sort(duplicates, VALID_TIME_ORDER); + final List mandatory = activeOverlaps(versions, validTime); + final List conflicts = new ArrayList<>(mandatory); + conflicts.removeAll(duplicates); + final List rules = new ArrayList<>(); + rules.add(duplicates.isEmpty() ? "ASSERT_NEW" : "MERGE_EQUAL_VALUE"); + if (!conflicts.isEmpty()) { + rules.add("COEXIST_CONFLICT"); + } + return new ResolutionDecision(validTime, mandatory, mandatory, rules, + duplicates, conflicts, null); + } + + private static ResolutionDecision resolveChange(NormalizedMemoryEvent event, + List versions) { + final String targetId = event.getTargetVersionId().orElseThrow(() -> + new TemporalValidationException(TemporalErrorCode.REJECT_TARGET_REQUIRED, + "CORRECT/RETRACT requires a target version")); + MemoryFactVersion target = null; + for (MemoryFactVersion candidate : versions) { + if (candidate.getId().equals(targetId)) { + target = candidate; + break; + } + } + if (target == null) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, + "Target is not in this fact key: " + targetId); + } + if (!isCurrentActive(target)) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_TARGET_INACTIVE, + "Target is not current and active: " + targetId); + } + if (!target.getValidTime().overlaps(event.getValidTime())) { + throw new TemporalValidationException(TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, + "Target and requested valid intervals do not overlap"); + } + TimeInterval intersection = target.getValidTime() + .intersection(event.getValidTime()).get(); + return new ResolutionDecision(intersection, activeOverlaps(versions, intersection), + Collections.singletonList(target), Collections.singletonList( + event.getOperation() == MemoryEventOperation.CORRECT + ? "CORRECT_TARGET" : "RETRACT_TARGET"), + Collections.emptyList(), Collections.emptyList(), target); + } + + private static List activeOverlaps(List versions, + TimeInterval interval) { + final List result = new ArrayList<>(); + for (MemoryFactVersion version : versions) { + if (isCurrentActive(version) && version.getValidTime().overlaps(interval)) { + result.add(version); + } + } + Collections.sort(result, VALID_TIME_ORDER); + return result; + } + + public void apply( + NormalizedMemoryEvent event, + List versions, + List relations, + Map generatingEventIds) { + final ResolutionDecision decision = resolve(event, versions); + final MemoryEventOperation operation = event.getOperation(); + if (operation == MemoryEventOperation.ADD) { + applyNormalizedAdd(event, decision, versions, relations, generatingEventIds); + } else if (operation == MemoryEventOperation.CORRECT + || operation == MemoryEventOperation.RETRACT) { + applyNormalizedChange(event, decision, versions, relations, generatingEventIds); + } else { + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + + operation); + } + } + + private static void applyNormalizedAdd( + NormalizedMemoryEvent event, + ResolutionDecision decision, + List versions, + List relations, + Map generatingEventIds) { + final List duplicates = decision.getDuplicateVersions(); + TimeInterval validTime = decision.getEffectiveValidTime(); + + final List evidence = new ArrayList<>(); + mergeEvidence(evidence, event.getEvidence()); + final Map materializedDuplicates = new HashMap<>(); + for (MemoryFactVersion duplicate : duplicates) { + versions.remove(duplicate); + boolean materialized = materializeClosedVersion( + duplicate, + event.getRecordedAt(), + versions, + relations, + generatingEventIds); + materializedDuplicates.put(duplicate.getId(), materialized); + mergeEvidence(evidence, duplicate.getEvidence()); + } + + MemoryFactVersion added = new MemoryFactVersion( + VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, validTime, null), + event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + validTime, + TimeInterval.unboundedFrom(event.getRecordedAt()), + evidence); + versions.add(added); + generatingEventIds.put(added.getId(), event.getEventId()); + + for (MemoryFactVersion duplicate : duplicates) { + if (Boolean.TRUE.equals( + materializedDuplicates.get(duplicate.getId()))) { + addRelation( + relations, + new VersionRelation( + VersionRelationType.DUPLICATE_OF, + added.getId(), + duplicate.getId())); + } + } + + for (MemoryFactVersion version : decision.getConflictVersions()) { + addRelation(relations, new VersionRelation(VersionRelationType.CONFLICTS_WITH, + added.getId(), version.getId())); + } + } + + private static void applyNormalizedChange( + NormalizedMemoryEvent event, + ResolutionDecision decision, + List versions, + List relations, + Map generatingEventIds) { + final String targetId = decision.getTargetVersionId(); + final MemoryFactVersion target = decision.getTargetVersion(); + final TimeInterval intersection = decision.getEffectiveValidTime(); + versions.remove(target); + final Map materialized = new HashMap<>(); + materialized.put(targetId, materializeClosedVersion(target, + event.getRecordedAt(), versions, relations, generatingEventIds)); + boolean correcting = event.getOperation() == MemoryEventOperation.CORRECT; + final MemoryFact replacementFact = correcting + ? event.getEvent().getFact().get() : target.getFact(); + final MemoryFactVersionStatus replacementStatus = correcting + ? MemoryFactVersionStatus.ACTIVE : MemoryFactVersionStatus.RETRACTED; + MemoryFactVersion replacement = new MemoryFactVersion( + VersionIdentity.create(event, replacementFact, replacementStatus, + intersection, targetId), + replacementFact, replacementStatus, intersection, + TimeInterval.unboundedFrom(event.getRecordedAt()), event.getEvidence()); + versions.add(replacement); + generatingEventIds.put(replacement.getId(), event.getEventId()); + addSupersedesIfMaterialized(replacement, target, materialized, relations); + for (TimeInterval remainder : target.getValidTime().subtract(intersection)) { + MemoryFactVersion residual = new MemoryFactVersion( + VersionIdentity.create(event, target.getFact(), + MemoryFactVersionStatus.ACTIVE, remainder, targetId), + target.getFact(), MemoryFactVersionStatus.ACTIVE, remainder, + TimeInterval.unboundedFrom(event.getRecordedAt()), target.getEvidence()); + versions.add(residual); + generatingEventIds.put(residual.getId(), event.getEventId()); + addSupersedesIfMaterialized(residual, target, materialized, relations); + } + addCurrentConflicts(versions, relations); + } + + private static void addCurrentConflicts( + List versions, + List relations) { + for (int leftIndex = 0; + leftIndex < versions.size(); leftIndex++) { + MemoryFactVersion left = versions.get(leftIndex); + if (!isCurrentActive(left)) { + continue; + } + for (int rightIndex = leftIndex + 1; + rightIndex < versions.size(); rightIndex++) { + MemoryFactVersion right = versions.get(rightIndex); + if (isCurrentActive(right) + && left.getValidTime().overlaps( + right.getValidTime()) + && !factValue(left.getFact()).equals( + factValue(right.getFact()))) { + addRelation( + relations, + new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + left.getId(), + right.getId())); + } + } + } + } + + private static boolean materializeClosedVersion( + MemoryFactVersion version, + Instant recordedAt, + List versions, + List relations, + Map generatingEventIds) { + if (!version.getTransactionTime().getStart() + .isBefore(recordedAt)) { + removeRelationsFor(version.getId(), relations); + generatingEventIds.remove(version.getId()); + return false; + } + + versions.add(new MemoryFactVersion( + version.getId(), + version.getFact(), + version.getStatus(), + version.getValidTime(), + new TimeInterval( + version.getTransactionTime().getStart(), + recordedAt), + version.getEvidence())); + return true; + } + + private static void addSupersedesIfMaterialized( + MemoryFactVersion replacement, + MemoryFactVersion source, + Map materializedSources, + List relations) { + if (Boolean.TRUE.equals( + materializedSources.get(source.getId()))) { + addRelation( + relations, + new VersionRelation( + VersionRelationType.SUPERSEDES, + replacement.getId(), + source.getId())); + } + } + + private static void addRelation( + List relations, + VersionRelation relation) { + if (!relations.contains(relation)) { + relations.add(relation); + } + } + + private static void removeRelationsFor( + String versionId, + List relations) { + for (int index = relations.size() - 1; index >= 0; index--) { + VersionRelation relation = relations.get(index); + if (relation.getFromVersionId().equals(versionId) + || relation.getToVersionId().equals(versionId)) { + relations.remove(index); + } + } + } + + private static boolean hasSameValue( + NormalizedMemoryEvent event, + MemoryFactVersion version) { + final Optional eventValue = event.getFactValue(); + return eventValue.isPresent() + && eventValue.get().equals(factValue(version.getFact())); + } + + private static FactValue factValue(MemoryFact fact) { + if (fact.isRelationship()) { + return FactValue.entityReference( + fact.getTarget().get().getId()); + } + return FactValue.literal(fact.getLiteralValue().get()); + } + + private static TimeInterval span( + TimeInterval left, + TimeInterval right) { + Instant start = left.getStart().isBefore(right.getStart()) + ? left.getStart() : right.getStart(); + Instant end; + if (!left.getEnd().isPresent() + || !right.getEnd().isPresent()) { + end = null; + } else { + Instant leftEnd = left.getEnd().get(); + Instant rightEnd = right.getEnd().get(); + end = leftEnd.isAfter(rightEnd) ? leftEnd : rightEnd; + } + return new TimeInterval(start, end); + } + + private static void mergeEvidence( + List target, + List additions) { + for (Evidence evidence : additions) { + if (!target.contains(evidence)) { + target.add(evidence); + } + } + Collections.sort(target, EVIDENCE_ORDER); + } + + private static boolean isCurrentActive( + MemoryFactVersion version) { + return version.getStatus() == MemoryFactVersionStatus.ACTIVE + && isCurrent(version); + } + + private static boolean isCurrent(MemoryFactVersion version) { + return !version.getTransactionTime().getEnd().isPresent(); + } +} 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..612f11761 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegrator.java @@ -0,0 +1,482 @@ +/* + * 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 java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.FactKey; +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; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedger; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; + +/** + * Incrementally integrates legacy events by fact id and normalized events by + * fact key. + */ +public final class IncrementalTemporalIntegrator { + + private static final Comparator EVENT_ORDER = + Comparator.comparing(MemoryEvent::getTransactionTime) + .thenComparing(MemoryEvent::getId); + + private static final Comparator + NORMALIZED_EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + 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<>(); + + private EventLedger normalizedEventLedger = new EventLedger(); + private Map> + normalizedEventsByFactKey = new HashMap<>(); + private Map> + normalizedVersionsByFactKey = new HashMap<>(); + private Map> + normalizedRelationsByFactKey = new HashMap<>(); + private Map> + normalizedGeneratingEventIdsByFactKey = 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)); + } + + /** + * Applies an already normalized event to the state for its fact key. + */ + public void apply(NormalizedMemoryEvent event) { + Objects.requireNonNull(event, "event"); + + EventLedgerDecision decision = normalizedEventLedger.check(event); + if (decision == EventLedgerDecision.DUPLICATE_NOOP) { + return; + } + if (decision == EventLedgerDecision.REJECT_EVENT_ID_REUSE) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_EVENT_ID_REUSE, + "Event id reused with a different payload: " + + event.getEventId()); + } + + FactKey factKey = event.getFactKey(); + List existingEvents = + normalizedEventsByFactKey.get(factKey); + List updatedEvents = + existingEvents == null + ? new ArrayList<>() + : new ArrayList<>(existingEvents); + boolean appended = existingEvents == null + || NORMALIZED_EVENT_ORDER.compare( + existingEvents.get(existingEvents.size() - 1), + event) < 0; + updatedEvents.add(event); + Collections.sort(updatedEvents, NORMALIZED_EVENT_ORDER); + + List updatedVersions; + List updatedRelations; + Map updatedGeneratingEventIds; + if (appended) { + List existingVersions = + normalizedVersionsByFactKey.get(factKey); + List existingRelations = + normalizedRelationsByFactKey.get(factKey); + updatedVersions = existingVersions == null + ? new ArrayList<>() + : new ArrayList<>(existingVersions); + updatedRelations = existingRelations == null + ? new ArrayList<>() + : new ArrayList<>(existingRelations); + updatedGeneratingEventIds = new HashMap<>( + normalizedGeneratingEventIdsByFactKey.getOrDefault( + factKey, Collections.emptyMap())); + applyNormalizedOrderedEvent( + event, + updatedVersions, + updatedRelations, + updatedGeneratingEventIds); + } else { + updatedVersions = new ArrayList<>(); + updatedRelations = new ArrayList<>(); + updatedGeneratingEventIds = new HashMap<>(); + for (NormalizedMemoryEvent orderedEvent : updatedEvents) { + applyNormalizedOrderedEvent( + orderedEvent, + updatedVersions, + updatedRelations, + updatedGeneratingEventIds); + } + } + + Collections.sort(updatedVersions, VERSION_ORDER); + Collections.sort(updatedRelations); + normalizedEventLedger.commit(event); + normalizedEventsByFactKey.put( + factKey, + Collections.unmodifiableList(updatedEvents)); + normalizedVersionsByFactKey.put( + factKey, + Collections.unmodifiableList(updatedVersions)); + normalizedRelationsByFactKey.put( + factKey, + Collections.unmodifiableList(updatedRelations)); + normalizedGeneratingEventIdsByFactKey.put( + factKey, Collections.unmodifiableMap(updatedGeneratingEventIds)); + } + + /** Applies a finite batch in event order, publishing only after all events succeed. */ + public List applyBatch(List events) { + Objects.requireNonNull(events, "events"); + List ordered = new ArrayList<>(events); + for (NormalizedMemoryEvent event : ordered) { + Objects.requireNonNull(event, "event"); + } + Collections.sort(ordered, NORMALIZED_EVENT_ORDER); + IncrementalTemporalIntegrator staged = new IncrementalTemporalIntegrator(); + staged.normalizedEventLedger = new EventLedger(normalizedEventLedger); + staged.normalizedEventsByFactKey.putAll(normalizedEventsByFactKey); + staged.normalizedVersionsByFactKey.putAll(normalizedVersionsByFactKey); + staged.normalizedRelationsByFactKey.putAll(normalizedRelationsByFactKey); + staged.normalizedGeneratingEventIdsByFactKey.putAll( + normalizedGeneratingEventIdsByFactKey); + List decisions = new ArrayList<>(); + Map> updatedTimelines = new TreeMap<>(); + for (NormalizedMemoryEvent event : ordered) { + EventLedgerDecision decision = staged.normalizedEventLedger.commit(event); + decisions.add(decision); + if (decision == EventLedgerDecision.ACCEPTED) { + updatedTimelines.computeIfAbsent(event.getFactKey(), key -> + new ArrayList<>(normalizedEventsByFactKey.getOrDefault( + key, Collections.emptyList()))).add(event); + } + } + // A batch prefix can invalidate an old target that the complete batch preserves. + List replayEvents = new ArrayList<>(); + for (List timeline : updatedTimelines.values()) { + Collections.sort(timeline, NORMALIZED_EVENT_ORDER); + replayEvents.addAll(timeline); + } + Collections.sort(replayEvents, NORMALIZED_EVENT_ORDER); + Map> replayVersions = new HashMap<>(); + Map> replayRelations = new HashMap<>(); + Map> replayGeneratingEventIds = new HashMap<>(); + for (NormalizedMemoryEvent event : replayEvents) { + applyNormalizedOrderedEvent(event, + replayVersions.computeIfAbsent(event.getFactKey(), key -> new ArrayList<>()), + replayRelations.computeIfAbsent(event.getFactKey(), key -> new ArrayList<>()), + replayGeneratingEventIds.computeIfAbsent(event.getFactKey(), key -> new HashMap<>())); + } + for (Map.Entry> entry + : updatedTimelines.entrySet()) { + List timeline = entry.getValue(); + List versions = replayVersions.get(entry.getKey()); + List relations = replayRelations.get(entry.getKey()); + Collections.sort(versions, VERSION_ORDER); + Collections.sort(relations); + staged.normalizedEventsByFactKey.put(entry.getKey(), + Collections.unmodifiableList(timeline)); + staged.normalizedVersionsByFactKey.put(entry.getKey(), + Collections.unmodifiableList(versions)); + staged.normalizedRelationsByFactKey.put(entry.getKey(), + Collections.unmodifiableList(relations)); + staged.normalizedGeneratingEventIdsByFactKey.put(entry.getKey(), + Collections.unmodifiableMap(replayGeneratingEventIds.get(entry.getKey()))); + } + normalizedEventLedger = staged.normalizedEventLedger; + normalizedEventsByFactKey = staged.normalizedEventsByFactKey; + normalizedVersionsByFactKey = staged.normalizedVersionsByFactKey; + normalizedRelationsByFactKey = staged.normalizedRelationsByFactKey; + normalizedGeneratingEventIdsByFactKey = staged.normalizedGeneratingEventIdsByFactKey; + return Collections.unmodifiableList(decisions); + } + + public List normalizedEventSnapshot() { + List events = new ArrayList<>(); + for (List timeline : normalizedEventsByFactKey.values()) { + events.addAll(timeline); + } + Collections.sort(events, NORMALIZED_EVENT_ORDER); + return Collections.unmodifiableList(events); + } + + /** Returns normalized state, accepted events and explicit version provenance. */ + public CanonicalSnapshot canonicalSnapshot() { + Map generatingEventIds = new TreeMap<>(); + for (Map keyGeneratingEventIds + : normalizedGeneratingEventIdsByFactKey.values()) { + generatingEventIds.putAll(keyGeneratingEventIds); + } + return new CanonicalSnapshot( + stateSnapshot(), normalizedEventSnapshot(), generatingEventIds); + } + + public List snapshot() { + List snapshot = new ArrayList<>(); + for (List versions : + versionsByFactId.values()) { + snapshot.addAll(versions); + } + + Collections.sort(snapshot, VERSION_ORDER); + return Collections.unmodifiableList(snapshot); + } + + /** + * Returns an immutable, deterministic snapshot of normalized state. + */ + public TemporalState stateSnapshot() { + Map> versionsByFactKey = + new TreeMap<>(); + versionsByFactKey.putAll(normalizedVersionsByFactKey); + + List relations = new ArrayList<>(); + for (List keyRelations : + normalizedRelationsByFactKey.values()) { + relations.addAll(keyRelations); + } + Collections.sort(relations); + return new TemporalState(versionsByFactKey, relations); + } + + List eventSnapshot() { + List snapshot = + new ArrayList<>(eventsById.values()); + Collections.sort(snapshot, EVENT_ORDER); + return Collections.unmodifiableList(snapshot); + } + + private static void applyNormalizedOrderedEvent( + NormalizedMemoryEvent event, + List versions, + List relations, + Map generatingEventIds) { + new ConflictResolver().apply(event, versions, relations, generatingEventIds); + } + + 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/main/java/org/apache/geaflow/ai/temporal/integration/ResolutionDecision.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/ResolutionDecision.java new file mode 100644 index 000000000..d9d32028d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/integration/ResolutionDecision.java @@ -0,0 +1,93 @@ +/* + * 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.List; +import java.util.Objects; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.TimeInterval; + +/** The rule inputs and selected target used by one normalized transition. */ +public final class ResolutionDecision { + + private final TimeInterval effectiveValidTime; + private final List mandatoryVersionIds; + private final List readVersionIds; + private final List rules; + private final List duplicateVersions; + private final List conflictVersions; + private final MemoryFactVersion targetVersion; + + ResolutionDecision(TimeInterval effectiveValidTime, List mandatoryVersions, + List readVersions, List rules, + List duplicateVersions, + List conflictVersions, MemoryFactVersion targetVersion) { + this.effectiveValidTime = Objects.requireNonNull(effectiveValidTime, "effectiveValidTime"); + this.mandatoryVersionIds = ids(mandatoryVersions); + this.readVersionIds = ids(readVersions); + this.rules = Collections.unmodifiableList(new ArrayList<>(rules)); + this.duplicateVersions = Collections.unmodifiableList(new ArrayList<>(duplicateVersions)); + this.conflictVersions = Collections.unmodifiableList(new ArrayList<>(conflictVersions)); + this.targetVersion = targetVersion; + } + + public TimeInterval getEffectiveValidTime() { + return effectiveValidTime; + } + + public List getMandatoryVersionIds() { + return mandatoryVersionIds; + } + + public List getReadVersionIds() { + return readVersionIds; + } + + public List getRules() { + return rules; + } + + public String getTargetVersionId() { + return targetVersion == null ? null : targetVersion.getId(); + } + + List getDuplicateVersions() { + return duplicateVersions; + } + + List getConflictVersions() { + return conflictVersions; + } + + MemoryFactVersion getTargetVersion() { + return targetVersion; + } + + private static List ids(List versions) { + List result = new ArrayList<>(); + for (MemoryFactVersion version : versions) { + result.add(version.getId()); + } + Collections.sort(result); + return Collections.unmodifiableList(result); + } +} 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/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/FactKey.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/FactKey.java new file mode 100644 index 000000000..b74621b37 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/FactKey.java @@ -0,0 +1,96 @@ +/* + * 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 for a fact independent of its value. + */ +public final class FactKey implements Comparable { + + private final String subjectId; + private final String predicate; + private final String scope; + + public FactKey( + String subjectId, + String predicate, + String scope) { + this.subjectId = requireText(subjectId, "subjectId"); + this.predicate = requireText(predicate, "predicate"); + this.scope = requireText(scope, "scope"); + } + + public String getSubjectId() { + return subjectId; + } + + public String getPredicate() { + return predicate; + } + + public String getScope() { + return scope; + } + + @Override + public int compareTo(FactKey that) { + int comparison = subjectId.compareTo(that.subjectId); + if (comparison != 0) { + return comparison; + } + comparison = predicate.compareTo(that.predicate); + if (comparison != 0) { + return comparison; + } + return scope.compareTo(that.scope); + } + + private static String requireText( + String value, + String fieldName) { + Objects.requireNonNull(value, fieldName); + if (value.trim().isEmpty()) { + throw new IllegalArgumentException( + "Fact key " + fieldName + " must not be blank"); + } + return value; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof FactKey)) { + return false; + } + FactKey that = (FactKey) object; + return subjectId.equals(that.subjectId) + && predicate.equals(that.predicate) + && scope.equals(that.scope); + } + + @Override + public int hashCode() { + return Objects.hash(subjectId, predicate, scope); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/FactValue.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/FactValue.java new file mode 100644 index 000000000..5905f82ea --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/FactValue.java @@ -0,0 +1,101 @@ +/* + * 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 literal or entity-reference fact value. + */ +public final class FactValue implements Comparable { + + public enum Kind { + LITERAL, + ENTITY_REF + } + + private final Kind kind; + private final String value; + + private FactValue(Kind kind, String value) { + this.kind = Objects.requireNonNull(kind, "kind"); + this.value = requireText(value); + } + + public static FactValue literal(String value) { + return new FactValue(Kind.LITERAL, value); + } + + public static FactValue entityReference(String entityId) { + return new FactValue(Kind.ENTITY_REF, entityId); + } + + public Kind getKind() { + return kind; + } + + public String getValue() { + return value; + } + + public Optional getLiteralValue() { + return kind == Kind.LITERAL + ? Optional.of(value) : Optional.empty(); + } + + public Optional getEntityId() { + return kind == Kind.ENTITY_REF + ? Optional.of(value) : Optional.empty(); + } + + @Override + public int compareTo(FactValue that) { + int comparison = kind.compareTo(that.kind); + return comparison != 0 + ? comparison : value.compareTo(that.value); + } + + private static String requireText(String value) { + Objects.requireNonNull(value, "value"); + if (value.trim().isEmpty()) { + throw new IllegalArgumentException( + "Fact value must not be blank"); + } + return value; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof FactValue)) { + return false; + } + FactValue that = (FactValue) object; + return kind == that.kind && value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hash(kind, value); + } +} 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/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..ff075ed7f --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryEvent.java @@ -0,0 +1,223 @@ +/* + * 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 final String targetVersionId; + + private MemoryEvent( + String id, + MemoryEventOperation operation, + String factId, + MemoryFact fact, + TimeInterval validTime, + Instant transactionTime, + List evidence) { + this(id, operation, factId, fact, validTime, transactionTime, + evidence, null); + } + + private MemoryEvent( + String id, + MemoryEventOperation operation, + String factId, + MemoryFact fact, + TimeInterval validTime, + Instant transactionTime, + List evidence, + String targetVersionId) { + 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); + this.targetVersionId = targetVersionId; + } + + 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 MemoryEvent targeting(String versionId) { + if (operation == MemoryEventOperation.ADD) { + throw new IllegalArgumentException("An ADD event cannot have a target"); + } + return new MemoryEvent(id, operation, factId, fact, validTime, + transactionTime, evidence, requireText(versionId, "target version id")); + } + + public Optional getTargetVersionId() { + return Optional.ofNullable(targetVersionId); + } + + 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) + && Objects.equals(targetVersionId, that.targetVersionId) + && evidence.equals(that.evidence); + } + + @Override + public int hashCode() { + return Objects.hash( + id, + operation, + factId, + fact, + validTime, + transactionTime, + targetVersionId, + 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/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/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..a52de766f --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersion.java @@ -0,0 +1,140 @@ +/* + * 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 MemoryFactVersionStatus status; + 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, + fact, + MemoryFactVersionStatus.ACTIVE, + validTime, + transactionTime, + evidence); + } + + public MemoryFactVersion( + String id, + MemoryFact fact, + MemoryFactVersionStatus status, + TimeInterval validTime, + TimeInterval transactionTime, + List evidence) { + this.id = requireText(id); + this.fact = Objects.requireNonNull(fact, "fact"); + this.status = Objects.requireNonNull(status, "status"); + 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 MemoryFactVersionStatus getStatus() { + return status; + } + + 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) + && status == that.status + && validTime.equals(that.validTime) + && transactionTime.equals(that.transactionTime) + && evidence.equals(that.evidence); + } + + @Override + public int hashCode() { + return Objects.hash( + id, + fact, + status, + validTime, + transactionTime, + evidence); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionStatus.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionStatus.java new file mode 100644 index 000000000..8e2e2e85d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionStatus.java @@ -0,0 +1,25 @@ +/* + * 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; + +public enum MemoryFactVersionStatus { + ACTIVE, + RETRACTED +} 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/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/main/java/org/apache/geaflow/ai/temporal/model/VersionRelation.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/VersionRelation.java new file mode 100644 index 000000000..bf09076cb --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/VersionRelation.java @@ -0,0 +1,112 @@ +/* + * 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 directed relation between two materialized versions. + */ +public final class VersionRelation + implements Comparable { + + private final VersionRelationType type; + private final String fromVersionId; + private final String toVersionId; + + public VersionRelation( + VersionRelationType type, + String fromVersionId, + String toVersionId) { + this.type = Objects.requireNonNull(type, "type"); + String from = requireText( + fromVersionId, + "fromVersionId"); + String to = requireText(toVersionId, "toVersionId"); + if (from.equals(to)) { + throw new IllegalArgumentException( + "Version relation must not be a self relation"); + } + if (type == VersionRelationType.CONFLICTS_WITH + && from.compareTo(to) > 0) { + this.fromVersionId = to; + this.toVersionId = from; + } else { + this.fromVersionId = from; + this.toVersionId = to; + } + } + + public VersionRelationType getType() { + return type; + } + + public String getFromVersionId() { + return fromVersionId; + } + + public String getToVersionId() { + return toVersionId; + } + + @Override + public int compareTo(VersionRelation that) { + int comparison = type.compareTo(that.type); + if (comparison != 0) { + return comparison; + } + comparison = fromVersionId.compareTo(that.fromVersionId); + if (comparison != 0) { + return comparison; + } + return toVersionId.compareTo(that.toVersionId); + } + + private static String requireText( + String value, + String fieldName) { + Objects.requireNonNull(value, fieldName); + if (value.trim().isEmpty()) { + throw new IllegalArgumentException( + "Version relation " + fieldName + + " must not be blank"); + } + return value; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof VersionRelation)) { + return false; + } + VersionRelation that = (VersionRelation) object; + return type == that.type + && fromVersionId.equals(that.fromVersionId) + && toVersionId.equals(that.toVersionId); + } + + @Override + public int hashCode() { + return Objects.hash(type, fromVersionId, toVersionId); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/VersionRelationType.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/VersionRelationType.java new file mode 100644 index 000000000..ab4ebbec6 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/model/VersionRelationType.java @@ -0,0 +1,26 @@ +/* + * 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; + +public enum VersionRelationType { + SUPERSEDES, + DUPLICATE_OF, + CONFLICTS_WITH +} 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..3f7e1c8da --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracle.java @@ -0,0 +1,616 @@ +/* + * 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.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.adapter.MemoryGraphAdapter; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEvent; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedger; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; + +/** + * Recomputes temporal memory versions from a complete event collection. + */ +public final class FullReplayOracle implements ReplayMethod { + + private static final Comparator EVENT_ORDER = + Comparator.comparing(MemoryEvent::getTransactionTime) + .thenComparing(MemoryEvent::getId); + + private static final Comparator + NORMALIZED_EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + 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 EVIDENCE_ORDER = + Comparator.comparing(Evidence::getId) + .thenComparing(evidence -> evidence.getSource().getId()) + .thenComparing(evidence -> evidence.getSource().getName()) + .thenComparing(Evidence::getContent); + + 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"); + + 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<>(); + for (MemoryEvent event : orderedEvents) { + MemoryEventOperation operation = event.getOperation(); + if (operation == MemoryEventOperation.ADD) { + replayAdd(event, versions); + } else if (operation == MemoryEventOperation.CORRECT + || operation == MemoryEventOperation.RETRACT) { + replayChange(event, versions); + } else { + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + + operation); + } + } + + Collections.sort(versions, VERSION_ORDER); + return Collections.unmodifiableList(versions); + } + + /** + * Recomputes canonical temporal state from normalized events. + */ + public TemporalState replayNormalized( + List events) { + return replayNormalized(events, new HashMap<>()); + } + + /** Returns the formal oracle result after writing and reading MemoryGraph. */ + @Override + public CanonicalSnapshot replayToSnapshot( + List events) { + Map generatingEventIds = new HashMap<>(); + TemporalState state = replayNormalized(events, generatingEventIds); + CanonicalSnapshot snapshot = new CanonicalSnapshot( + state, events, generatingEventIds); + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + return adapter.fromGraph(adapter.toGraph(snapshot)); + } + + private TemporalState replayNormalized( + List events, + Map generatingEventIds) { + Objects.requireNonNull(events, "events"); + + List orderedEvents = + new ArrayList<>(events.size()); + for (NormalizedMemoryEvent event : events) { + orderedEvents.add(Objects.requireNonNull(event, "event")); + } + Collections.sort(orderedEvents, NORMALIZED_EVENT_ORDER); + + EventLedger validationLedger = new EventLedger(); + for (NormalizedMemoryEvent event : orderedEvents) { + validationLedger.commit(event); + } + EventLedger ledger = new EventLedger(); + Map> versionsByFactKey = + new HashMap<>(); + Set relations = new TreeSet<>(); + + for (NormalizedMemoryEvent event : orderedEvents) { + EventLedgerDecision decision = ledger.check(event); + if (decision == EventLedgerDecision.DUPLICATE_NOOP) { + continue; + } + if (decision + == EventLedgerDecision.REJECT_EVENT_ID_REUSE) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_EVENT_ID_REUSE, + "Event id reused with a different payload: " + + event.getEventId()); + } + + List nextVersions = + new ArrayList<>(versionsByFactKey.getOrDefault( + event.getFactKey(), + Collections.emptyList())); + Set nextRelations = + new TreeSet<>(relations); + replayNormalizedEvent( + event, + nextVersions, + nextRelations, + generatingEventIds); + + ledger.commit(event); + versionsByFactKey.put( + event.getFactKey(), + nextVersions); + relations = nextRelations; + } + + for (List versions + : versionsByFactKey.values()) { + Collections.sort(versions, VERSION_ORDER); + } + return new TemporalState( + versionsByFactKey, + new ArrayList<>(relations)); + } + + private static void replayNormalizedEvent( + NormalizedMemoryEvent event, + List versions, + Set relations, + Map generatingEventIds) { + if (event.getOperation() == MemoryEventOperation.ADD) { + replayNormalizedAdd(event, versions, relations, generatingEventIds); + return; + } + if (event.getOperation() == MemoryEventOperation.CORRECT + || event.getOperation() == MemoryEventOperation.RETRACT) { + replayNormalizedChange(event, versions, relations, generatingEventIds); + return; + } + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + + event.getOperation()); + } + + private static void replayNormalizedAdd( + NormalizedMemoryEvent event, + List versions, + Set relations, + Map generatingEventIds) { + FactValue newValue = event.getFactValue().get(); + TimeInterval mergedValidTime = event.getValidTime(); + List duplicates = new ArrayList<>(); + + boolean expanded; + do { + expanded = false; + for (MemoryFactVersion version : versions) { + if (isCurrentActive(version) + && !duplicates.contains(version) + && factValue(version.getFact()).equals(newValue) + && version.getValidTime().overlaps( + mergedValidTime)) { + duplicates.add(version); + mergedValidTime = span( + mergedValidTime, + version.getValidTime()); + expanded = true; + } + } + } while (expanded); + Collections.sort(duplicates, VALID_TIME_ORDER); + + List evidence = + new ArrayList<>(event.getEvidence()); + versions.removeAll(duplicates); + String newVersionId = VersionIdentity.create( + event, + event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + mergedValidTime, + null); + for (MemoryFactVersion duplicate : duplicates) { + evidence.addAll(duplicate.getEvidence()); + if (closeVersion( + duplicate, + event.getRecordedAt(), + versions, + relations, + generatingEventIds)) { + relations.add(new VersionRelation( + VersionRelationType.DUPLICATE_OF, + newVersionId, + duplicate.getId())); + } + } + + MemoryFactVersion added = new MemoryFactVersion( + newVersionId, + event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + mergedValidTime, + TimeInterval.unboundedFrom(event.getRecordedAt()), + sortedUniqueEvidence(evidence)); + for (MemoryFactVersion version : versions) { + if (isCurrentActive(version) + && version.getValidTime().overlaps( + mergedValidTime) + && !factValue(version.getFact()).equals(newValue)) { + relations.add(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + newVersionId, + version.getId())); + } + } + versions.add(added); + generatingEventIds.put(added.getId(), event.getEventId()); + } + + private static void replayNormalizedChange( + NormalizedMemoryEvent event, + List versions, + Set relations, + Map generatingEventIds) { + String targetId = event.getTargetVersionId().orElseThrow( + () -> new TemporalValidationException( + TemporalErrorCode.REJECT_TARGET_REQUIRED, + "Change event requires a target version")); + MemoryFactVersion target = null; + for (MemoryFactVersion version : versions) { + if (version.getId().equals(targetId)) { + target = version; + break; + } + } + if (target == null) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_TARGET_NOT_FOUND, + "Target version not found for fact key: " + targetId); + } + if (!isCurrentActive(target)) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_TARGET_INACTIVE, + "Target version is not current and active: " + targetId); + } + if (!target.getValidTime().overlaps(event.getValidTime())) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, + "Target version does not overlap the change interval: " + + targetId); + } + + TimeInterval changedTime = target.getValidTime() + .intersection(event.getValidTime()).get(); + versions.remove(target); + boolean sourceMaterialized = closeVersion( + target, event.getRecordedAt(), versions, relations, generatingEventIds); + boolean correction = + event.getOperation() == MemoryEventOperation.CORRECT; + MemoryFact changedFact = correction + ? event.getEvent().getFact().get() : target.getFact(); + MemoryFactVersionStatus changedStatus = correction + ? MemoryFactVersionStatus.ACTIVE + : MemoryFactVersionStatus.RETRACTED; + String changedId = VersionIdentity.create( + event, changedFact, changedStatus, changedTime, targetId); + versions.add(new MemoryFactVersion( + changedId, + changedFact, + changedStatus, + changedTime, + TimeInterval.unboundedFrom(event.getRecordedAt()), + event.getEvidence())); + generatingEventIds.put(changedId, event.getEventId()); + addSupersedes(changedId, targetId, sourceMaterialized, relations); + + for (TimeInterval remaining + : target.getValidTime().subtract(changedTime)) { + String fragmentId = VersionIdentity.create( + event, + target.getFact(), + MemoryFactVersionStatus.ACTIVE, + remaining, + targetId); + versions.add(new MemoryFactVersion( + fragmentId, + target.getFact(), + MemoryFactVersionStatus.ACTIVE, + remaining, + TimeInterval.unboundedFrom(event.getRecordedAt()), + target.getEvidence())); + generatingEventIds.put(fragmentId, event.getEventId()); + addSupersedes( + fragmentId, targetId, sourceMaterialized, relations); + } + + addCurrentConflicts(versions, relations); + } + + private static void addCurrentConflicts( + List versions, + Set relations) { + for (int leftIndex = 0; + leftIndex < versions.size(); leftIndex++) { + MemoryFactVersion left = versions.get(leftIndex); + if (!isCurrentActive(left)) { + continue; + } + for (int rightIndex = leftIndex + 1; + rightIndex < versions.size(); rightIndex++) { + MemoryFactVersion right = versions.get(rightIndex); + if (isCurrentActive(right) + && left.getValidTime().overlaps( + right.getValidTime()) + && !factValue(left.getFact()).equals( + factValue(right.getFact()))) { + relations.add(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + left.getId(), + right.getId())); + } + } + } + } + + private static void addSupersedes( + String replacementId, + String sourceId, + boolean sourceMaterialized, + Set relations) { + if (sourceMaterialized) { + relations.add(new VersionRelation( + VersionRelationType.SUPERSEDES, + replacementId, + sourceId)); + } + } + + private static boolean closeVersion( + MemoryFactVersion version, + Instant recordedAt, + List versions, + Set relations, + Map generatingEventIds) { + Instant startedAt = + version.getTransactionTime().getStart(); + if (startedAt.isAfter(recordedAt)) { + throw new IllegalArgumentException( + "Event recorded time precedes current version"); + } + if (startedAt.equals(recordedAt)) { + relations.removeIf(relation -> + references(relation, version.getId())); + generatingEventIds.remove(version.getId()); + return false; + } + versions.add(new MemoryFactVersion( + version.getId(), + version.getFact(), + version.getStatus(), + version.getValidTime(), + new TimeInterval(startedAt, recordedAt), + version.getEvidence())); + return true; + } + + private static boolean references( + VersionRelation relation, + String versionId) { + return relation.getFromVersionId().equals(versionId) + || relation.getToVersionId().equals(versionId); + } + + private static FactValue factValue(MemoryFact fact) { + if (fact.isRelationship()) { + return FactValue.entityReference( + fact.getTarget().get().getId()); + } + return FactValue.literal( + fact.getLiteralValue().get()); + } + + private static TimeInterval span( + TimeInterval left, + TimeInterval right) { + Instant start = left.getStart().isBefore(right.getStart()) + ? left.getStart() : right.getStart(); + Instant end; + if (!left.getEnd().isPresent() + || !right.getEnd().isPresent()) { + end = null; + } else { + Instant leftEnd = left.getEnd().get(); + Instant rightEnd = right.getEnd().get(); + end = leftEnd.isAfter(rightEnd) ? leftEnd : rightEnd; + } + return new TimeInterval(start, end); + } + + private static List sortedUniqueEvidence( + List evidence) { + Collections.sort(evidence, EVIDENCE_ORDER); + List unique = new ArrayList<>(); + for (Evidence item : evidence) { + if (unique.isEmpty() + || !unique.get(unique.size() - 1).equals(item)) { + unique.add(item); + } + } + return unique; + } + + private static boolean isCurrentActive( + MemoryFactVersion version) { + return isCurrent(version) + && version.getStatus() + == MemoryFactVersionStatus.ACTIVE; + } + + 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())); + } + + private static void replayChange( + 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/main/java/org/apache/geaflow/ai/temporal/oracle/ReplayMethod.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/ReplayMethod.java new file mode 100644 index 000000000..bf01a5d19 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/oracle/ReplayMethod.java @@ -0,0 +1,32 @@ +/* + * 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.List; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; + +/** + * Generates a canonical snapshot from normalized memory events. + */ +public interface ReplayMethod { + + CanonicalSnapshot replayToSnapshot(List events); +} 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..ce88813c0 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/BitemporalQuery.java @@ -0,0 +1,65 @@ +/* + * 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; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; + +/** + * 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.getStatus() + == MemoryFactVersionStatus.ACTIVE + && 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/main/java/org/apache/geaflow/ai/temporal/query/TemporalQueryService.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/TemporalQueryService.java new file mode 100644 index 000000000..e3ed98804 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/query/TemporalQueryService.java @@ -0,0 +1,135 @@ +/* + * 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.Map; +import java.util.Objects; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; + +/** + * Queries a committed temporal state without choosing a winner among conflicts. + * Results use FactKey, valid interval, system interval and version ID order; + * an open interval end sorts after every finite end. + */ +public final class TemporalQueryService { + + private static final Comparator END_ORDER = + Comparator.nullsLast(Comparator.naturalOrder()); + + private static final Comparator VERSION_ORDER = + Comparator.comparing( + (MemoryFactVersion version) -> version.getValidTime().getStart()) + .thenComparing( + version -> version.getValidTime().getEnd().orElse(null), END_ORDER) + .thenComparing(version -> version.getTransactionTime().getStart()) + .thenComparing( + version -> version.getTransactionTime().getEnd().orElse(null), END_ORDER) + .thenComparing(MemoryFactVersion::getId); + + /** Returns active versions with an open system end at the given valid time. */ + public List current( + TemporalState state, FactKey factKey, Instant validAt) { + Objects.requireNonNull(factKey, "factKey"); + return select(state, factKey, validAt, null, Mode.CURRENT); + } + + public List current(TemporalState state, Instant validAt) { + return select(state, null, validAt, null, Mode.CURRENT); + } + + /** Returns all audit versions, including closed versions and tombstones. */ + public List historical( + TemporalState state, FactKey factKey, Instant validAt) { + Objects.requireNonNull(factKey, "factKey"); + return select(state, factKey, validAt, null, Mode.HISTORICAL); + } + + public List historical(TemporalState state, Instant validAt) { + return select(state, null, validAt, null, Mode.HISTORICAL); + } + + /** Returns active versions visible at both half-open temporal coordinates. */ + public List asOf( + TemporalState state, FactKey factKey, Instant validAt, Instant systemAt) { + Objects.requireNonNull(factKey, "factKey"); + return select(state, factKey, validAt, systemAt, Mode.AS_OF); + } + + public List asOf( + TemporalState state, Instant validAt, Instant systemAt) { + return select(state, null, validAt, systemAt, Mode.AS_OF); + } + + private static List select( + TemporalState state, + FactKey factKey, + Instant validAt, + Instant systemAt, + Mode mode) { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(validAt, "validAt"); + if (mode == Mode.AS_OF) { + Objects.requireNonNull(systemAt, "systemAt"); + } + + List result = new ArrayList<>(); + // TemporalState already orders its groups by the full FactKey, including scope. + for (Map.Entry> entry + : state.getVersionsByFactKey().entrySet()) { + if (factKey != null && !factKey.equals(entry.getKey())) { + continue; + } + List matches = new ArrayList<>(); + for (MemoryFactVersion version : entry.getValue()) { + if (!version.getValidTime().contains(validAt)) { + continue; + } + if (mode != Mode.HISTORICAL) { + if (version.getStatus() != MemoryFactVersionStatus.ACTIVE) { + continue; + } + if (mode == Mode.CURRENT + ? version.getTransactionTime().getEnd().isPresent() + : !version.getTransactionTime().contains(systemAt)) { + continue; + } + } + matches.add(version); + } + Collections.sort(matches, VERSION_ORDER); + result.addAll(matches); + } + return Collections.unmodifiableList(result); + } + + private enum Mode { + CURRENT, + HISTORICAL, + AS_OF + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/CanonicalSnapshot.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/CanonicalSnapshot.java new file mode 100644 index 000000000..4b98c92d6 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/CanonicalSnapshot.java @@ -0,0 +1,239 @@ +/* + * 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.semantics; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.VersionRelation; + +/** + * An immutable canonical representation of temporal state and its input events. + */ +public final class CanonicalSnapshot { + + private static final Comparator EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + private static final Comparator EVIDENCE_ORDER = + Comparator.comparing(Evidence::getId) + .thenComparing(evidence -> evidence.getSource().getId()) + .thenComparing(evidence -> evidence.getSource().getName()) + .thenComparing(Evidence::getContent); + + private final TemporalState state; + private final List events; + private final Map generatingEventIds; + + public CanonicalSnapshot( + TemporalState state, + List events, + Map generatingEventIds) { + this.state = canonicalizeState( + Objects.requireNonNull(state, "state")); + this.events = canonicalizeEvents(events); + this.generatingEventIds = canonicalizeGeneratingEventIds( + generatingEventIds, + this.state, + this.events); + } + + public TemporalState getState() { + return state; + } + + public List getEvents() { + return events; + } + + public Map getGeneratingEventIds() { + return generatingEventIds; + } + + private static TemporalState canonicalizeState( + TemporalState state) { + Map> versionsByKey = + new TreeMap<>(); + Set versionIds = new HashSet<>(); + for (List versions + : state.getVersionsByFactKey().values()) { + for (MemoryFactVersion version : versions) { + if (!versionIds.add(version.getId())) { + throw new IllegalArgumentException( + "Duplicate version id: " + version.getId()); + } + } + } + for (Map.Entry> entry + : state.getVersionsByFactKey().entrySet()) { + FactKey factKey = Objects.requireNonNull( + entry.getKey(), "factKey"); + List versions = Objects.requireNonNull( + entry.getValue(), "versions"); + if (versions.isEmpty()) { + continue; + } + List canonicalVersions = + new ArrayList<>(); + for (MemoryFactVersion version : versions) { + Objects.requireNonNull(version, "version"); + validateFactKey(factKey, version.getFact()); + canonicalVersions.add(canonicalizeVersion(version)); + } + versionsByKey.put(factKey, canonicalVersions); + } + for (VersionRelation relation : state.getRelations()) { + Objects.requireNonNull(relation, "relation"); + if (!versionIds.contains(relation.getFromVersionId()) + || !versionIds.contains(relation.getToVersionId())) { + throw new IllegalArgumentException( + "Version relation references an unknown version"); + } + } + return new TemporalState(versionsByKey, state.getRelations()); + } + + private static MemoryFactVersion canonicalizeVersion( + MemoryFactVersion version) { + List evidence = new ArrayList<>(version.getEvidence()); + for (Evidence item : evidence) { + Objects.requireNonNull(item, "evidence"); + } + Collections.sort(evidence, EVIDENCE_ORDER); + return new MemoryFactVersion( + version.getId(), + version.getFact(), + version.getStatus(), + version.getValidTime(), + version.getTransactionTime(), + evidence); + } + + private static void validateFactKey( + FactKey factKey, + MemoryFact fact) { + if (!factKey.getSubjectId().equals(fact.getSubject().getId()) + || !factKey.getPredicate().equals(fact.getPredicate())) { + throw new IllegalArgumentException( + "Fact key does not match version fact"); + } + } + + private static List canonicalizeEvents( + List events) { + List ordered = new ArrayList<>( + Objects.requireNonNull(events, "events")); + for (NormalizedMemoryEvent event : ordered) { + Objects.requireNonNull(event, "event"); + } + Collections.sort(ordered, EVENT_ORDER); + + Map payloadHashes = new HashMap<>(); + List unique = new ArrayList<>(); + for (NormalizedMemoryEvent event : ordered) { + String previousHash = payloadHashes.get(event.getEventId()); + if (previousHash == null) { + payloadHashes.put( + event.getEventId(), event.getPayloadHash()); + unique.add(event); + } else if (!previousHash.equals(event.getPayloadHash())) { + throw new IllegalArgumentException( + "Event id reused with a different payload: " + + event.getEventId()); + } + } + return Collections.unmodifiableList(unique); + } + + private static Map canonicalizeGeneratingEventIds( + Map generatingEventIds, + TemporalState state, + List events) { + Map versionKeys = new HashMap<>(); + for (Map.Entry> entry + : state.getVersionsByFactKey().entrySet()) { + for (MemoryFactVersion version : entry.getValue()) { + versionKeys.put(version.getId(), entry.getKey()); + } + } + Map eventsById = new HashMap<>(); + for (NormalizedMemoryEvent event : events) { + eventsById.put(event.getEventId(), event); + } + + Map ordered = new TreeMap<>(); + for (Map.Entry entry : Objects.requireNonNull( + generatingEventIds, "generatingEventIds").entrySet()) { + String versionId = Objects.requireNonNull( + entry.getKey(), "versionId"); + String eventId = Objects.requireNonNull( + entry.getValue(), "eventId"); + ordered.put(versionId, eventId); + } + if (!ordered.keySet().equals(versionKeys.keySet())) { + throw new IllegalArgumentException( + "Generating event ids must match version ids"); + } + for (Map.Entry entry : ordered.entrySet()) { + NormalizedMemoryEvent event = eventsById.get(entry.getValue()); + if (event == null) { + throw new IllegalArgumentException( + "Generating event is unknown: " + entry.getValue()); + } + if (!versionKeys.get(entry.getKey()).equals( + event.getFactKey())) { + throw new IllegalArgumentException( + "Generating event fact key does not match version"); + } + } + return Collections.unmodifiableMap(ordered); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof CanonicalSnapshot)) { + return false; + } + CanonicalSnapshot that = (CanonicalSnapshot) object; + return state.equals(that.state) + && events.equals(that.events) + && generatingEventIds.equals(that.generatingEventIds); + } + + @Override + public int hashCode() { + return Objects.hash(state, events, generatingEventIds); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/DiffReport.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/DiffReport.java new file mode 100644 index 000000000..eb6c2ca14 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/DiffReport.java @@ -0,0 +1,107 @@ +/* + * 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.semantics; + +import java.util.Objects; +import java.util.Optional; +import org.apache.geaflow.ai.temporal.model.FactKey; + +/** + * The first difference found between two canonical snapshots. + */ +public final class DiffReport { + + private final boolean equivalent; + private final String fieldPath; + private final String expectedValue; + private final String actualValue; + private final FactKey factKey; + private final String eventId; + + private DiffReport(boolean equivalent, String fieldPath, String expectedValue, + String actualValue, FactKey factKey, String eventId) { + this.equivalent = equivalent; + this.fieldPath = fieldPath; + this.expectedValue = expectedValue; + this.actualValue = actualValue; + this.factKey = factKey; + this.eventId = eventId; + } + + public static DiffReport equivalent() { + return new DiffReport(true, null, null, null, null, null); + } + + public static DiffReport difference(String fieldPath, String expectedValue, + String actualValue, FactKey factKey, + String eventId) { + Objects.requireNonNull(fieldPath, "fieldPath"); + if (fieldPath.trim().isEmpty()) { + throw new IllegalArgumentException("Difference field path must not be blank"); + } + return new DiffReport(false, fieldPath, expectedValue, actualValue, factKey, eventId); + } + + public boolean isEquivalent() { + return equivalent; + } + + public Optional getFieldPath() { + return Optional.ofNullable(fieldPath); + } + + public Optional getExpectedValue() { + return Optional.ofNullable(expectedValue); + } + + public Optional getActualValue() { + return Optional.ofNullable(actualValue); + } + + public Optional getFactKey() { + return Optional.ofNullable(factKey); + } + + public Optional getEventId() { + return Optional.ofNullable(eventId); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof DiffReport)) { + return false; + } + DiffReport that = (DiffReport) object; + return equivalent == that.equivalent + && Objects.equals(fieldPath, that.fieldPath) + && Objects.equals(expectedValue, that.expectedValue) + && Objects.equals(actualValue, that.actualValue) + && Objects.equals(factKey, that.factKey) + && Objects.equals(eventId, that.eventId); + } + + @Override + public int hashCode() { + return Objects.hash(equivalent, fieldPath, expectedValue, actualValue, factKey, eventId); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventLedger.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventLedger.java new file mode 100644 index 000000000..ad7bb95d7 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventLedger.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.semantics; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Tracks the committed payload hash for each normalized event id. + */ +public final class EventLedger { + + private final Map payloadHashes = new HashMap<>(); + + public EventLedger() { + } + + public EventLedger(EventLedger existing) { + payloadHashes.putAll(Objects.requireNonNull(existing, "existing").payloadHashes); + } + + public EventLedgerDecision check(NormalizedMemoryEvent event) { + Objects.requireNonNull(event, "event"); + String existing = payloadHashes.get(event.getEventId()); + if (existing == null) { + return EventLedgerDecision.ACCEPTED; + } + if (existing.equals(event.getPayloadHash())) { + return EventLedgerDecision.DUPLICATE_NOOP; + } + return EventLedgerDecision.REJECT_EVENT_ID_REUSE; + } + + public EventLedgerDecision commit(NormalizedMemoryEvent event) { + EventLedgerDecision decision = check(event); + if (decision == EventLedgerDecision.REJECT_EVENT_ID_REUSE) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_EVENT_ID_REUSE, + "Event id reused with a different payload: " + + event.getEventId()); + } + if (decision == EventLedgerDecision.ACCEPTED) { + payloadHashes.put( + event.getEventId(), + event.getPayloadHash()); + } + return decision; + } + + public Optional getPayloadHash(String eventId) { + return Optional.ofNullable(payloadHashes.get( + Objects.requireNonNull(eventId, "eventId"))); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventLedgerDecision.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventLedgerDecision.java new file mode 100644 index 000000000..22a387070 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventLedgerDecision.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.semantics; + +/** + * Result of checking a normalized event against the event ledger. + */ +public enum EventLedgerDecision { + + ACCEPTED, + DUPLICATE_NOOP, + REJECT_EVENT_ID_REUSE +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventNormalizer.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventNormalizer.java new file mode 100644 index 000000000..dcf1e968d --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/EventNormalizer.java @@ -0,0 +1,359 @@ +/* + * 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.semantics; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +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 java.util.Optional; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEntity; +import org.apache.geaflow.ai.temporal.model.MemoryEvent; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; + +/** + * Canonicalizes temporal events before replay or ledger checks. + */ +public final class EventNormalizer { + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private static final Comparator EVIDENCE_ORDER = + Comparator.comparing(Evidence::getId) + .thenComparing(evidence -> evidence.getSource().getId()) + .thenComparing(evidence -> evidence.getSource().getName()) + .thenComparing(Evidence::getContent); + + public NormalizedMemoryEvent normalize( + MemoryEvent event, + FactKey factKey) { + Objects.requireNonNull(event, "event"); + Objects.requireNonNull(factKey, "factKey"); + + if (event.getOperation() != MemoryEventOperation.ADD + && !event.getTargetVersionId().isPresent()) { + throw new TemporalValidationException( + TemporalErrorCode.REJECT_TARGET_REQUIRED, + "CORRECT/RETRACT requires a target version"); + } + + FactKey normalizedKey = new FactKey( + normalizeText(factKey.getSubjectId()), + normalizeText(factKey.getPredicate()), + normalizeText(factKey.getScope())); + String eventId = normalizeText(event.getId()); + String factId = normalizeText(event.getFactId()); + TimeInterval validTime = normalizeInterval( + event.getValidTime()); + Instant recordedAt = normalizeTime( + event.getTransactionTime()); + List evidence = normalizeEvidence( + event.getEvidence()); + MemoryFact fact = normalizeFact(event, factId); + + validateFactKey(event.getOperation(), fact, normalizedKey); + MemoryEvent normalizedEvent = createEvent( + eventId, + event.getOperation(), + factId, + fact, + validTime, + recordedAt, + evidence); + if (event.getTargetVersionId().isPresent()) { + normalizedEvent = normalizedEvent.targeting( + normalizeText(event.getTargetVersionId().get())); + } + FactValue factValue = createFactValue(fact); + String payloadHash = payloadHash( + normalizedEvent, + normalizedKey, + factValue); + + return new NormalizedMemoryEvent( + normalizedEvent, + normalizedKey, + factValue, + payloadHash); + } + + private static MemoryFact normalizeFact( + MemoryEvent event, + String factId) { + Optional optionalFact = event.getFact(); + if (!optionalFact.isPresent()) { + if (event.getOperation() != MemoryEventOperation.RETRACT) { + throw new IllegalArgumentException( + "Event operation requires a fact"); + } + return null; + } + + MemoryFact fact = optionalFact.get(); + MemoryEntity subject = normalizeEntity(fact.getSubject()); + String predicate = normalizeText(fact.getPredicate()); + if (fact.isRelationship()) { + return MemoryFact.relationship( + factId, + subject, + predicate, + normalizeEntity(fact.getTarget().get())); + } + return MemoryFact.attribute( + factId, + subject, + predicate, + normalizeText(fact.getLiteralValue().get())); + } + + private static MemoryEntity normalizeEntity( + MemoryEntity entity) { + return new MemoryEntity( + normalizeText(entity.getId()), + normalizeText(entity.getLabel())); + } + + private static List normalizeEvidence( + List evidence) { + List normalized = new ArrayList<>(); + for (Evidence item : evidence) { + Source source = item.getSource(); + normalized.add(new Evidence( + normalizeText(item.getId()), + new Source( + normalizeText(source.getId()), + normalizeText(source.getName())), + normalizeText(item.getContent()))); + } + Collections.sort(normalized, EVIDENCE_ORDER); + return normalized; + } + + private static TimeInterval normalizeInterval( + TimeInterval interval) { + Instant end = interval.getEnd().isPresent() + ? normalizeTime(interval.getEnd().get()) : null; + return new TimeInterval( + normalizeTime(interval.getStart()), + end); + } + + private static Instant normalizeTime(Instant time) { + return time; + } + + private static String normalizeText(String value) { + return Normalizer.normalize(value, Normalizer.Form.NFC); + } + + private static void validateFactKey( + MemoryEventOperation operation, + MemoryFact fact, + FactKey factKey) { + if (operation == MemoryEventOperation.RETRACT) { + if (fact != null) { + throw new IllegalArgumentException( + "Retract event must not contain a fact"); + } + return; + } + if (fact == null + || !fact.getSubject().getId().equals( + factKey.getSubjectId()) + || !fact.getPredicate().equals( + factKey.getPredicate())) { + throw new IllegalArgumentException( + "Fact key does not match event fact"); + } + } + + private static MemoryEvent createEvent( + String eventId, + MemoryEventOperation operation, + String factId, + MemoryFact fact, + TimeInterval validTime, + Instant recordedAt, + List evidence) { + if (operation == MemoryEventOperation.ADD) { + return MemoryEvent.add( + eventId, + fact, + validTime, + recordedAt, + evidence); + } + if (operation == MemoryEventOperation.CORRECT) { + return MemoryEvent.correct( + eventId, + fact, + validTime, + recordedAt, + evidence); + } + if (operation == MemoryEventOperation.RETRACT) { + return MemoryEvent.retract( + eventId, + factId, + validTime, + recordedAt, + evidence); + } + throw new UnsupportedOperationException( + "Unsupported memory event operation: " + operation); + } + + private static FactValue createFactValue(MemoryFact fact) { + if (fact == null) { + return null; + } + if (fact.isRelationship()) { + return FactValue.entityReference( + fact.getTarget().get().getId()); + } + return FactValue.literal( + fact.getLiteralValue().get()); + } + + private static String payloadHash( + MemoryEvent event, + FactKey factKey, + FactValue factValue) { + MessageDigest digest = sha256(); + updateText(digest, event.getOperation().name()); + updateText(digest, event.getFactId()); + updateText(digest, event.getTargetVersionId().orElse(null)); + updateText(digest, factKey.getSubjectId()); + updateText(digest, factKey.getPredicate()); + updateText(digest, factKey.getScope()); + updateText( + digest, + factValue == null ? null : factValue.getKind().name()); + updateText( + digest, + factValue == null ? null : factValue.getValue()); + + MemoryFact fact = event.getFact().orElse(null); + updateText( + digest, + fact == null ? null : fact.getSubject().getLabel()); + updateText( + digest, + fact != null && fact.isRelationship() + ? fact.getTarget().get().getLabel() : null); + updateTime( + digest, + event.getValidTime().getStart()); + updateOptionalTime( + digest, + event.getValidTime().getEnd()); + updateTime( + digest, + event.getTransactionTime()); + updateInt(digest, event.getEvidence().size()); + for (Evidence evidence : event.getEvidence()) { + updateText(digest, evidence.getId()); + updateText(digest, evidence.getSource().getId()); + updateText(digest, evidence.getSource().getName()); + updateText(digest, evidence.getContent()); + } + return toHex(digest.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException( + "SHA-256 is unavailable", + exception); + } + } + + private static void updateText( + MessageDigest digest, + String value) { + if (value == null) { + digest.update((byte) 0); + return; + } + digest.update((byte) 1); + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + updateInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateOptionalTime( + MessageDigest digest, + Optional time) { + if (time.isPresent()) { + digest.update((byte) 1); + updateTime(digest, time.get()); + } else { + digest.update((byte) 0); + } + } + + private static void updateTime( + MessageDigest digest, + Instant time) { + updateLong(digest, time.getEpochSecond()); + updateInt(digest, time.getNano()); + } + + private static void updateInt( + MessageDigest digest, + int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES) + .putInt(value) + .array()); + } + + private static void updateLong( + MessageDigest digest, + long value) { + digest.update(ByteBuffer.allocate(Long.BYTES) + .putLong(value) + .array()); + } + + private static String toHex(byte[] bytes) { + char[] characters = new char[bytes.length * 2]; + for (int index = 0; index < bytes.length; index++) { + int value = bytes[index] & 0xff; + characters[index * 2] = HEX[value >>> 4]; + characters[index * 2 + 1] = HEX[value & 0x0f]; + } + return new String(characters); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/NormalizedMemoryEvent.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/NormalizedMemoryEvent.java new file mode 100644 index 000000000..845540bf3 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/NormalizedMemoryEvent.java @@ -0,0 +1,122 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEvent; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.TimeInterval; + +/** + * An immutable event after canonical input normalization. + */ +public final class NormalizedMemoryEvent { + + public static final int SCHEMA_VERSION = 2; + + private final MemoryEvent event; + private final FactKey factKey; + private final FactValue factValue; + private final String payloadHash; + + NormalizedMemoryEvent( + MemoryEvent event, + FactKey factKey, + FactValue factValue, + String payloadHash) { + this.event = Objects.requireNonNull(event, "event"); + this.factKey = Objects.requireNonNull(factKey, "factKey"); + this.factValue = factValue; + this.payloadHash = Objects.requireNonNull( + payloadHash, + "payloadHash"); + } + + public MemoryEvent getEvent() { + return event; + } + + public String getEventId() { + return event.getId(); + } + + public MemoryEventOperation getOperation() { + return event.getOperation(); + } + + public String getFactId() { + return event.getFactId(); + } + + public Optional getTargetVersionId() { + return event.getTargetVersionId(); + } + + public FactKey getFactKey() { + return factKey; + } + + public Optional getFactValue() { + return Optional.ofNullable(factValue); + } + + public TimeInterval getValidTime() { + return event.getValidTime(); + } + + public Instant getRecordedAt() { + return event.getTransactionTime(); + } + + public List getEvidence() { + return event.getEvidence(); + } + + public String getPayloadHash() { + return payloadHash; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof NormalizedMemoryEvent)) { + return false; + } + NormalizedMemoryEvent that = + (NormalizedMemoryEvent) object; + return event.equals(that.event) + && factKey.equals(that.factKey) + && Objects.equals(factValue, that.factValue) + && payloadHash.equals(that.payloadHash); + } + + @Override + public int hashCode() { + return Objects.hash(event, factKey, factValue, payloadHash); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/SnapshotComparator.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/SnapshotComparator.java new file mode 100644 index 000000000..3128af2c8 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/SnapshotComparator.java @@ -0,0 +1,641 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; + +/** + * Finds the first stable, field-level difference between two canonical snapshots. + */ +public final class SnapshotComparator { + + public DiffReport compare( + CanonicalSnapshot expected, + CanonicalSnapshot actual) { + Objects.requireNonNull(expected, "expected"); + Objects.requireNonNull(actual, "actual"); + + DiffReport difference = compareEvents(expected, actual); + if (difference != null) { + return difference; + } + difference = compareVersions(expected, actual); + if (difference != null) { + return difference; + } + difference = compareGeneratingEventIds(expected, actual); + if (difference != null) { + return difference; + } + difference = compareRelations(expected, actual); + return difference == null ? DiffReport.equivalent() : difference; + } + + private static DiffReport compareEvents( + CanonicalSnapshot expected, + CanonicalSnapshot actual) { + Map expectedEvents = eventsById(expected); + Map actualEvents = eventsById(actual); + Set eventIds = keys(expectedEvents, actualEvents); + for (String eventId : eventIds) { + NormalizedMemoryEvent expectedEvent = expectedEvents.get(eventId); + NormalizedMemoryEvent actualEvent = actualEvents.get(eventId); + NormalizedMemoryEvent contextEvent = expectedEvent == null + ? actualEvent : expectedEvent; + FactKey contextKey = contextEvent.getFactKey(); + String prefix = "events[" + eventId + "]"; + DiffReport difference = comparePresence( + prefix, expectedEvent, actualEvent, contextKey, eventId); + if (difference != null) { + return difference; + } + difference = compareFactKey( + prefix + ".factKey", + expectedEvent.getFactKey(), + actualEvent.getFactKey(), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".operation", + expectedEvent.getOperation().name(), + actualEvent.getOperation().name(), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".factId", + expectedEvent.getFactId(), + actualEvent.getFactId(), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".targetVersionId", + expectedEvent.getTargetVersionId().orElse(null), + actualEvent.getTargetVersionId().orElse(null), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = compareOptionalFact( + prefix + ".fact", + expectedEvent.getEvent().getFact().orElse(null), + actualEvent.getEvent().getFact().orElse(null), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = compareInterval( + prefix + ".validTime", + expectedEvent.getValidTime(), + actualEvent.getValidTime(), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".recordedAt", + time(expectedEvent.getRecordedAt()), + time(actualEvent.getRecordedAt()), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = compareEvidence( + prefix + ".evidence", + expectedEvent.getEvidence(), + actualEvent.getEvidence(), + contextKey, + eventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".payloadHash", + expectedEvent.getPayloadHash(), + actualEvent.getPayloadHash(), + contextKey, + eventId); + if (difference != null) { + return difference; + } + } + return null; + } + + private static DiffReport compareVersions( + CanonicalSnapshot expected, + CanonicalSnapshot actual) { + Map expectedVersions = versionsById(expected); + Map actualVersions = versionsById(actual); + Set versionIds = keys(expectedVersions, actualVersions); + for (String versionId : versionIds) { + VersionEntry expectedEntry = expectedVersions.get(versionId); + VersionEntry actualEntry = actualVersions.get(versionId); + VersionEntry contextEntry = expectedEntry == null + ? actualEntry : expectedEntry; + CanonicalSnapshot contextSnapshot = expectedEntry == null + ? actual : expected; + FactKey contextKey = contextEntry.factKey; + String contextEventId = contextSnapshot.getGeneratingEventIds().get(versionId); + String prefix = "versions[" + versionId + "]"; + DiffReport difference = comparePresence( + prefix, expectedEntry, actualEntry, contextKey, contextEventId); + if (difference != null) { + return difference; + } + difference = compareFactKey( + prefix + ".factKey", + expectedEntry.factKey, + actualEntry.factKey, + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + MemoryFactVersion expectedVersion = expectedEntry.version; + MemoryFactVersion actualVersion = actualEntry.version; + difference = compareFact( + prefix + ".fact", + expectedVersion.getFact(), + actualVersion.getFact(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".status", + expectedVersion.getStatus().name(), + actualVersion.getStatus().name(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = compareInterval( + prefix + ".validTime", + expectedVersion.getValidTime(), + actualVersion.getValidTime(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = compareInterval( + prefix + ".transactionTime", + expectedVersion.getTransactionTime(), + actualVersion.getTransactionTime(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = compareEvidence( + prefix + ".evidence", + expectedVersion.getEvidence(), + actualVersion.getEvidence(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + } + return null; + } + + private static DiffReport compareGeneratingEventIds( + CanonicalSnapshot expected, + CanonicalSnapshot actual) { + Map expectedIds = expected.getGeneratingEventIds(); + Map actualIds = actual.getGeneratingEventIds(); + Map expectedVersions = versionsById(expected); + Map actualVersions = versionsById(actual); + for (String versionId : keys(expectedIds, actualIds)) { + String expectedEventId = expectedIds.get(versionId); + String actualEventId = actualIds.get(versionId); + VersionEntry contextEntry = expectedVersions.get(versionId); + if (contextEntry == null) { + contextEntry = actualVersions.get(versionId); + } + FactKey contextKey = contextEntry == null + ? null : contextEntry.factKey; + DiffReport difference = difference( + "generatingEventIds[" + versionId + "]", + expectedEventId, + actualEventId, + contextKey, + expectedEventId); + if (difference != null) { + return difference; + } + } + return null; + } + + private static DiffReport compareRelations( + CanonicalSnapshot expected, + CanonicalSnapshot actual) { + List expectedRelations = new ArrayList<>( + expected.getState().getRelations()); + List actualRelations = new ArrayList<>( + actual.getState().getRelations()); + Collections.sort(expectedRelations); + Collections.sort(actualRelations); + Map expectedVersions = versionsById(expected); + Map actualVersions = versionsById(actual); + int relationCount = Math.max(expectedRelations.size(), actualRelations.size()); + for (int index = 0; index < relationCount; index++) { + VersionRelation expectedRelation = index < expectedRelations.size() + ? expectedRelations.get(index) : null; + VersionRelation actualRelation = index < actualRelations.size() + ? actualRelations.get(index) : null; + VersionRelation contextRelation = expectedRelation == null + ? actualRelation : expectedRelation; + CanonicalSnapshot contextSnapshot = expectedRelation == null + ? actual : expected; + Map contextVersions = expectedRelation == null + ? actualVersions : expectedVersions; + VersionEntry contextEntry = contextVersions.get( + contextRelation.getFromVersionId()); + FactKey contextKey = contextEntry == null + ? null : contextEntry.factKey; + String contextEventId = contextSnapshot.getGeneratingEventIds().get( + contextRelation.getFromVersionId()); + String prefix = "relations[" + index + "]"; + DiffReport difference = comparePresence( + prefix, + expectedRelation, + actualRelation, + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".type", + expectedRelation.getType().name(), + actualRelation.getType().name(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".fromVersionId", + expectedRelation.getFromVersionId(), + actualRelation.getFromVersionId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".toVersionId", + expectedRelation.getToVersionId(), + actualRelation.getToVersionId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + } + return null; + } + + private static DiffReport compareFactKey( + String prefix, + FactKey expected, + FactKey actual, + FactKey contextKey, + String contextEventId) { + DiffReport difference = difference( + prefix + ".subjectId", + expected.getSubjectId(), + actual.getSubjectId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".predicate", + expected.getPredicate(), + actual.getPredicate(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + return difference( + prefix + ".scope", + expected.getScope(), + actual.getScope(), + contextKey, + contextEventId); + } + + private static DiffReport compareOptionalFact( + String prefix, + MemoryFact expected, + MemoryFact actual, + FactKey contextKey, + String contextEventId) { + DiffReport difference = comparePresence( + prefix, expected, actual, contextKey, contextEventId); + if (difference != null || expected == null) { + return difference; + } + return compareFact( + prefix, expected, actual, contextKey, contextEventId); + } + + private static DiffReport compareFact( + String prefix, + MemoryFact expected, + MemoryFact actual, + FactKey contextKey, + String contextEventId) { + DiffReport difference = difference( + prefix + ".id", + expected.getId(), + actual.getId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".subject.id", + expected.getSubject().getId(), + actual.getSubject().getId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".subject.label", + expected.getSubject().getLabel(), + actual.getSubject().getLabel(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".predicate", + expected.getPredicate(), + actual.getPredicate(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + prefix + ".kind", + factKind(expected), + factKind(actual), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + if (!expected.isRelationship()) { + return difference( + prefix + ".literalValue", + expected.getLiteralValue().orElse(null), + actual.getLiteralValue().orElse(null), + contextKey, + contextEventId); + } + difference = difference( + prefix + ".target.id", + expected.getTarget().get().getId(), + actual.getTarget().get().getId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + return difference( + prefix + ".target.label", + expected.getTarget().get().getLabel(), + actual.getTarget().get().getLabel(), + contextKey, + contextEventId); + } + + private static DiffReport compareInterval( + String prefix, + TimeInterval expected, + TimeInterval actual, + FactKey contextKey, + String contextEventId) { + DiffReport difference = difference( + prefix + ".start", + time(expected.getStart()), + time(actual.getStart()), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + return difference( + prefix + ".end", + end(expected), + end(actual), + contextKey, + contextEventId); + } + + private static DiffReport compareEvidence( + String prefix, + List expected, + List actual, + FactKey contextKey, + String contextEventId) { + int evidenceCount = Math.max(expected.size(), actual.size()); + for (int index = 0; index < evidenceCount; index++) { + Evidence expectedEvidence = index < expected.size() + ? expected.get(index) : null; + Evidence actualEvidence = index < actual.size() + ? actual.get(index) : null; + String itemPrefix = prefix + "[" + index + "]"; + DiffReport difference = comparePresence( + itemPrefix, + expectedEvidence, + actualEvidence, + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + itemPrefix + ".id", + expectedEvidence.getId(), + actualEvidence.getId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + itemPrefix + ".source.id", + expectedEvidence.getSource().getId(), + actualEvidence.getSource().getId(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + itemPrefix + ".source.name", + expectedEvidence.getSource().getName(), + actualEvidence.getSource().getName(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + difference = difference( + itemPrefix + ".content", + expectedEvidence.getContent(), + actualEvidence.getContent(), + contextKey, + contextEventId); + if (difference != null) { + return difference; + } + } + return null; + } + + private static DiffReport comparePresence( + String path, + Object expected, + Object actual, + FactKey contextKey, + String contextEventId) { + return difference( + path, + expected == null ? null : "present", + actual == null ? null : "present", + contextKey, + contextEventId); + } + + private static DiffReport difference( + String path, + String expected, + String actual, + FactKey contextKey, + String contextEventId) { + return Objects.equals(expected, actual) + ? null + : DiffReport.difference( + path, expected, actual, contextKey, contextEventId); + } + + private static String factKind(MemoryFact fact) { + return fact.isRelationship() + ? FactValue.Kind.ENTITY_REF.name() + : FactValue.Kind.LITERAL.name(); + } + + private static String time(Instant instant) { + return instant.toString(); + } + + private static String end(TimeInterval interval) { + return interval.getEnd().isPresent() + ? time(interval.getEnd().get()) : "infinity"; + } + + private static Map eventsById( + CanonicalSnapshot snapshot) { + Map events = new TreeMap<>(); + for (NormalizedMemoryEvent event : snapshot.getEvents()) { + events.put(event.getEventId(), event); + } + return events; + } + + private static Map versionsById( + CanonicalSnapshot snapshot) { + Map versions = new TreeMap<>(); + for (Map.Entry> entry + : snapshot.getState().getVersionsByFactKey().entrySet()) { + for (MemoryFactVersion version : entry.getValue()) { + versions.put( + version.getId(), + new VersionEntry(entry.getKey(), version)); + } + } + return versions; + } + + private static Set keys( + Map expected, + Map actual) { + Set keys = new TreeSet<>(expected.keySet()); + keys.addAll(actual.keySet()); + return keys; + } + + private static final class VersionEntry { + + private final FactKey factKey; + private final MemoryFactVersion version; + + private VersionEntry( + FactKey factKey, + MemoryFactVersion version) { + this.factKey = factKey; + this.version = version; + } + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalErrorCode.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalErrorCode.java new file mode 100644 index 000000000..acec67b61 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalErrorCode.java @@ -0,0 +1,32 @@ +/* + * 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.semantics; + +/** Stable validation failures for normalized temporal events. */ +public enum TemporalErrorCode { + REJECT_INPUT, + REJECT_SCHEMA_VERSION, + REJECT_REFERENCE_INTEGRITY, + REJECT_TARGET_REQUIRED, + REJECT_TARGET_NOT_FOUND, + REJECT_TARGET_INACTIVE, + REJECT_TARGET_NO_OVERLAP, + REJECT_EVENT_ID_REUSE +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalState.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalState.java new file mode 100644 index 000000000..3cbc2d9a6 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalState.java @@ -0,0 +1,124 @@ +/* + * 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.semantics; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.VersionRelation; + +/** + * An immutable, deterministically ordered temporal state snapshot. + */ +public final class TemporalState { + + private static final Comparator VERSION_ORDER = + Comparator.comparing( + (MemoryFactVersion version) -> + version.getTransactionTime().getStart()) + .thenComparing( + version -> version.getValidTime().getStart()) + .thenComparing(MemoryFactVersion::getId); + + private final Map> + versionsByFactKey; + private final List versions; + private final List relations; + + public TemporalState( + Map> versionsByFactKey, + List relations) { + Objects.requireNonNull(versionsByFactKey, "versionsByFactKey"); + + Map> ordered = + new TreeMap<>(); + for (Map.Entry> entry + : versionsByFactKey.entrySet()) { + FactKey key = Objects.requireNonNull( + entry.getKey(), + "factKey"); + List versionsForKey = + new ArrayList<>(Objects.requireNonNull( + entry.getValue(), + "versionsForKey")); + for (MemoryFactVersion version : versionsForKey) { + Objects.requireNonNull(version, "version"); + } + Collections.sort(versionsForKey, VERSION_ORDER); + List immutableVersions = + Collections.unmodifiableList(versionsForKey); + ordered.put(key, immutableVersions); + } + List flattened = new ArrayList<>(); + for (List versionsForKey + : ordered.values()) { + flattened.addAll(versionsForKey); + } + this.versionsByFactKey = Collections.unmodifiableMap(ordered); + this.versions = Collections.unmodifiableList(flattened); + + List orderedRelations = + new ArrayList<>(Objects.requireNonNull( + relations, + "relations")); + for (VersionRelation relation : orderedRelations) { + Objects.requireNonNull(relation, "relation"); + } + Collections.sort(orderedRelations); + this.relations = Collections.unmodifiableList(orderedRelations); + } + + public Map> + getVersionsByFactKey() { + return versionsByFactKey; + } + + public List getVersions() { + return versions; + } + + public List getRelations() { + return relations; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof TemporalState)) { + return false; + } + TemporalState that = (TemporalState) object; + return versionsByFactKey.equals(that.versionsByFactKey) + && relations.equals(that.relations); + } + + @Override + public int hashCode() { + return Objects.hash(versionsByFactKey, relations); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalValidationException.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalValidationException.java new file mode 100644 index 000000000..d225adef7 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/TemporalValidationException.java @@ -0,0 +1,38 @@ +/* + * 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.semantics; + +import java.util.Objects; + +/** A validation failure which callers can inspect without parsing messages. */ +public final class TemporalValidationException extends IllegalArgumentException { + + private static final long serialVersionUID = 1L; + private final TemporalErrorCode code; + + public TemporalValidationException(TemporalErrorCode code, String message) { + super(Objects.requireNonNull(code, "code").name() + ": " + message); + this.code = code; + } + + public TemporalErrorCode getCode() { + return code; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/VersionIdentity.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/VersionIdentity.java new file mode 100644 index 000000000..1d4284f96 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/semantics/VersionIdentity.java @@ -0,0 +1,85 @@ +/* + * 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.semantics; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; + +/** Stable serialization of version identity, shared without state transition logic. */ +public final class VersionIdentity { + + private VersionIdentity() { + } + + public static String create( + NormalizedMemoryEvent event, + MemoryFact fact, + MemoryFactVersionStatus status, + TimeInterval validTime, + String sourceVersionId) { + Objects.requireNonNull(event, "event"); + Objects.requireNonNull(fact, "fact"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(validTime, "validTime"); + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + field(digest, "temporal-version-v2"); + field(digest, event.getEventId()); + field(digest, event.getPayloadHash()); + field(digest, fact.getId()); + field(digest, fact.getSubject().getId()); + field(digest, fact.getSubject().getLabel()); + field(digest, fact.getPredicate()); + field(digest, fact.isRelationship() ? "ENTITY_REF" : "LITERAL"); + field(digest, fact.isRelationship() + ? fact.getTarget().get().getId() : fact.getLiteralValue().get()); + field(digest, status.name()); + field(digest, validTime.getStart().toString()); + field(digest, validTime.getEnd().map(Object::toString).orElse(null)); + field(digest, sourceVersionId); + StringBuilder hex = new StringBuilder(); + for (byte value : digest.digest()) { + hex.append(Character.forDigit((value & 0xff) >>> 4, 16)); + hex.append(Character.forDigit(value & 0x0f, 16)); + } + return event.getEventId() + ":version:v2:" + hex; + } + + private static void field(MessageDigest digest, String value) { + if (value == null) { + digest.update((byte) 0); + return; + } + digest.update((byte) 1); + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + digest.update(bytes); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/DecisionTrace.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/DecisionTrace.java new file mode 100644 index 000000000..0e8d4745a --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/DecisionTrace.java @@ -0,0 +1,58 @@ +/* + * 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.trace; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.Objects; + +/** Immutable event-attempt or replay explanation in the versioned JSON protocol. */ +public final class DecisionTrace { + + private final String json; + private final String id; + private final String eventId; + private final String code; + + public DecisionTrace(JsonObject value) { + Objects.requireNonNull(value, "value"); + id = value.get("attemptId").getAsString(); + eventId = value.get("eventId").isJsonNull() + ? null : value.get("eventId").getAsString(); + code = value.get("code").getAsString(); + json = value.toString(); + } + + public String getId() { + return id; + } + + public String getEventId() { + return eventId; + } + + public String getCode() { + return code; + } + + public JsonObject toJson() { + return new JsonParser().parse(json).getAsJsonObject(); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/QueryTrace.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/QueryTrace.java new file mode 100644 index 000000000..addbb2820 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/QueryTrace.java @@ -0,0 +1,52 @@ +/* + * 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.trace; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.Objects; +import org.apache.geaflow.ai.temporal.api.QueryMode; + +/** Immutable query explanation; evidence comes only from the selected versions. */ +public final class QueryTrace { + + private final String json; + private final String id; + private final QueryMode mode; + + public QueryTrace(JsonObject value) { + Objects.requireNonNull(value, "value"); + id = value.get("queryId").getAsString(); + mode = QueryMode.valueOf(value.get("mode").getAsString()); + json = value.toString(); + } + + public String getId() { + return id; + } + + public QueryMode getMode() { + return mode; + } + + public JsonObject toJson() { + return new JsonParser().parse(json).getAsJsonObject(); + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/TraceBuilder.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/TraceBuilder.java new file mode 100644 index 000000000..7ef7b0b91 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/temporal/trace/TraceBuilder.java @@ -0,0 +1,461 @@ +/* + * 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.trace; + +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +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 java.util.Set; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.candidate.FactCandidate; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.candidate.TemporalCandidateService; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.integration.ConflictResolver; +import org.apache.geaflow.ai.temporal.integration.IncrementalTemporalIntegrator; +import org.apache.geaflow.ai.temporal.integration.ResolutionDecision; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; + +/** Derives audit deltas by replaying accepted events, without sharing Oracle transitions. */ +public final class TraceBuilder { + + private static final TemporalJsonCodec CODEC = new TemporalJsonCodec(); + + private TraceBuilder() { + } + + public static Map replay(List events) { + return replayInternal(events, null, 0); + } + + public static Map replay(List events, + RetrievalProfile profile, int topK) { + validateRetrieval(profile, topK); + return replayInternal(events, profile, topK); + } + + private static Map replayInternal(List events, + RetrievalProfile profile, int topK) { + IncrementalTemporalIntegrator replay = new IncrementalTemporalIntegrator(); + Map result = new HashMap<>(); + List ordered = new ArrayList<>(events); + ordered.sort(Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId)); + for (NormalizedMemoryEvent event : ordered) { + TemporalState before = replay.stateSnapshot(); + ResolutionDecision decision = profile == null ? null : new ConflictResolver().resolve( + event, before.getVersionsByFactKey() + .getOrDefault(event.getFactKey(), Collections.emptyList())); + final List retrieved = profile == null ? Collections.emptyList() + : retrieve(before, event, decision, profile, topK); + replay.apply(event); + TemporalState after = replay.stateSnapshot(); + JsonObject trace = base(event); + Map previous = byId(before); + final Map current = byId(after); + if (profile == null) { + TimeInterval candidateInterval = event.getValidTime(); + if (event.getOperation() == MemoryEventOperation.ADD) { + for (MemoryFactVersion version : after.getVersions()) { + if (!previous.containsKey(version.getId()) + && version.getFact().equals(event.getEvent().getFact().get())) { + candidateInterval = version.getValidTime(); + } + } + } + List candidates = candidates(before, event, candidateInterval); + trace.add("candidateVersionIds", ids(candidates)); + trace.add("readVersionIds", ids(event.getOperation() == MemoryEventOperation.ADD + ? candidates : targets(candidates, event))); + List rules = new ArrayList<>(); + if (event.getOperation() == MemoryEventOperation.ADD) { + boolean equal = candidates.stream().anyMatch(v -> + sameValue(v.getFact(), event.getEvent().getFact().get())); + boolean conflict = candidates.stream().anyMatch(v -> + !sameValue(v.getFact(), event.getEvent().getFact().get())); + rules.add(equal ? "MERGE_EQUAL_VALUE" : "ASSERT_NEW"); + if (conflict) { + rules.add("COEXIST_CONFLICT"); + } + } else { + rules.add(event.getOperation() == MemoryEventOperation.CORRECT + ? "CORRECT_TARGET" : "RETRACT_TARGET"); + } + trace.add("rules", strings(rules)); + } else { + explainRetrieval(trace, retrieved, decision, profile, topK); + } + List generated = new ArrayList<>(); + List closed = new ArrayList<>(); + for (MemoryFactVersion version : after.getVersions()) { + MemoryFactVersion old = previous.get(version.getId()); + if (old == null) { + generated.add(version.getId()); + } else if (!old.getTransactionTime().equals(version.getTransactionTime())) { + closed.add(version.getId()); + } + } + trace.add("generatedVersionIds", strings(generated)); + trace.add("closedVersionIds", strings(closed)); + trace.add("discardedVersionIds", difference(previous.keySet(), current.keySet())); + trace.add("addedRelations", relationDifference(after, before)); + trace.add("removedRelations", relationDifference(before, after)); + if (profile != null) { + Set relationInputs = new TreeSet<>(); + for (VersionRelation relation : after.getRelations()) { + if (!before.getRelations().contains(relation)) { + if (previous.containsKey(relation.getFromVersionId())) { + relationInputs.add(relation.getFromVersionId()); + } + if (previous.containsKey(relation.getToVersionId())) { + relationInputs.add(relation.getToVersionId()); + } + } + } + trace.add("relationReadVersionIds", strings(relationInputs)); + trace.addProperty("relationReadSource", "EXISTING_ENDPOINTS_OF_ADDED_RELATIONS"); + } + result.put(event.getEventId(), trace); + } + Set materialized = byId(replay.stateSnapshot()).keySet(); + for (JsonObject trace : result.values()) { + List generated = new ArrayList<>(); + List omitted = new ArrayList<>(); + for (com.google.gson.JsonElement value : trace.getAsJsonArray("generatedVersionIds")) { + String id = value.getAsString(); + (materialized.contains(id) ? generated : omitted).add(id); + } + trace.add("generatedVersionIds", strings(generated)); + trace.add("omittedVersionIds", strings(omitted)); + } + return result; + } + + public static DecisionTrace decision(NormalizedMemoryEvent event, JsonObject semantic, + String id, int revision, String type, String code, + String validationCode, String error) { + JsonObject json = semantic == null ? base(event) + : CODEC.parseObject(CODEC.toJson(semantic)); + json.addProperty("attemptId", id); + json.addProperty("revision", revision); + json.addProperty("traceType", type); + json.addProperty("code", code); + json.addProperty("validationCode", validationCode); + json.addProperty("errorMessage", error); + if (!"ACCEPTED".equals(code)) { + json.add("rules", strings(Collections.singletonList(code))); + json.addProperty("targetResolution", "DUPLICATE_NOOP".equals(code) + ? "NOT_EVALUATED_DUPLICATE" : "BATCH_NOT_COMMITTED"); + } + return new DecisionTrace(json); + } + + public static DecisionTrace decision(NormalizedMemoryEvent event, JsonObject semantic, + String id, int revision, String type, String code, + String validationCode, String error, + RetrievalProfile profile, int topK) { + if (profile == null) { + return decision(event, semantic, id, revision, type, code, validationCode, error); + } + validateRetrieval(profile, topK); + if ("ACCEPTED".equals(code)) { + if (semantic == null || !semantic.has("decision")) { + throw new IllegalArgumentException("Accepted candidate trace requires a resolution"); + } + return decision(event, semantic, id, revision, type, code, validationCode, error); + } + JsonObject json = decision(event, null, id, revision, type, code, validationCode, error) + .toJson(); + json.addProperty("retrievalProfile", profile.name()); + json.addProperty("candidateTopK", topK); + json.addProperty("retrievalStatus", "DUPLICATE_NOOP".equals(code) + ? "NOT_EVALUATED_DUPLICATE" : "BATCH_NOT_COMMITTED"); + json.add("softCandidateVersionIds", new JsonArray()); + json.add("relationReadVersionIds", new JsonArray()); + json.addProperty("relationReadSource", "NOT_EVALUATED"); + json.add("candidateEffectiveInterval", JsonNull.INSTANCE); + json.add("decision", JsonNull.INSTANCE); + return new DecisionTrace(json); + } + + private static List retrieve(TemporalState before, NormalizedMemoryEvent event, + ResolutionDecision decision, + RetrievalProfile profile, int topK) { + try { + return new TemporalCandidateService(profile).eventFacts(before, event, + decision.getEffectiveValidTime(), topK); + } catch (IOException error) { + throw new IllegalStateException("Candidate retrieval failed for " + event.getEventId(), + error); + } + } + + private static void explainRetrieval(JsonObject trace, List candidates, + ResolutionDecision decision, + RetrievalProfile profile, int topK) { + final Set mandatory = new TreeSet<>(); + final List soft = new ArrayList<>(); + final JsonObject candidateScores = new JsonObject(); + for (FactCandidate candidate : candidates) { + String id = candidate.getVersion().getId(); + if (candidate.isMandatory()) { + mandatory.add(id); + } else { + soft.add(id); + } + JsonObject scores = new JsonObject(); + for (Map.Entry entry : candidate.getScores().entrySet()) { + scores.addProperty(entry.getKey(), entry.getValue()); + } + JsonObject ranks = new JsonObject(); + for (Map.Entry entry : candidate.getChannelRanks().entrySet()) { + ranks.addProperty(entry.getKey(), entry.getValue()); + } + JsonObject detail = new JsonObject(); + detail.addProperty("mandatory", candidate.isMandatory()); + detail.addProperty("rank", candidate.getRank()); + detail.addProperty("score", candidate.getScore()); + detail.add("scores", scores); + detail.add("channelRanks", ranks); + candidateScores.add(id, detail); + } + if (!mandatory.equals(new TreeSet<>(decision.getMandatoryVersionIds()))) { + throw new IllegalStateException("Candidate mandatory set differs from resolution"); + } + JsonObject resolved = new JsonObject(); + resolved.addProperty("status", "COMMITTED"); + resolved.add("effectiveValidInterval", interval(decision.getEffectiveValidTime())); + resolved.add("mandatoryVersionIds", strings(decision.getMandatoryVersionIds())); + resolved.add("readVersionIds", strings(decision.getReadVersionIds())); + resolved.add("rules", strings(decision.getRules())); + resolved.addProperty("targetVersionId", decision.getTargetVersionId()); + trace.add("candidateVersionIds", strings(mandatory)); + trace.add("softCandidateVersionIds", strings(soft)); + trace.add("candidateScores", candidateScores); + trace.add("readVersionIds", strings(decision.getReadVersionIds())); + trace.add("rules", strings(decision.getRules())); + trace.addProperty("retrievalStatus", "COMPLETED"); + trace.addProperty("retrievalProfile", profile.name()); + trace.addProperty("candidateTopK", topK); + trace.add("candidateEffectiveInterval", interval(decision.getEffectiveValidTime())); + trace.add("decision", resolved); + } + + private static JsonObject interval(TimeInterval interval) { + JsonObject json = new JsonObject(); + json.addProperty("validFrom", interval.getStart().toString()); + json.addProperty("validTo", interval.getEnd().map(Instant::toString).orElse(null)); + return json; + } + + private static void validateRetrieval(RetrievalProfile profile, int topK) { + Objects.requireNonNull(profile, "profile"); + if (!profile.isCandidateStrategy() || topK <= 0) { + throw new IllegalArgumentException("Use a RULE candidate profile and positive topK"); + } + } + + private static JsonObject base(NormalizedMemoryEvent event) { + JsonObject json = new JsonObject(); + json.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + json.addProperty("attemptId", ""); + json.addProperty("revision", 0); + json.addProperty("traceType", "INPUT"); + json.addProperty("eventId", event.getEventId()); + json.addProperty("payloadHash", event.getPayloadHash()); + json.addProperty("recordedAt", event.getRecordedAt().toString()); + json.addProperty("operation", event.getOperation() == MemoryEventOperation.ADD + ? "ASSERT" : event.getOperation().name()); + json.add("factKey", CODEC.factKeyToJson(event.getFactKey())); + json.addProperty("targetVersionId", event.getTargetVersionId().orElse(null)); + json.addProperty("targetResolution", event.getOperation() == MemoryEventOperation.ADD + ? "NOT_REQUIRED" : "RESOLVED"); + json.addProperty("code", "ACCEPTED"); + json.addProperty("validationCode", "ACCEPTED"); + for (String field : new String[] {"rules", "readVersionIds", "closedVersionIds", + "generatedVersionIds", "omittedVersionIds", "discardedVersionIds", + "candidateVersionIds", "addedRelations", "removedRelations"}) { + json.add(field, new JsonArray()); + } + json.add("candidateScores", JsonNull.INSTANCE); + json.addProperty("retrievalStatus", "NOT_APPLICABLE_WEEK6"); + json.addProperty("affectedFactKeyCount", 1); + json.addProperty("errorMessage", (String) null); + return json; + } + + public static QueryTrace query(String id, int revision, QueryMode mode, FactKey key, + Instant validAt, Instant systemAt, + List versions, TemporalState state) { + JsonObject json = new JsonObject(); + json.addProperty("schemaVersion", NormalizedMemoryEvent.SCHEMA_VERSION); + json.addProperty("queryId", id); + json.addProperty("revision", revision); + json.addProperty("mode", mode.name()); + json.add("factKey", CODEC.factKeyToJson(key)); + json.addProperty("validAt", validAt.toString()); + json.addProperty("systemAt", systemAt == null ? null : systemAt.toString()); + json.add("hitVersionIds", ids(versions)); + List tombstones = new ArrayList<>(); + for (MemoryFactVersion version : state.getVersionsByFactKey() + .getOrDefault(key, Collections.emptyList())) { + if (version.getStatus() == MemoryFactVersionStatus.RETRACTED + && version.getValidTime().contains(validAt) + && (mode == QueryMode.HISTORICAL + || (mode == QueryMode.CURRENT + ? !version.getTransactionTime().getEnd().isPresent() + : version.getTransactionTime().contains(systemAt)))) { + tombstones.add(version); + } + } + json.add("tombstoneVersionIds", ids(tombstones)); + JsonArray evidence = new JsonArray(); + JsonArray payloads = new JsonArray(); + for (MemoryFactVersion version : versions) { + payloads.add(CODEC.versionToJson(key, version, null)); + for (Evidence item : version.getEvidence()) { + JsonObject source = new JsonObject(); + source.addProperty("versionId", version.getId()); + source.addProperty("evidenceId", item.getId()); + source.addProperty("sourceId", item.getSource().getId()); + source.addProperty("sourceName", item.getSource().getName()); + evidence.add(source); + } + } + json.add("evidenceSources", evidence); + json.addProperty("reason", !versions.isEmpty() ? "MATCHED" + : !tombstones.isEmpty() ? "RETRACTED" + : state.getVersionsByFactKey().containsKey(key) ? "OUTSIDE_INTERVALS" : "UNKNOWN_FACT_KEY"); + json.addProperty("resultDigest", digest(CODEC.toJson(payloads))); + return new QueryTrace(json); + } + + private static List candidates(TemporalState state, + NormalizedMemoryEvent event, + TimeInterval interval) { + List candidates = new ArrayList<>(); + for (MemoryFactVersion version : state.getVersionsByFactKey() + .getOrDefault(event.getFactKey(), Collections.emptyList())) { + if (version.getStatus() == MemoryFactVersionStatus.ACTIVE + && !version.getTransactionTime().getEnd().isPresent() + && version.getValidTime().overlaps(interval)) { + candidates.add(version); + } + } + return candidates; + } + + private static List targets(List candidates, + NormalizedMemoryEvent event) { + List result = new ArrayList<>(); + for (MemoryFactVersion version : candidates) { + if (event.getTargetVersionId().get().equals(version.getId())) { + result.add(version); + } + } + return result; + } + + private static boolean sameValue(MemoryFact left, MemoryFact right) { + return left.getLiteralValue().equals(right.getLiteralValue()) + && left.getTarget().map(e -> e.getId()).equals(right.getTarget().map(e -> e.getId())); + } + + private static Map byId(TemporalState state) { + Map result = new HashMap<>(); + for (MemoryFactVersion version : state.getVersions()) { + result.put(version.getId(), version); + } + return result; + } + + private static JsonArray relationDifference(TemporalState left, TemporalState right) { + JsonArray result = new JsonArray(); + for (VersionRelation relation : left.getRelations()) { + if (!right.getRelations().contains(relation)) { + result.add(CODEC.relationToJson(relation)); + } + } + return result; + } + + private static JsonArray difference(Set left, Set right) { + Set result = new TreeSet<>(left); + result.removeAll(right); + return strings(result); + } + + public static JsonArray ids(List versions) { + JsonArray values = new JsonArray(); + for (MemoryFactVersion version : versions) { + values.add(new JsonPrimitive(version.getId())); + } + return values; + } + + private static JsonArray strings(Iterable values) { + Set ordered = new TreeSet<>(); + for (String value : values) { + ordered.add(value); + } + JsonArray result = new JsonArray(); + for (String value : ordered) { + result.add(new JsonPrimitive(value)); + } + return result; + } + + private static String digest(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + for (byte item : hash) { + result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); + } + return result.toString(); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 unavailable", error); + } + } +} 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, + IncrementalAlgorithmUserFunction { + + private AlgorithmRuntimeContext context; + + @Override + public void init( + AlgorithmRuntimeContext context, + Object[] params) { + this.context = context; + } + + @Override + public void process( + RowVertex vertex, + Optional updatedValues, + Iterator messages) { + if (context.getCurrentIterationId() == 1L) { + processDynamicEdges(updatedValues); + } else { + processMessages(updatedValues, messages); + } + } + + private void processDynamicEdges(Optional updatedValues) { + int batchCount = updatedValues + .map(value -> integerField(value, 0)) + .orElse(0) + 1; + int receivedMessageCount = updatedValues + .map(value -> integerField(value, 3)) + .orElse(0); + List dynamicEdges = + context.loadDynamicEdges(EdgeDirection.OUT); + + context.updateVertexValue(ObjectRow.create( + batchCount, + updatedValues.isPresent(), + dynamicEdges.size(), + receivedMessageCount)); + for (RowEdge edge : dynamicEdges) { + context.sendMessage(edge.getTargetId(), 1); + } + } + + private void processMessages( + Optional updatedValues, + Iterator messages) { + int receivedNow = 0; + while (messages.hasNext()) { + receivedNow += messages.next(); + } + if (receivedNow == 0 || !updatedValues.isPresent()) { + return; + } + + Row value = updatedValues.get(); + context.updateVertexValue(ObjectRow.create( + integerField(value, 0), + booleanField(value, 1), + integerField(value, 2), + integerField(value, 3) + receivedNow)); + } + + @Override + public void finish( + RowVertex vertex, + Optional updatedValues) { + if (!updatedValues.isPresent()) { + return; + } + Row value = updatedValues.get(); + context.take(ObjectRow.create( + vertex.getId(), + integerField(value, 0), + booleanField(value, 1), + integerField(value, 2), + integerField(value, 3))); + } + + @Override + public StructType getOutputType(GraphSchema graphSchema) { + return new StructType( + new TableField( + "vertex_id", + graphSchema.getIdType(), + false), + new TableField( + "batch_count", + IntegerType.INSTANCE, + false), + new TableField( + "had_previous_value", + BooleanType.INSTANCE, + false), + new TableField( + "dynamic_edge_count", + IntegerType.INSTANCE, + false), + new TableField( + "received_message_count", + IntegerType.INSTANCE, + false)); + } + + private static int integerField(Row value, int index) { + return (int) value.getField(index, IntegerType.INSTANCE); + } + + private static boolean booleanField(Row value, int index) { + return (boolean) value.getField(index, BooleanType.INSTANCE); + } +} diff --git a/geaflow-ai/src/main/resources/temporal/example-events.jsonl b/geaflow-ai/src/main/resources/temporal/example-events.jsonl new file mode 100644 index 000000000..a6dccb371 --- /dev/null +++ b/geaflow-ai/src/main/resources/temporal/example-events.jsonl @@ -0,0 +1,5 @@ +{"schemaVersion":2,"id":"city-01-assert","operation":"ASSERT","subject":{"id":"alice","label":"person"},"predicate":"city","scope":"profile","factId":"fact-beijing","value":{"kind":"LITERAL","value":"Beijing"},"validFrom":"2024-01-01T00:00:00Z","validTo":"2024-02-01T00:00:00Z","recordedAt":"2024-03-01T00:00:00Z","evidence":[{"id":"city-evidence-1","source":{"id":"registry","name":"City Registry"},"content":"The original record places Alice in Beijing."}],"target":null} +{"schemaVersion":2,"id":"city-02-evidence","operation":"ASSERT","subject":{"id":"alice","label":"person"},"predicate":"city","scope":"profile","factId":"fact-beijing","value":{"kind":"LITERAL","value":"Beijing"},"validFrom":"2024-01-10T00:00:00Z","validTo":"2024-02-01T00:00:00Z","recordedAt":"2024-03-02T00:00:00Z","evidence":[{"id":"city-evidence-2","source":{"id":"registry","name":"City Registry"},"content":"An independent record confirms Beijing."}],"target":null} +{"schemaVersion":2,"id":"city-03-correct","operation":"CORRECT","subject":{"id":"alice","label":"person"},"predicate":"city","scope":"profile","factId":"fact-shanghai","value":{"kind":"LITERAL","value":"Shanghai"},"validFrom":"2024-01-10T00:00:00Z","validTo":"2024-01-25T00:00:00Z","recordedAt":"2024-03-03T00:00:00Z","evidence":[{"id":"city-evidence-3","source":{"id":"registry","name":"City Registry"},"content":"Correct the specified Beijing branch to Shanghai for this interval."}],"target":"city-02-evidence:version:v2:561ffcd3a62c259228f5910d0eaebf86808cbe3703b4b5f8cc0ee49308b874a9"} +{"schemaVersion":2,"id":"city-04-conflict","operation":"ASSERT","subject":{"id":"alice","label":"person"},"predicate":"city","scope":"profile","factId":"fact-tianjin","value":{"kind":"LITERAL","value":"Tianjin"},"validFrom":"2024-01-12T00:00:00Z","validTo":"2024-01-22T00:00:00Z","recordedAt":"2024-03-04T00:00:00Z","evidence":[{"id":"city-evidence-4","source":{"id":"travel-log","name":"Travel Log"},"content":"A conflicting record places Alice in Tianjin."}],"target":null} +{"schemaVersion":2,"id":"city-05-retract","operation":"RETRACT","subject":{"id":"alice","label":null},"predicate":"city","scope":"profile","factId":"fact-shanghai","value":null,"validFrom":"2024-01-15T00:00:00Z","validTo":"2024-01-20T00:00:00Z","recordedAt":"2024-03-05T00:00:00Z","evidence":[{"id":"city-evidence-5","source":{"id":"registry","name":"City Registry"},"content":"Withdraw only the specified Shanghai branch for this interval."}],"target":"city-03-correct:version:v2:12b641ed5d34dd972f5f25b29d637f33584dd5b7a14449efa7aa6e8f853b166d"} diff --git a/geaflow-ai/src/main/resources/temporal/example-queries.jsonl b/geaflow-ai/src/main/resources/temporal/example-queries.jsonl new file mode 100644 index 000000000..fbf935b84 --- /dev/null +++ b/geaflow-ai/src/main/resources/temporal/example-queries.jsonl @@ -0,0 +1,13 @@ +{"mode":"AS_OF","validAt":"2024-01-16T00:00:00Z","systemAt":"2024-03-01T00:00:00Z","values":["Beijing"],"evidenceIds":["city-evidence-1"]} +{"mode":"AS_OF","validAt":"2024-01-16T00:00:00Z","systemAt":"2024-03-02T00:00:00Z","values":["Beijing"],"evidenceIds":["city-evidence-1","city-evidence-2"]} +{"mode":"AS_OF","validAt":"2024-01-16T00:00:00Z","systemAt":"2024-03-03T00:00:00Z","values":["Shanghai"],"evidenceIds":["city-evidence-3"]} +{"mode":"AS_OF","validAt":"2024-01-16T00:00:00Z","systemAt":"2024-03-04T00:00:00Z","values":["Shanghai","Tianjin"],"evidenceIds":["city-evidence-3","city-evidence-4"]} +{"mode":"AS_OF","validAt":"2024-01-16T00:00:00Z","systemAt":"2024-03-05T00:00:00Z","values":["Tianjin"],"evidenceIds":["city-evidence-4"]} +{"mode":"CURRENT","validAt":"2024-01-09T00:00:00Z","systemAt":null,"values":["Beijing"],"evidenceIds":["city-evidence-1","city-evidence-2"]} +{"mode":"CURRENT","validAt":"2024-01-11T00:00:00Z","systemAt":null,"values":["Shanghai"],"evidenceIds":["city-evidence-3"]} +{"mode":"CURRENT","validAt":"2024-01-16T00:00:00Z","systemAt":null,"values":["Tianjin"],"evidenceIds":["city-evidence-4"]} +{"mode":"CURRENT","validAt":"2024-01-21T00:00:00Z","systemAt":null,"values":["Shanghai","Tianjin"],"evidenceIds":["city-evidence-3","city-evidence-4"]} +{"mode":"CURRENT","validAt":"2024-01-22T00:00:00Z","systemAt":null,"values":["Shanghai"],"evidenceIds":["city-evidence-3"]} +{"mode":"CURRENT","validAt":"2024-01-26T00:00:00Z","systemAt":null,"values":["Beijing"],"evidenceIds":["city-evidence-1","city-evidence-2"]} +{"mode":"CURRENT","validAt":"2024-02-01T00:00:00Z","systemAt":null,"values":[],"evidenceIds":[]} +{"mode":"HISTORICAL","validAt":"2024-01-16T00:00:00Z","systemAt":null,"values":["Beijing","Beijing","Shanghai","Shanghai","Tianjin"],"evidenceIds":["city-evidence-1","city-evidence-2","city-evidence-3","city-evidence-4","city-evidence-5"]} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/TemporalCoreAcceptanceTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/TemporalCoreAcceptanceTest.java new file mode 100644 index 000000000..d87da1508 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/TemporalCoreAcceptanceTest.java @@ -0,0 +1,214 @@ +/* + * 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; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.integration.IncrementalTemporalIntegrator; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.query.BitemporalQuery; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * A fixed, end-to-end acceptance example for the Phase 1 temporal core. + */ +public class TemporalCoreAcceptanceTest { + + private static final MemoryEntity ALICE = + new MemoryEntity("alice", "person"); + private static final FactKey CITY_KEY = + new FactKey("alice", "city", "profile"); + private static final Source PROFILE = + new Source("source-profile", "profile"); + + private final EventNormalizer normalizer = new EventNormalizer(); + private final BitemporalQuery query = new BitemporalQuery(); + + @Test + public void testCorrectionRetractionAndReplayAreIndependentlyVerifiable() { + NormalizedMemoryEvent added = normalize(MemoryEvent.add( + "event-1", + cityFact("fact-beijing", "Beijing"), + TimeInterval.unboundedFrom(time("2024-01-01T00:00:00Z")), + time("2024-02-01T00:00:00Z"), + evidence("evidence-1", "Alice lived in Beijing."))); + FullReplayOracle oracle = new FullReplayOracle(); + String originalId = oracle.replayNormalized( + Collections.singletonList(added)).getVersions().get(0).getId(); + NormalizedMemoryEvent corrected = normalize(MemoryEvent.correct( + "event-2", + cityFact("fact-shanghai", "Shanghai"), + interval("2024-04-01T00:00:00Z", "2024-07-01T00:00:00Z"), + time("2024-05-01T00:00:00Z"), + evidence("evidence-2", "Alice moved to Shanghai.")) + .targeting(originalId)); + String correctedId = oracle.replayNormalized( + Arrays.asList(added, corrected)).getVersions().stream() + .filter(version -> version.getFact().getLiteralValue().get() + .equals("Shanghai")) + .findFirst().get().getId(); + NormalizedMemoryEvent retracted = normalize(MemoryEvent.retract( + "event-3", + "fact-shanghai", + interval("2024-06-01T00:00:00Z", "2024-07-01T00:00:00Z"), + time("2024-06-15T00:00:00Z"), + evidence("evidence-3", "The later Shanghai claim was withdrawn.")) + .targeting(correctedId)); + List events = + Arrays.asList(added, corrected, retracted); + + TemporalState oracleState = + oracle.replayNormalized(events); + IncrementalTemporalIntegrator incremental = + new IncrementalTemporalIntegrator(); + for (NormalizedMemoryEvent event : events) { + incremental.apply(event); + } + TemporalState actual = incremental.stateSnapshot(); + + Assertions.assertEquals(oracleState, actual); + Assertions.assertEquals(6, actual.getVersions().size()); + Assertions.assertEquals(1, countStatus( + actual, MemoryFactVersionStatus.RETRACTED)); + Assertions.assertEquals(5, actual.getRelations().size()); + Assertions.assertTrue(actual.getRelations().stream().allMatch( + relation -> relation.getType() == VersionRelationType.SUPERSEDES)); + + MemoryFactVersion tombstone = actual.getVersions().stream() + .filter(version -> version.getStatus() + == MemoryFactVersionStatus.RETRACTED) + .findFirst() + .get(); + Assertions.assertEquals( + time("2024-06-01T00:00:00Z"), + tombstone.getValidTime().getStart()); + Assertions.assertEquals( + time("2024-07-01T00:00:00Z"), + tombstone.getValidTime().getEnd().get()); + Assertions.assertEquals( + time("2024-06-15T00:00:00Z"), + tombstone.getTransactionTime().getStart()); + Assertions.assertFalse( + tombstone.getTransactionTime().getEnd().isPresent()); + + assertValues(actual, "2024-04-15T00:00:00Z", + "2024-04-30T00:00:00Z", "Beijing"); + assertValues(actual, "2024-04-15T00:00:00Z", + "2024-05-15T00:00:00Z", "Shanghai"); + assertValues(actual, "2024-03-01T00:00:00Z", + "2024-06-20T00:00:00Z", "Beijing"); + assertValues(actual, "2024-06-20T00:00:00Z", + "2024-06-01T00:00:00Z", "Shanghai"); + assertValues(actual, "2024-06-20T00:00:00Z", + "2024-06-20T00:00:00Z"); + assertValues(actual, "2024-07-01T00:00:00Z", + "2024-06-20T00:00:00Z", "Beijing"); + assertValues(actual, "2024-06-01T00:00:00Z", + "2024-06-15T00:00:00Z"); + + MemoryFactVersion retainedBeijing = query.query( + actual.getVersions(), + time("2024-03-01T00:00:00Z"), + time("2024-06-20T00:00:00Z")).get(0); + Assertions.assertEquals( + "evidence-1", retainedBeijing.getEvidence().get(0).getId()); + Assertions.assertEquals( + "source-profile", + retainedBeijing.getEvidence().get(0).getSource().getId()); + + TemporalState beforeDuplicate = incremental.stateSnapshot(); + incremental.apply(corrected); + Assertions.assertEquals(beforeDuplicate, incremental.stateSnapshot()); + + NormalizedMemoryEvent reusedId = normalize(MemoryEvent.correct( + "event-2", + cityFact("fact-tianjin", "Tianjin"), + interval("2024-04-01T00:00:00Z", "2024-07-01T00:00:00Z"), + time("2024-05-01T00:00:00Z"), + evidence("evidence-2", "Alice moved to Tianjin.")) + .targeting(originalId)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> incremental.apply(reusedId)); + Assertions.assertEquals(beforeDuplicate, incremental.stateSnapshot()); + } + + private NormalizedMemoryEvent normalize(MemoryEvent event) { + return normalizer.normalize(event, CITY_KEY); + } + + private void assertValues( + TemporalState state, + String validAt, + String transactionAt, + String... expected) { + List actual = query.query( + state.getVersions(), + time(validAt), + time(transactionAt)).stream() + .map(version -> version.getFact().getLiteralValue().get()) + .sorted() + .collect(Collectors.toList()); + List expectedValues = Arrays.asList(expected); + Collections.sort(expectedValues); + Assertions.assertEquals(expectedValues, actual); + } + + private static long countStatus( + TemporalState state, + MemoryFactVersionStatus status) { + return state.getVersions().stream() + .filter(version -> version.getStatus() == status) + .count(); + } + + private static MemoryFact cityFact(String id, String city) { + return MemoryFact.attribute(id, ALICE, "city", city); + } + + private static List evidence(String id, String content) { + return Collections.singletonList(new Evidence(id, PROFILE, 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); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/TemporalGoldTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/TemporalGoldTest.java new file mode 100644 index 000000000..1e1f41e3c --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/TemporalGoldTest.java @@ -0,0 +1,739 @@ +/* + * 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; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.QueryResult; +import org.apache.geaflow.ai.temporal.api.ReplayResult; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.integration.IncrementalTemporalIntegrator; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.query.TemporalQueryService; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Hand-authored semantic expectations, independent of either replay implementation. */ +public class TemporalGoldTest { + + private static final List RESOURCES = Arrays.asList( + "01-assert-entity-reference", "02-identical-duplicate", "03-event-id-reuse", + "04-evidence-merge", "05-conflict-coexistence", "06-adjacent-intervals", + "07-partial-correction", "08-partial-retraction", "09-valid-time-late", + "10-system-time-disorder", "11-same-timestamp", "12-invalid-target-atomicity"); + + private static final Comparator END_ORDER = + Comparator.nullsLast(Comparator.naturalOrder()); + + // Check the documented query order independently of the query service. + private static final Comparator QUERY_ORDER = + Comparator.comparing((MemoryFactVersion v) -> v.getValidTime().getStart()) + .thenComparing(v -> v.getValidTime().getEnd().orElse(null), END_ORDER) + .thenComparing(v -> v.getTransactionTime().getStart()) + .thenComparing(v -> v.getTransactionTime().getEnd().orElse(null), END_ORDER) + .thenComparing(MemoryFactVersion::getId); + + @Test + public void testHandAuthoredGoldStreams() throws Exception { + for (String name : RESOURCES) { + try (InputStream input = getClass().getResourceAsStream( + "/temporal/gold/" + name + ".json")) { + Assertions.assertNotNull(input, name); + try (Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + Fixture fixture = new Gson().fromJson(reader, Fixture.class); + Assertions.assertEquals(name, fixture.name); + TemporalMemoryFacade legacy = new GoldCase(fixture, null).verify(); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, + RetrievalProfile.RULE_ALL)) { + TemporalMemoryFacade actual = new GoldCase(fixture, profile).verify(); + Assertions.assertEquals(legacy.getSnapshot(), actual.getSnapshot(), + name + ": candidate strategy changed committed facts or ledger " + profile); + Assertions.assertEquals(semanticTraces(legacy), semanticTraces(actual), + name + ": candidate strategy changed semantic decisions " + profile); + Assertions.assertEquals(legacy.getQueryTraces().size(), + actual.getQueryTraces().size(), name); + for (int index = 0; index < legacy.getQueryTraces().size(); index++) { + Assertions.assertEquals(legacy.getQueryTraces().get(index).toJson(), + actual.getQueryTraces().get(index).toJson(), + name + ": candidate strategy changed query explanation " + profile); + } + } + } + } + } + } + + private static List semanticTraces(TemporalMemoryFacade facade) { + final List result = new ArrayList<>(); + for (DecisionTrace trace : facade.getDecisionTraces()) { + JsonObject source = trace.toJson(); + JsonObject semantic = new JsonObject(); + for (String field : Arrays.asList("schemaVersion", "attemptId", "revision", "traceType", + "eventId", "payloadHash", "recordedAt", "operation", "factKey", "targetVersionId", + "targetResolution", "code", "validationCode", "errorMessage", "affectedFactKeyCount", + "addedRelations", "removedRelations")) { + semantic.add(field, source.get(field)); + } + for (String field : Arrays.asList("rules", "readVersionIds", "closedVersionIds", + "generatedVersionIds", "omittedVersionIds", "discardedVersionIds")) { + TreeSet values = new TreeSet<>(); + for (JsonElement value : source.getAsJsonArray(field)) { + Assertions.assertTrue(values.add(value.getAsString()), field); + } + JsonArray ordered = new JsonArray(); + for (String value : values) { + ordered.add(new JsonPrimitive(value)); + } + semantic.add(field, ordered); + } + result.add(semantic); + } + return result; + } + + private static final class GoldCase { + + private final Fixture fixture; + private final RetrievalProfile profile; + private final EventNormalizer normalizer = new EventNormalizer(); + private final Map events = new HashMap<>(); + private final Map definitions = new HashMap<>(); + private final Map normalized = new HashMap<>(); + private final Map identities = new HashMap<>(); + + private GoldCase(Fixture fixture, RetrievalProfile profile) { + this.fixture = fixture; + this.profile = profile; + Assertions.assertEquals(2, fixture.schemaVersion, fixture.name); + for (EventSpec event : fixture.events) { + Assertions.assertNull(events.put(event.alias, event), event.alias); + } + for (VersionSpec version : fixture.expected.identities) { + Assertions.assertNull(definitions.put(version.alias, version), version.alias); + } + for (VersionSpec version : fixture.expected.versions) { + Assertions.assertNull(definitions.put(version.alias, version), version.alias); + } + } + + private TemporalMemoryFacade verify() { + IncrementalTemporalIntegrator incremental = new IncrementalTemporalIntegrator(); + FullReplayOracle oracle = new FullReplayOracle(); + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + List committed = new ArrayList<>(); + int start = 0; + for (BatchSpec batch : fixture.batches) { + Assertions.assertTrue(batch.endExclusive > start + && batch.endExclusive <= fixture.events.size(), fixture.name); + List attempted = new ArrayList<>(); + for (int index = start; index < batch.endExclusive; index++) { + attempted.add(normalize(fixture.events.get(index).alias)); + } + TemporalState before = incremental.stateSnapshot(); + CanonicalSnapshot facadeBefore = facade.getSnapshot(); + if (batch.expectedFailure != null) { + TemporalValidationException error = Assertions.assertThrows( + TemporalValidationException.class, () -> incremental.applyBatch(attempted), + fixture.name); + Assertions.assertEquals(batch.expectedFailure, error.getCode().name(), + fixture.name); + Assertions.assertEquals(before, incremental.stateSnapshot(), + fixture.name + ": rejected batch changed committed state"); + List rejectedReplay = new ArrayList<>(committed); + rejectedReplay.addAll(attempted); + TemporalValidationException oracleError = Assertions.assertThrows( + TemporalValidationException.class, + () -> oracle.replayNormalized(rejectedReplay), fixture.name); + Assertions.assertEquals(batch.expectedFailure, oracleError.getCode().name(), + fixture.name); + int traceCount = facade.getDecisionTraces().size(); + TemporalValidationException facadeError = Assertions.assertThrows( + TemporalValidationException.class, () -> apply(facade, attempted), + fixture.name); + Assertions.assertEquals(batch.expectedFailure, facadeError.getCode().name()); + Assertions.assertEquals(before, facade.getSnapshot().getState(), fixture.name); + Assertions.assertEquals(facadeBefore, facade.getSnapshot(), fixture.name); + Assertions.assertEquals(traceCount + attempted.size(), + facade.getDecisionTraces().size(), fixture.name); + for (DecisionTrace trace : facade.getDecisionTraces().subList( + traceCount, facade.getDecisionTraces().size())) { + JsonObject json = trace.toJson(); + Assertions.assertEquals("BATCH_ROLLED_BACK", json.get("code").getAsString()); + Assertions.assertEquals(batch.expectedFailure, + json.get("validationCode").getAsString()); + Assertions.assertEquals(Collections.emptySet(), strings(json, + "generatedVersionIds")); + } + } else { + List decisions = incremental.applyBatch(attempted); + ReplayResult result = apply(facade, attempted); + attempted.sort(Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId)); + Assertions.assertEquals(batch.decisions.size(), decisions.size(), fixture.name); + Assertions.assertEquals(batch.decisions.size(), + result.getDecisionTraces().size(), fixture.name); + for (int index = 0; index < decisions.size(); index++) { + DecisionSpec expected = batch.decisions.get(index); + Assertions.assertEquals(normalize(expected.event), attempted.get(index), + fixture.name + ": decision event order"); + Assertions.assertEquals(expected.code, decisions.get(index).name(), + fixture.name + ": decision code"); + JsonObject trace = result.getDecisionTraces().get(index).toJson(); + Assertions.assertEquals(normalize(expected.event).getEventId(), + trace.get("eventId").getAsString(), fixture.name); + Assertions.assertEquals(expected.code, trace.get("code").getAsString(), + fixture.name); + } + committed.addAll(attempted); + Assertions.assertEquals(oracle.replayNormalized(committed), + incremental.stateSnapshot(), fixture.name + ": successful batch diff"); + Assertions.assertEquals(incremental.stateSnapshot(), + facade.getSnapshot().getState(), fixture.name + ": facade batch diff"); + Assertions.assertTrue(facade.validateInvariants().isValid(), fixture.name); + } + start = batch.endExclusive; + } + Assertions.assertEquals(fixture.events.size(), start, fixture.name); + TemporalState expected = expectedState(); + Assertions.assertEquals(expected, incremental.stateSnapshot(), + fixture.name + ": hand-authored incremental expectation"); + Assertions.assertEquals(expected, oracle.replayNormalized(committed), + fixture.name + ": hand-authored Oracle expectation"); + Collections.reverse(committed); + Assertions.assertEquals(expected, oracle.replayNormalized(committed), + fixture.name + ": reversed input changes Oracle result"); + Assertions.assertEquals(expected, facade.getSnapshot().getState(), fixture.name); + verifyTraces(facade); + verifyQueries(incremental.stateSnapshot(), facade); + return facade; + } + + private ReplayResult apply(TemporalMemoryFacade facade, + List attempted) { + return profile == null ? facade.applyBatch(attempted) + : facade.applyBatch(attempted, profile, 20); + } + + private void verifyTraces(TemporalMemoryFacade facade) { + Map latestAccepted = new HashMap<>(); + Set attemptIds = new HashSet<>(); + for (DecisionTrace trace : facade.getDecisionTraces()) { + JsonObject json = trace.toJson(); + Assertions.assertTrue(attemptIds.add(trace.getId()), fixture.name); + Assertions.assertEquals(trace.getId(), json.get("attemptId").getAsString()); + verifyRetrievalTrace(json); + Assertions.assertTrue(json.has("revision")); + if ("ACCEPTED".equals(json.get("code").getAsString())) { + latestAccepted.put(trace.getEventId(), trace); + } + } + Assertions.assertEquals(facade.getSnapshot().getEvents().size(), + latestAccepted.size(), fixture.name); + for (NormalizedMemoryEvent event : facade.getSnapshot().getEvents()) { + DecisionTrace trace = latestAccepted.get(event.getEventId()); + Assertions.assertNotNull(trace, fixture.name + ": missing accepted trace"); + Assertions.assertTrue(facade.getTrace(event.getEventId()).contains(trace)); + JsonObject json = trace.toJson(); + Assertions.assertEquals(event.getPayloadHash(), + json.get("payloadHash").getAsString()); + Assertions.assertEquals(event.getRecordedAt().toString(), + json.get("recordedAt").getAsString()); + if (profile != null) { + verifyEffectiveInterval(event, json); + } + Set generated = new HashSet<>(); + Set omitted = new HashSet<>(); + Set closed = new HashSet<>(); + for (VersionSpec version : fixture.expected.versions) { + if (normalize(version.event).getEventId().equals(event.getEventId())) { + generated.add(identity(version.alias)); + Assertions.assertEquals(event.getEventId(), facade.getSnapshot() + .getGeneratingEventIds().get(identity(version.alias)), fixture.name); + } + if (version.systemTo != null + && time(version.systemTo).equals(event.getRecordedAt())) { + closed.add(identity(version.alias)); + } + } + for (VersionSpec version : fixture.expected.identities) { + if (normalize(version.event).getEventId().equals(event.getEventId())) { + omitted.add(identity(version.alias)); + } + } + Assertions.assertEquals(generated, strings(json, "generatedVersionIds"), + fixture.name + ": materialized generated versions for " + event.getEventId()); + Assertions.assertEquals(omitted, strings(json, "omittedVersionIds"), + fixture.name + ": same-time omitted versions for " + event.getEventId()); + // A closed system interval is an observable transition at this event time. + if (facade.getSnapshot().getEvents().stream().filter(candidate -> + candidate.getRecordedAt().equals(event.getRecordedAt())).count() == 1) { + Assertions.assertEquals(closed, strings(json, "closedVersionIds"), + fixture.name + ": closed versions for " + event.getEventId()); + } + if (event.getTargetVersionId().isPresent()) { + Assertions.assertEquals(event.getTargetVersionId().get(), + json.get("targetVersionId").getAsString(), fixture.name); + Assertions.assertTrue(strings(json, "readVersionIds") + .contains(event.getTargetVersionId().get()), fixture.name); + } + Assertions.assertFalse(strings(json, "rules").isEmpty(), fixture.name); + } + } + + private void verifyRetrievalTrace(JsonObject json) { + if (profile == null) { + Assertions.assertEquals("NOT_APPLICABLE_WEEK6", + json.get("retrievalStatus").getAsString()); + Assertions.assertTrue(json.get("candidateScores").isJsonNull()); + Assertions.assertFalse(json.has("decision")); + Assertions.assertFalse(json.has("retrievalProfile")); + return; + } + Assertions.assertEquals(profile.name(), json.get("retrievalProfile").getAsString()); + Assertions.assertEquals(20, json.get("candidateTopK").getAsInt()); + String code = json.get("code").getAsString(); + if (!"ACCEPTED".equals(code)) { + Assertions.assertEquals("DUPLICATE_NOOP".equals(code) + ? "NOT_EVALUATED_DUPLICATE" : "BATCH_NOT_COMMITTED", + json.get("retrievalStatus").getAsString()); + Assertions.assertTrue(json.get("candidateScores").isJsonNull()); + Assertions.assertTrue(json.get("decision").isJsonNull()); + Assertions.assertTrue(json.get("candidateEffectiveInterval").isJsonNull()); + for (String field : Arrays.asList("candidateVersionIds", "softCandidateVersionIds", + "readVersionIds", "relationReadVersionIds", "closedVersionIds", + "generatedVersionIds", "omittedVersionIds", "discardedVersionIds", + "addedRelations", "removedRelations")) { + Assertions.assertEquals(0, json.getAsJsonArray(field).size(), + fixture.name + ": uncommitted trace published " + field); + } + return; + } + Assertions.assertEquals("COMPLETED", json.get("retrievalStatus").getAsString()); + final Set mandatory = strings(json, "candidateVersionIds"); + final Set soft = strings(json, "softCandidateVersionIds"); + Assertions.assertTrue(Collections.disjoint(mandatory, soft), fixture.name); + Assertions.assertTrue(soft.size() <= 20, fixture.name); + if (profile == RetrievalProfile.RULE_ONLY) { + Assertions.assertTrue(soft.isEmpty(), fixture.name); + } + final Set scored = new HashSet<>(); + final Set ranks = new HashSet<>(); + for (Map.Entry entry + : json.getAsJsonObject("candidateScores").entrySet()) { + Assertions.assertTrue(scored.add(entry.getKey()), fixture.name); + JsonObject detail = entry.getValue().getAsJsonObject(); + Assertions.assertEquals(mandatory.contains(entry.getKey()), + detail.get("mandatory").getAsBoolean(), fixture.name); + int rank = detail.get("rank").getAsInt(); + Assertions.assertTrue(rank > 0 && ranks.add(rank), fixture.name); + double score = detail.get("score").getAsDouble(); + Assertions.assertTrue(Double.isFinite(score) && score >= 0, fixture.name); + JsonObject channels = detail.getAsJsonObject("scores"); + if (mandatory.contains(entry.getKey())) { + Assertions.assertEquals(1.0, channels.get("RULE").getAsDouble()); + Assertions.assertTrue(detail.getAsJsonObject("channelRanks").has("RULE")); + } else { + Assertions.assertFalse(channels.has("RULE"), fixture.name); + } + if (!profile.usesBm25()) { + Assertions.assertFalse(channels.has("BM25"), fixture.name); + } + if (!profile.usesMinHash()) { + Assertions.assertFalse(channels.has("MINHASH"), fixture.name); + } + for (Map.Entry channel : channels.entrySet()) { + double channelScore = channel.getValue().getAsDouble(); + Assertions.assertTrue(Double.isFinite(channelScore) && channelScore >= 0, + fixture.name); + } + for (Map.Entry channel + : detail.getAsJsonObject("channelRanks").entrySet()) { + Assertions.assertTrue(channels.has(channel.getKey()), fixture.name); + Assertions.assertTrue(channel.getValue().getAsInt() > 0, fixture.name); + } + } + Set expectedScored = new HashSet<>(mandatory); + expectedScored.addAll(soft); + Assertions.assertEquals(expectedScored, scored, fixture.name); + for (int rank = 1; rank <= scored.size(); rank++) { + Assertions.assertTrue(ranks.contains(rank), fixture.name); + } + JsonObject decision = json.getAsJsonObject("decision"); + Assertions.assertEquals("COMMITTED", decision.get("status").getAsString()); + Assertions.assertEquals(mandatory, strings(decision, "mandatoryVersionIds")); + Assertions.assertEquals(strings(json, "readVersionIds"), + strings(decision, "readVersionIds")); + Assertions.assertEquals(strings(json, "rules"), strings(decision, "rules")); + Assertions.assertEquals(json.get("targetVersionId"), decision.get("targetVersionId")); + Assertions.assertEquals(json.get("candidateEffectiveInterval"), + decision.get("effectiveValidInterval")); + Assertions.assertTrue(Collections.disjoint(soft, strings(json, "readVersionIds")), + fixture.name + ": soft candidates became authoritative reads"); + for (JsonElement element : json.getAsJsonArray("addedRelations")) { + JsonObject relation = element.getAsJsonObject(); + Assertions.assertFalse(soft.contains(relation.get("from").getAsString()), + fixture.name + ": soft candidate created a relation"); + Assertions.assertFalse(soft.contains(relation.get("to").getAsString()), + fixture.name + ": soft candidate created a relation"); + } + } + + private void verifyEffectiveInterval(NormalizedMemoryEvent event, JsonObject json) { + TimeInterval expected = null; + if (event.getTargetVersionId().isPresent()) { + for (VersionSpec version : definitions.values()) { + if (identity(version.alias).equals(event.getTargetVersionId().get())) { + expected = interval(version.validFrom, version.validTo) + .intersection(event.getValidTime()).get(); + } + } + } else { + for (VersionSpec version : definitions.values()) { + if (normalize(version.event).getEventId().equals(event.getEventId())) { + Assertions.assertNull(expected, + fixture.name + ": ambiguous hand-authored ASSERT interval"); + expected = interval(version.validFrom, version.validTo); + } + } + } + Assertions.assertNotNull(expected, + fixture.name + ": no hand-authored resolution interval for " + event.getEventId()); + JsonObject actual = json.getAsJsonObject("candidateEffectiveInterval"); + Assertions.assertEquals(expected.getStart().toString(), + actual.get("validFrom").getAsString(), fixture.name); + Assertions.assertEquals(expected.getEnd().map(Instant::toString).orElse(null), + actual.get("validTo").isJsonNull() ? null : actual.get("validTo").getAsString(), + fixture.name); + } + + private TemporalState expectedState() { + Map> byKey = new TreeMap<>(); + for (VersionSpec spec : fixture.expected.versions) { + FactKey key = new FactKey(spec.subject.id, spec.predicate, spec.scope); + byKey.computeIfAbsent(key, ignored -> new ArrayList<>()).add(version(spec)); + } + List relations = new ArrayList<>(); + for (RelationSpec spec : fixture.expected.relations) { + relations.add(new VersionRelation(VersionRelationType.valueOf(spec.type), + identity(spec.from), identity(spec.to))); + } + return new TemporalState(byKey, relations); + } + + private void verifyQueries(TemporalState state, TemporalMemoryFacade facade) { + TemporalQueryService query = new TemporalQueryService(); + List modes = new ArrayList<>(); + for (QuerySpec spec : fixture.expected.queries) { + modes.add(spec.mode); + FactKey key = new FactKey(spec.subjectId, spec.predicate, spec.scope); + List actual; + if ("CURRENT".equals(spec.mode)) { + actual = query.current(state, key, time(spec.validAt)); + } else if ("HISTORICAL".equals(spec.mode)) { + actual = query.historical(state, key, time(spec.validAt)); + } else if ("AS_OF".equals(spec.mode)) { + actual = query.asOf(state, key, time(spec.validAt), time(spec.systemAt)); + } else { + throw new AssertionError("Unknown query mode: " + spec.mode); + } + List expected = new ArrayList<>(); + for (String alias : spec.versions) { + Assertions.assertTrue(fixture.expected.versions.stream() + .anyMatch(item -> alias.equals(item.alias)), + "Query references an unmaterialized identity: " + alias); + expected.add(version(definitions.get(alias))); + } + expected.sort(QUERY_ORDER); + Assertions.assertEquals(expected, actual, + fixture.name + ": " + spec.mode + " at " + spec.validAt + + " / " + spec.systemAt); + QueryResult result = facade.query(QueryMode.valueOf(spec.mode), key, + time(spec.validAt), spec.systemAt == null ? null : time(spec.systemAt)); + Assertions.assertEquals(expected, result.getVersions(), fixture.name); + Assertions.assertEquals(result.getTrace().toJson(), + facade.getQueryTrace(result.getTrace().getId()).toJson(), fixture.name); + JsonObject trace = result.getTrace().toJson(); + Set hits = new HashSet<>(); + Set evidenceSources = new HashSet<>(); + Set tombstones = new HashSet<>(); + for (VersionSpec version : fixture.expected.versions) { + if ("RETRACTED".equals(version.status) + && key.equals(new FactKey(version.subject.id, version.predicate, + version.scope)) + && interval(version.validFrom, version.validTo).contains(time(spec.validAt)) + && ("HISTORICAL".equals(spec.mode) + || ("CURRENT".equals(spec.mode) && version.systemTo == null) + || ("AS_OF".equals(spec.mode) && interval(version.systemFrom, + version.systemTo).contains(time(spec.systemAt))))) { + tombstones.add(identity(version.alias)); + } + } + for (MemoryFactVersion version : expected) { + hits.add(version.getId()); + for (Evidence item : version.getEvidence()) { + evidenceSources.add(version.getId() + "/" + item.getId() + "/" + + item.getSource().getId() + "/" + item.getSource().getName()); + } + } + Assertions.assertEquals(hits, strings(trace, "hitVersionIds"), fixture.name); + Assertions.assertEquals(tombstones, strings(trace, "tombstoneVersionIds"), + fixture.name + ": query tombstone visibility"); + Set actualEvidence = new HashSet<>(); + for (JsonElement element : trace.getAsJsonArray("evidenceSources")) { + JsonObject item = element.getAsJsonObject(); + actualEvidence.add(item.get("versionId").getAsString() + "/" + + item.get("evidenceId").getAsString() + "/" + + item.get("sourceId").getAsString() + "/" + + item.get("sourceName").getAsString()); + } + Assertions.assertEquals(evidenceSources, actualEvidence, + fixture.name + ": query evidence must come only from hit versions"); + Assertions.assertFalse(trace.get("reason").getAsString().isEmpty()); + Assertions.assertFalse(trace.get("resultDigest").getAsString().isEmpty()); + } + Assertions.assertTrue(modes.containsAll(Arrays.asList("CURRENT", "HISTORICAL", "AS_OF")), + fixture.name + ": all three query modes are required"); + } + + private Set strings(JsonObject json, String field) { + Set result = new HashSet<>(); + for (JsonElement value : json.getAsJsonArray(field)) { + Assertions.assertTrue(result.add(value.getAsString()), + fixture.name + ": duplicate trace reference in " + field); + } + return result; + } + + private NormalizedMemoryEvent normalize(String alias) { + NormalizedMemoryEvent cached = normalized.get(alias); + if (cached != null) { + return cached; + } + EventSpec spec = events.get(alias); + Assertions.assertNotNull(spec, "Unknown input alias: " + alias); + MemoryEvent event; + TimeInterval valid = interval(spec.validFrom, spec.validTo); + List evidence = evidence(spec.evidence); + if ("ASSERT".equals(spec.operation)) { + event = MemoryEvent.add(spec.id, fact(spec), valid, time(spec.recordedAt), evidence); + } else if ("CORRECT".equals(spec.operation)) { + event = MemoryEvent.correct(spec.id, fact(spec), valid, + time(spec.recordedAt), evidence); + } else if ("RETRACT".equals(spec.operation)) { + event = MemoryEvent.retract(spec.id, spec.factId, valid, + time(spec.recordedAt), evidence); + } else { + throw new AssertionError("Unknown operation: " + spec.operation); + } + if (spec.target != null) { + event = event.targeting(definitions.containsKey(spec.target) + ? identity(spec.target) : spec.target); + } + NormalizedMemoryEvent result = normalizer.normalize(event, + new FactKey(spec.subject.id, spec.predicate, spec.scope)); + normalized.put(alias, result); + return result; + } + + private String identity(String alias) { + String cached = identities.get(alias); + if (cached != null) { + return cached; + } + VersionSpec spec = definitions.get(alias); + Assertions.assertNotNull(spec, "Unknown version alias: " + alias); + String id = VersionIdentity.create(normalize(spec.event), fact(spec), + MemoryFactVersionStatus.valueOf(spec.status), + interval(spec.validFrom, spec.validTo), + spec.sourceVersion == null ? null : identity(spec.sourceVersion)); + identities.put(alias, id); + return id; + } + + private MemoryFactVersion version(VersionSpec spec) { + return new MemoryFactVersion(identity(spec.alias), fact(spec), + MemoryFactVersionStatus.valueOf(spec.status), + interval(spec.validFrom, spec.validTo), + interval(spec.systemFrom, spec.systemTo), evidence(spec.evidence)); + } + } + + private static MemoryFact fact(FactSpec spec) { + MemoryEntity subject = new MemoryEntity(spec.subject.id, spec.subject.label); + if ("ENTITY_REF".equals(spec.value.kind)) { + return MemoryFact.relationship(spec.factId, subject, spec.predicate, + new MemoryEntity(spec.value.value, spec.value.label)); + } + Assertions.assertEquals("LITERAL", spec.value.kind); + return MemoryFact.attribute(spec.factId, subject, spec.predicate, spec.value.value); + } + + private static List evidence(List specs) { + List evidence = new ArrayList<>(); + for (EvidenceSpec spec : specs) { + evidence.add(new Evidence(spec.id, new Source(spec.source.id, spec.source.name), + spec.content)); + } + return evidence; + } + + private static Instant time(String value) { + return Instant.parse(value); + } + + private static TimeInterval interval(String start, String end) { + return new TimeInterval(time(start), end == null ? null : time(end)); + } + + private static final class Fixture { + private int schemaVersion; + private String name; + private List events; + private List batches; + private ExpectedSpec expected; + } + + private static class FactSpec { + String factId; + EntitySpec subject; + String predicate; + String scope; + ValueSpec value; + } + + private static class TimedFactSpec extends FactSpec { + String alias; + String validFrom; + String validTo; + List evidence; + } + + private static final class EventSpec extends TimedFactSpec { + private String id; + private String operation; + private String recordedAt; + private String target; + } + + private static final class VersionSpec extends TimedFactSpec { + private String event; + private String sourceVersion; + private String status; + private String systemFrom; + private String systemTo; + } + + private static final class EntitySpec { + private String id; + private String label; + } + + private static final class ValueSpec { + private String kind; + private String value; + private String label; + } + + private static final class SourceSpec { + private String id; + private String name; + } + + private static final class EvidenceSpec { + private String id; + private SourceSpec source; + private String content; + } + + private static final class BatchSpec { + private int endExclusive; + private String expectedFailure; + private List decisions; + } + + private static final class DecisionSpec { + private String event; + private String code; + } + + private static final class ExpectedSpec { + private List identities; + private List versions; + private List relations; + private List queries; + } + + private static final class RelationSpec { + private String type; + private String from; + private String to; + } + + private static final class QuerySpec { + private String mode; + private String subjectId; + private String predicate; + private String scope; + private String validAt; + private String systemAt; + private List versions; + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/adapter/MemoryGraphAdapterTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/adapter/MemoryGraphAdapterTest.java new file mode 100644 index 000000000..01428b9c6 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/adapter/MemoryGraphAdapterTest.java @@ -0,0 +1,772 @@ +/* + * 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.adapter; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.geaflow.ai.graph.io.Edge; +import org.apache.geaflow.ai.graph.io.EdgeGroup; +import org.apache.geaflow.ai.graph.io.EdgeSchema; +import org.apache.geaflow.ai.graph.io.EntityGroup; +import org.apache.geaflow.ai.graph.io.MemoryGraph; +import org.apache.geaflow.ai.graph.io.Vertex; +import org.apache.geaflow.ai.graph.io.VertexGroup; +import org.apache.geaflow.ai.graph.io.VertexSchema; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Defines the canonical MemoryGraph projection and reverse-read contract. + */ +public class MemoryGraphAdapterTest { + + private static final String CITY_VERSION = "city-version"; + private static final String CITY_TOMBSTONE = "city-tombstone"; + private static final String BOB_VERSION = "bob-version"; + private static final String BOB_COPY_VERSION = "bob-copy-version"; + private static final String CAROL_VERSION = "carol-version"; + + private static final String CITY_ADD_EVENT = "city-add"; + private static final String CITY_RETRACT_EVENT = "city-retract"; + private static final String BOB_ADD_EVENT = "bob-add"; + private static final String BOB_CORRECT_EVENT = "bob-correct"; + private static final String CAROL_ADD_EVENT = "carol-add"; + + private static final String CITY_EVIDENCE = "city-proof"; + + private static final FactKey CITY_KEY = new FactKey( + "person:alice", "city", "profile"); + private static final FactKey KNOWS_KEY = new FactKey( + "person:alice", "knows", "social"); + private static final FactKey ALIAS_KEY = new FactKey( + "person:history", "alias", "profile"); + + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testEmptySnapshotCreatesFixedSchemaAndRoundTrips() { + CanonicalSnapshot empty = new CanonicalSnapshot( + new TemporalState( + Collections.emptyMap(), + Collections.emptyList()), + Collections.emptyList(), + Collections.emptyMap()); + + MemoryGraph graph = new MemoryGraphAdapter().toGraph(empty); + + Assertions.assertEquals(expectedVertexSchemas(), vertexSchemas(graph)); + Assertions.assertEquals(expectedEdgeSchemas(), edgeSchemas(graph)); + Assertions.assertEquals(expectedGroupOrder(), + new ArrayList<>(graph.entities.keySet())); + for (EntityGroup group : graph.entities.values()) { + if (group instanceof VertexGroup) { + Assertions.assertTrue( + ((VertexGroup) group).getVertices().isEmpty()); + } else { + Assertions.assertTrue( + ((EdgeGroup) group).getOutEdges().isEmpty()); + } + } + Assertions.assertEquals( + empty, + new MemoryGraphAdapter().fromGraph(graph)); + } + + @Test + public void testCompleteSnapshotRoundTripsWithoutLoss() { + CanonicalSnapshot expected = completeSnapshot(); + MemoryGraph graph = new MemoryGraphAdapter().toGraph(expected); + + CanonicalSnapshot actual = + new MemoryGraphAdapter().fromGraph(graph); + + Assertions.assertEquals(expected, actual); + Assertions.assertEquals(CITY_VERSION, + event(actual, CITY_RETRACT_EVENT).getTargetVersionId().get()); + Assertions.assertEquals(BOB_VERSION, + event(actual, BOB_CORRECT_EVENT).getTargetVersionId().get()); + Assertions.assertFalse(event(actual, CITY_ADD_EVENT).getTargetVersionId().isPresent()); + Assertions.assertEquals(CITY_VERSION, vertexProperty(graph, + "memory_event", eventId(CITY_RETRACT_EVENT), "targetVersionId")); + Assertions.assertEquals(BOB_VERSION, vertexProperty(graph, + "memory_event", eventId(BOB_CORRECT_EVENT), "targetVersionId")); + Assertions.assertNotNull(graph.getVertex( + "entity", "entity:person:alice")); + Assertions.assertNotNull(graph.getVertex( + "entity", "entity:person:bob")); + Assertions.assertNotNull(graph.getVertex( + "entity", "entity:person:carol")); + Assertions.assertNotNull(graph.getVertex( + "entity", "entity:person:history")); + Assertions.assertNotNull(graph.getVertex( + "fact_version", versionId(CITY_TOMBSTONE))); + Assertions.assertNotNull(graph.getVertex( + "memory_event", eventId(CITY_RETRACT_EVENT))); + Assertions.assertNotNull(graph.getVertex( + "evidence", evidenceId(CITY_EVIDENCE))); + Assertions.assertNotNull(graph.getVertex( + "source", "source:registry")); + Assertions.assertEquals( + event(expected, CITY_ADD_EVENT).getPayloadHash(), + vertexProperty( + graph, + "memory_event", + eventId(CITY_ADD_EVENT), + "payloadHash")); + + Assertions.assertTrue(outEdges( + graph, "object", versionId(CITY_VERSION)).isEmpty()); + assertSingleEdge( + graph, + "subject", + versionId(CITY_VERSION), + "entity:person:alice"); + assertSingleEdge( + graph, + "object", + versionId(BOB_VERSION), + "entity:person:bob"); + assertSingleEdge( + graph, + "generates", + eventId(CITY_ADD_EVENT), + versionId(CITY_VERSION)); + assertSingleEdge( + graph, + "from_source", + evidenceId(CITY_EVIDENCE), + "source:registry"); + + Assertions.assertEquals( + Collections.singletonList("2"), + assertSingleEdge( + graph, + "supported_by", + eventId(CITY_ADD_EVENT), + evidenceId(CITY_EVIDENCE)).getValues()); + Assertions.assertEquals( + Collections.singletonList("2"), + assertSingleEdge( + graph, + "supported_by", + versionId(CITY_VERSION), + evidenceId(CITY_EVIDENCE)).getValues()); + + assertRelationEdge( + graph, + "supersedes", + versionId(CITY_TOMBSTONE), + versionId(CITY_VERSION), + 1); + assertRelationEdge( + graph, + "duplicate_of", + versionId(BOB_COPY_VERSION), + versionId(BOB_VERSION), + 2); + assertRelationEdge( + graph, + "conflicts_with", + versionId(BOB_VERSION), + versionId(CAROL_VERSION), + 1); + } + + @Test + public void testProjectionIsDeterministicAndUsesCanonicalEdges() { + CanonicalSnapshot snapshot = completeSnapshot(); + MemoryGraph first = new MemoryGraphAdapter().toGraph(snapshot); + MemoryGraph second = new MemoryGraphAdapter().toGraph(snapshot); + + Assertions.assertEquals(graphRows(first), graphRows(second)); + Assertions.assertEquals( + graphRows(first), + graphRows(new MemoryGraphAdapter().toGraph( + new MemoryGraphAdapter().fromGraph(first)))); + + Set identities = new HashSet<>(); + Set relationIds = new HashSet<>(); + for (VertexSchema schema + : first.getGraphSchema().getVertexSchemaList()) { + for (Vertex vertex : vertices(first, schema.getLabel())) { + Assertions.assertEquals( + schema.getFields().size(), + vertex.getValues().size()); + } + } + for (EdgeSchema schema + : first.getGraphSchema().getEdgeSchemaList()) { + for (Edge edge : edges(first, schema.getLabel())) { + Assertions.assertEquals( + schema.getFields().size(), + edge.getValues().size()); + Assertions.assertTrue( + identities.add(edge), + "duplicate edge identity: " + edge); + if (isVersionRelation(edge.getLabel())) { + Assertions.assertTrue( + relationIds.add(edge.getValues().get(0))); + } + } + } + Assertions.assertEquals(3, relationIds.size()); + assertTypedVertexIds(first); + } + + @Test + public void testNullInputsAreRejected() { + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + + Assertions.assertThrows( + NullPointerException.class, + () -> adapter.toGraph(null)); + Assertions.assertThrows( + NullPointerException.class, + () -> adapter.fromGraph(null)); + } + + @Test + public void testChangedTargetReferenceFailsPayloadHashValidation() { + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + MemoryGraph graph = adapter.toGraph(completeSnapshot()); + VertexGroup events = (VertexGroup) graph.entities.get("memory_event"); + Vertex original = events.getVertex(eventId(CITY_RETRACT_EVENT)); + List changedValues = new ArrayList<>(original.getValues()); + int targetIndex = events.getVertexSchema().getFields().indexOf("targetVersionId"); + Assertions.assertTrue(targetIndex >= 0); + changedValues.set(targetIndex, BOB_VERSION); + graph.updateVertex(new Vertex(original.getLabel(), original.getId(), changedValues)); + + IllegalArgumentException error = Assertions.assertThrows( + IllegalArgumentException.class, () -> adapter.fromGraph(graph)); + + Assertions.assertEquals("Event payload hash does not match: " + CITY_RETRACT_EVENT, + error.getMessage()); + } + + @Test + public void testOrphanVerticesAreRejected() { + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + + MemoryGraph entityGraph = emptyGraph(adapter); + entityGraph.addVertex(new Vertex( + "entity", + "entity:orphan", + Collections.singletonList("person"))); + IllegalArgumentException entityError = Assertions.assertThrows( + IllegalArgumentException.class, + () -> adapter.fromGraph(entityGraph)); + Assertions.assertEquals( + "Orphan entity vertex: entity:orphan", + entityError.getMessage()); + + MemoryGraph evidenceGraph = emptyGraph(adapter); + evidenceGraph.addVertex(new Vertex( + "source", + "source:orphan-evidence-source", + Collections.singletonList("Orphan evidence source"))); + evidenceGraph.addVertex(new Vertex( + "evidence", + "evidence:orphan", + Collections.singletonList("Unreferenced evidence"))); + evidenceGraph.addEdge(new Edge( + "from_source", + "evidence:orphan", + "source:orphan-evidence-source", + Collections.emptyList())); + IllegalArgumentException evidenceError = Assertions.assertThrows( + IllegalArgumentException.class, + () -> adapter.fromGraph(evidenceGraph)); + Assertions.assertEquals( + "Orphan evidence vertex: evidence:orphan", + evidenceError.getMessage()); + + MemoryGraph sourceGraph = emptyGraph(adapter); + sourceGraph.addVertex(new Vertex( + "source", + "source:orphan", + Collections.singletonList("Unreferenced source"))); + IllegalArgumentException sourceError = Assertions.assertThrows( + IllegalArgumentException.class, + () -> adapter.fromGraph(sourceGraph)); + Assertions.assertEquals( + "Orphan source vertex: source:orphan", + sourceError.getMessage()); + } + + private static MemoryGraph emptyGraph(MemoryGraphAdapter adapter) { + return adapter.toGraph(new CanonicalSnapshot( + new TemporalState( + Collections.emptyMap(), + Collections.emptyList()), + Collections.emptyList(), + Collections.emptyMap())); + } + + private CanonicalSnapshot completeSnapshot() { + Source registry = new Source("registry", "Registry"); + Source analyst = new Source("analyst", "Analyst notes"); + Evidence cityEvidence = new Evidence( + CITY_EVIDENCE, registry, "Alice lived in Beijing"); + Evidence retractEvidence = new Evidence( + "retract-proof", registry, "The city record expired"); + Evidence bobEvidence = new Evidence( + "bob-proof", analyst, "Alice knows Bob"); + Evidence bobCopyEvidence = new Evidence( + "bob-copy-proof", analyst, "Duplicate Bob assertion"); + Evidence carolEvidence = new Evidence( + "carol-proof", registry, "Alice also knows Carol"); + Evidence aliasEvidence = new Evidence( + "alias-proof", analyst, "Historical alias event"); + + MemoryEntity alice = new MemoryEntity("person:alice", "person"); + MemoryEntity bob = new MemoryEntity("person:bob", "person"); + MemoryEntity carol = new MemoryEntity("person:carol", "person"); + MemoryEntity history = new MemoryEntity( + "person:history", "historical_person"); + MemoryFact cityFact = MemoryFact.attribute( + "fact-city", alice, "city", "Beijing"); + MemoryFact bobFact = MemoryFact.relationship( + "fact-bob", alice, "knows", bob); + MemoryFact bobCopyFact = MemoryFact.relationship( + "fact-bob-copy", alice, "knows", bob); + MemoryFact carolFact = MemoryFact.relationship( + "fact-carol", alice, "knows", carol); + MemoryFact aliasFact = MemoryFact.attribute( + "fact-alias", history, "alias", "Al"); + + TimeInterval cityValid = interval( + "2020-01-01T00:00:00Z", + "2021-01-01T00:00:00Z"); + TimeInterval relationshipValid = TimeInterval.unboundedFrom( + Instant.parse("2019-06-01T00:00:00Z")); + NormalizedMemoryEvent cityAdd = add( + CITY_ADD_EVENT, + cityFact, + CITY_KEY, + cityValid, + "2025-01-01T00:00:00Z", + cityEvidence, + cityEvidence); + NormalizedMemoryEvent cityRetract = retract( + CITY_RETRACT_EVENT, + cityFact.getId(), + CITY_KEY, + cityValid, + "2025-01-02T00:00:00Z", + retractEvidence); + NormalizedMemoryEvent bobAdd = add( + BOB_ADD_EVENT, + bobFact, + KNOWS_KEY, + relationshipValid, + "2025-01-03T00:00:00Z", + bobEvidence); + NormalizedMemoryEvent bobCorrect = correct( + BOB_CORRECT_EVENT, + bobCopyFact, + KNOWS_KEY, + relationshipValid, + "2025-01-04T00:00:00Z", + bobCopyEvidence); + NormalizedMemoryEvent carolAdd = add( + CAROL_ADD_EVENT, + carolFact, + KNOWS_KEY, + relationshipValid, + "2025-01-05T00:00:00Z", + carolEvidence); + NormalizedMemoryEvent historicalAlias = add( + "alias-history", + aliasFact, + ALIAS_KEY, + TimeInterval.unboundedFrom( + Instant.parse("2018-01-01T00:00:00Z")), + "2025-01-06T00:00:00Z", + aliasEvidence); + + MemoryFactVersion cityVersion = version( + CITY_VERSION, + cityFact, + MemoryFactVersionStatus.ACTIVE, + interval( + "2020-01-01T00:00:00.123456789Z", + "2021-01-01T00:00:00.987654321Z"), + interval( + "2025-01-01T00:00:00.000000001Z", + "2025-01-02T00:00:00.000000002Z"), + cityEvidence, + cityEvidence); + MemoryFactVersion cityTombstone = version( + CITY_TOMBSTONE, + cityFact, + MemoryFactVersionStatus.RETRACTED, + cityValid, + TimeInterval.unboundedFrom( + Instant.parse("2025-01-02T00:00:00Z")), + retractEvidence); + MemoryFactVersion bobVersion = version( + BOB_VERSION, + bobFact, + MemoryFactVersionStatus.ACTIVE, + relationshipValid, + interval( + "2025-01-03T00:00:00Z", + "2025-01-04T00:00:00Z"), + bobEvidence); + MemoryFactVersion bobCopyVersion = version( + BOB_COPY_VERSION, + bobCopyFact, + MemoryFactVersionStatus.ACTIVE, + relationshipValid, + interval( + "2025-01-04T00:00:00Z", + "2025-01-05T00:00:00Z"), + bobCopyEvidence); + MemoryFactVersion carolVersion = version( + CAROL_VERSION, + carolFact, + MemoryFactVersionStatus.ACTIVE, + relationshipValid, + TimeInterval.unboundedFrom( + Instant.parse("2025-01-05T00:00:00Z")), + carolEvidence); + + Map> versions = + new LinkedHashMap<>(); + versions.put(KNOWS_KEY, Arrays.asList( + carolVersion, bobCopyVersion, bobVersion)); + versions.put(CITY_KEY, Arrays.asList( + cityTombstone, cityVersion)); + + VersionRelation duplicate = new VersionRelation( + VersionRelationType.DUPLICATE_OF, + BOB_COPY_VERSION, + BOB_VERSION); + List relations = Arrays.asList( + new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + BOB_VERSION, + CAROL_VERSION), + duplicate, + new VersionRelation( + VersionRelationType.SUPERSEDES, + CITY_TOMBSTONE, + CITY_VERSION), + duplicate); + + Map generating = new LinkedHashMap<>(); + generating.put(CAROL_VERSION, CAROL_ADD_EVENT); + generating.put(CITY_TOMBSTONE, CITY_RETRACT_EVENT); + generating.put(BOB_COPY_VERSION, BOB_CORRECT_EVENT); + generating.put(CITY_VERSION, CITY_ADD_EVENT); + generating.put(BOB_VERSION, BOB_ADD_EVENT); + + return new CanonicalSnapshot( + new TemporalState(versions, relations), + Arrays.asList( + historicalAlias, + carolAdd, + bobCorrect, + bobAdd, + cityRetract, + cityAdd), + generating); + } + + private NormalizedMemoryEvent add( + String eventId, + MemoryFact fact, + FactKey key, + TimeInterval validTime, + String recordedAt, + Evidence... evidence) { + return normalizer.normalize( + MemoryEvent.add( + eventId, + fact, + validTime, + Instant.parse(recordedAt), + Arrays.asList(evidence)), + key); + } + + private NormalizedMemoryEvent correct( + String eventId, + MemoryFact fact, + FactKey key, + TimeInterval validTime, + String recordedAt, + Evidence... evidence) { + return normalizer.normalize( + MemoryEvent.correct( + eventId, + fact, + validTime, + Instant.parse(recordedAt), + Arrays.asList(evidence)).targeting(BOB_VERSION), + key); + } + + private NormalizedMemoryEvent retract( + String eventId, + String factId, + FactKey key, + TimeInterval validTime, + String recordedAt, + Evidence... evidence) { + return normalizer.normalize( + MemoryEvent.retract( + eventId, + factId, + validTime, + Instant.parse(recordedAt), + Arrays.asList(evidence)).targeting(CITY_VERSION), + key); + } + + private static MemoryFactVersion version( + String versionId, + MemoryFact fact, + MemoryFactVersionStatus status, + TimeInterval validTime, + TimeInterval transactionTime, + Evidence... evidence) { + return new MemoryFactVersion( + versionId, + fact, + status, + validTime, + transactionTime, + Arrays.asList(evidence)); + } + + private static TimeInterval interval(String start, String end) { + return new TimeInterval(Instant.parse(start), Instant.parse(end)); + } + + private static List expectedVertexSchemas() { + return Arrays.asList( + "entity|id|[label]", + "fact_version|id|[factId, predicate, scope, valueKind, " + + "literalValue, status, validStart, validEnd, " + + "transactionStart, transactionEnd]", + "memory_event|id|[operation, factId, subjectId, predicate, " + + "scope, valueKind, value, validStart, validEnd, " + + "recordedAt, targetVersionId, payloadHash]", + "evidence|id|[content]", + "source|id|[name]"); + } + + private static List expectedEdgeSchemas() { + return Arrays.asList( + "subject|srcId|dstId|[]", + "object|srcId|dstId|[]", + "generates|srcId|dstId|[]", + "supported_by|srcId|dstId|[occurrenceCount]", + "from_source|srcId|dstId|[]", + "supersedes|srcId|dstId|[relationId, occurrenceCount]", + "duplicate_of|srcId|dstId|[relationId, occurrenceCount]", + "conflicts_with|srcId|dstId|[relationId, occurrenceCount]"); + } + + private static List expectedGroupOrder() { + return Arrays.asList( + "entity", + "fact_version", + "memory_event", + "evidence", + "source", + "subject", + "object", + "generates", + "supported_by", + "from_source", + "supersedes", + "duplicate_of", + "conflicts_with"); + } + + private static List vertexSchemas(MemoryGraph graph) { + List rows = new ArrayList<>(); + for (VertexSchema schema + : graph.getGraphSchema().getVertexSchemaList()) { + rows.add(schema.getLabel() + "|" + schema.getIdField() + + "|" + schema.getFields()); + } + return rows; + } + + private static List edgeSchemas(MemoryGraph graph) { + List rows = new ArrayList<>(); + for (EdgeSchema schema + : graph.getGraphSchema().getEdgeSchemaList()) { + rows.add(schema.getLabel() + "|" + schema.getSrcIdField() + + "|" + schema.getDstIdField() + + "|" + schema.getFields()); + } + return rows; + } + + private static List graphRows(MemoryGraph graph) { + List rows = new ArrayList<>(); + rows.addAll(vertexSchemas(graph)); + rows.addAll(edgeSchemas(graph)); + for (VertexSchema schema + : graph.getGraphSchema().getVertexSchemaList()) { + for (Vertex vertex : vertices(graph, schema.getLabel())) { + rows.add("vertex|" + vertex.getLabel() + "|" + + vertex.getId() + "|" + vertex.getValues()); + } + } + for (EdgeSchema schema + : graph.getGraphSchema().getEdgeSchemaList()) { + for (Edge edge : edges(graph, schema.getLabel())) { + rows.add("edge|" + edge.getLabel() + "|" + + edge.getSrcId() + "|" + edge.getDstId() + + "|" + edge.getValues()); + } + } + return rows; + } + + private static List vertices(MemoryGraph graph, String label) { + return ((VertexGroup) graph.entities.get(label)).getVertices(); + } + + private static String vertexProperty( + MemoryGraph graph, + String label, + String vertexId, + String field) { + Vertex vertex = graph.getVertex(label, vertexId); + Assertions.assertNotNull(vertex); + for (VertexSchema schema + : graph.getGraphSchema().getVertexSchemaList()) { + if (label.equals(schema.getLabel())) { + int index = schema.getFields().indexOf(field); + Assertions.assertTrue(index >= 0); + return vertex.getValues().get(index); + } + } + throw new AssertionError("Missing vertex schema: " + label); + } + + private static List edges(MemoryGraph graph, String label) { + return ((EdgeGroup) graph.entities.get(label)).getOutEdges(); + } + + private static List outEdges( + MemoryGraph graph, + String label, + String sourceId) { + return ((EdgeGroup) graph.entities.get(label)).getOutEdges(sourceId); + } + + private static Edge assertSingleEdge( + MemoryGraph graph, + String label, + String sourceId, + String targetId) { + List matches = graph.getEdge(label, sourceId, targetId); + Assertions.assertEquals(1, matches.size()); + return matches.get(0); + } + + private static void assertRelationEdge( + MemoryGraph graph, + String label, + String sourceId, + String targetId, + int occurrenceCount) { + List values = assertSingleEdge( + graph, label, sourceId, targetId).getValues(); + Assertions.assertEquals(2, values.size()); + Assertions.assertFalse(values.get(0).trim().isEmpty()); + Assertions.assertEquals( + Integer.toString(occurrenceCount), values.get(1)); + } + + private static boolean isVersionRelation(String label) { + return "supersedes".equals(label) + || "duplicate_of".equals(label) + || "conflicts_with".equals(label); + } + + private static void assertTypedVertexIds(MemoryGraph graph) { + Map prefixes = new LinkedHashMap<>(); + prefixes.put("entity", "entity:"); + prefixes.put("fact_version", "version:"); + prefixes.put("memory_event", "event:"); + prefixes.put("evidence", "evidence:"); + prefixes.put("source", "source:"); + for (Map.Entry entry : prefixes.entrySet()) { + for (Vertex vertex : vertices(graph, entry.getKey())) { + Assertions.assertTrue( + vertex.getId().startsWith(entry.getValue())); + } + } + } + + private static String versionId(String rawId) { + return "version:" + rawId; + } + + private static String eventId(String rawId) { + return "event:" + rawId; + } + + private static String evidenceId(String rawId) { + return "evidence:" + rawId; + } + + private static NormalizedMemoryEvent event( + CanonicalSnapshot snapshot, + String eventId) { + for (NormalizedMemoryEvent event : snapshot.getEvents()) { + if (eventId.equals(event.getEventId())) { + return event; + } + } + throw new AssertionError("Missing event: " + eventId); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/GraphPublicationAtomicityTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/GraphPublicationAtomicityTest.java new file mode 100644 index 000000000..7fb030ed3 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/GraphPublicationAtomicityTest.java @@ -0,0 +1,225 @@ +/* + * 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.api; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.geaflow.ai.temporal.adapter.MemoryGraphAdapter; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** A graph identity collision cannot publish staged events or consume their ledger IDs. */ +public class GraphPublicationAtomicityTest { + + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private static final Source SOURCE = new Source("source", "Registry"); + private static final Instant VALID_FROM = Instant.parse("2024-01-01T00:00:00Z"); + + @Test + public void testConflictingEvidenceIdRollsBackWholeBatchAndLedger() { + assertPublicationRollback(new Evidence("proof-base", SOURCE, "Changed content"), + new Evidence("proof-repaired", SOURCE, "Changed content")); + } + + @Test + public void testConflictingSourceIdRollsBackWholeBatchAndLedger() { + assertPublicationRollback(new Evidence("proof-second", + new Source("source", "Different registry name"), "Second evidence"), + new Evidence("proof-second", SOURCE, "Second evidence")); + } + + @Test + public void testEveryCandidateStrategyRollsBackGraphAndLedgerBeforeRetry() { + for (RetrievalProfile profile : candidateProfiles()) { + assertPublicationRollback(new Evidence("proof-base", SOURCE, "Changed content"), + new Evidence("proof-repaired", SOURCE, "Changed content"), profile); + assertPublicationRollback(new Evidence("proof-second", + new Source("source", "Different registry name"), "Second evidence"), + new Evidence("proof-second", SOURCE, "Second evidence"), profile); + } + } + + @Test + public void testOutOfOrderReplayPublishesResidualConflictWithoutCrossScopeEdges() { + final TimeInterval whole = interval("2024-01-01T00:00:00Z", "2024-10-01T00:00:00Z"); + final NormalizedMemoryEvent first = assertion("first", "Paris", "2024-02-01T00:00:00Z", + new Evidence("first-proof", SOURCE, "Paris"), KEY, whole); + final NormalizedMemoryEvent conflict = assertion("conflict", "Rome", "2024-03-01T00:00:00Z", + new Evidence("conflict-proof", SOURCE, "Rome"), KEY, + interval("2024-05-01T00:00:00Z", "2024-12-01T00:00:00Z")); + final String target = versionId(new FullReplayOracle().replayToSnapshot( + Collections.singletonList(first)), "first", "Paris"); + final TimeInterval corrected = interval("2024-01-01T00:00:00Z", "2024-05-01T00:00:00Z"); + final NormalizedMemoryEvent correction = new EventNormalizer().normalize(MemoryEvent.correct( + "correction", MemoryFact.attribute("fact-correction", new MemoryEntity("alice", "person"), + "city", "Berlin"), corrected, Instant.parse("2024-04-01T00:00:00Z"), + Collections.singletonList(new Evidence("correction-proof", SOURCE, "Berlin"))) + .targeting(target), KEY); + NormalizedMemoryEvent otherScope = assertion("other-scope", "Tokyo", "2024-03-01T00:00:00Z", + new Evidence("other-proof", SOURCE, "Tokyo"), new FactKey("alice", "city", "travel"), whole); + final List shuffled = Arrays.asList(conflict, correction, otherScope, first); + final CanonicalSnapshot expected = new FullReplayOracle().replayToSnapshot(shuffled); + + for (RetrievalProfile profile : candidateProfiles()) { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + CanonicalSnapshot actual = facade.replay(shuffled, profile, 1).getSnapshot(); + Assertions.assertEquals(expected, actual, profile.name()); + final String residual = versionId(actual, "correction", "Paris"); + final String replacement = versionId(actual, "correction", "Berlin"); + final String conflicting = versionId(actual, "conflict", "Rome"); + Set gold = new HashSet<>(Arrays.asList( + new VersionRelation(VersionRelationType.CONFLICTS_WITH, target, conflicting), + new VersionRelation(VersionRelationType.CONFLICTS_WITH, residual, conflicting), + new VersionRelation(VersionRelationType.SUPERSEDES, residual, target), + new VersionRelation(VersionRelationType.SUPERSEDES, replacement, target))); + Assertions.assertEquals(gold, new HashSet<>(actual.getState().getRelations()), profile.name()); + assertPublishedRelations(actual); + } + } + + private static void assertPublicationRollback(Evidence invalidEvidence, Evidence repairedEvidence) { + assertPublicationRollback(invalidEvidence, repairedEvidence, null); + } + + private static void assertPublicationRollback(Evidence invalidEvidence, Evidence repairedEvidence, + RetrievalProfile profile) { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + NormalizedMemoryEvent base = assertion("base", "Paris", "2024-02-01T00:00:00Z", + new Evidence("proof-base", SOURCE, "Original content")); + apply(facade, Collections.singletonList(base), profile); + final CanonicalSnapshot committed = facade.getSnapshot(); + NormalizedMemoryEvent stagedGood = assertion("staged-good", "Rome", "2024-03-01T00:00:00Z", + new Evidence("proof-good", SOURCE, "Good evidence")); + NormalizedMemoryEvent stagedBad = assertion("staged-second", "Berlin", + "2024-04-01T00:00:00Z", invalidEvidence); + + TemporalValidationException failure = Assertions.assertThrows(TemporalValidationException.class, + () -> apply(facade, Arrays.asList(stagedBad, stagedGood), profile)); + Assertions.assertEquals(TemporalErrorCode.REJECT_REFERENCE_INTEGRITY, failure.getCode()); + Assertions.assertEquals(committed, facade.getSnapshot()); + Assertions.assertEquals(Collections.singletonList(base), facade.getSnapshot().getEvents()); + + NormalizedMemoryEvent repaired = assertion("staged-second", "Berlin", + "2024-04-01T00:00:00Z", repairedEvidence); + ReplayResult retry = apply(facade, Arrays.asList(repaired, stagedGood), profile); + Assertions.assertEquals(Integer.valueOf(2), retry.getStatistics().get("acceptedCount")); + Assertions.assertEquals(Integer.valueOf(0), retry.getStatistics().get("duplicateCount")); + Assertions.assertEquals(3, retry.getSnapshot().getEvents().size()); + Assertions.assertEquals("ACCEPTED", retry.getDecisionTraces().get(0).getCode()); + Assertions.assertEquals("ACCEPTED", retry.getDecisionTraces().get(1).getCode()); + final CanonicalSnapshot retried = retry.getSnapshot(); + final String baseId = versionId(retried, "base", "Paris"); + final String goodId = versionId(retried, "staged-good", "Rome"); + final String repairedId = versionId(retried, "staged-second", "Berlin"); + Assertions.assertEquals(new HashSet<>(Arrays.asList( + new VersionRelation(VersionRelationType.CONFLICTS_WITH, baseId, goodId), + new VersionRelation(VersionRelationType.CONFLICTS_WITH, baseId, repairedId), + new VersionRelation(VersionRelationType.CONFLICTS_WITH, goodId, repairedId))), + new HashSet<>(retried.getState().getRelations())); + assertPublishedRelations(retried); + Assertions.assertTrue(facade.validateInvariants().isValid()); + } + + private static ReplayResult apply(TemporalMemoryFacade facade, List events, + RetrievalProfile profile) { + return profile == null ? facade.applyBatch(events) : facade.applyBatch(events, profile, 1); + } + + private static List candidateProfiles() { + return Arrays.asList(RetrievalProfile.RULE_ONLY, RetrievalProfile.RULE_BM25, + RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL); + } + + private static String versionId(CanonicalSnapshot snapshot, String eventId, String value) { + return snapshot.getState().getVersions().stream() + .filter(version -> eventId.equals(snapshot.getGeneratingEventIds().get(version.getId())) + && value.equals(version.getFact().getLiteralValue().orElse(null))) + .map(MemoryFactVersion::getId).findFirst().orElseThrow(AssertionError::new); + } + + private static void assertPublishedRelations(CanonicalSnapshot snapshot) { + final Map keys = new HashMap<>(); + final Map versions = new HashMap<>(); + for (Map.Entry> entry + : snapshot.getState().getVersionsByFactKey().entrySet()) { + for (MemoryFactVersion version : entry.getValue()) { + keys.put(version.getId(), entry.getKey()); + versions.put(version.getId(), version); + } + } + List relations = snapshot.getState().getRelations(); + Assertions.assertEquals(relations.size(), new HashSet<>(relations).size()); + for (VersionRelation relation : relations) { + MemoryFactVersion from = versions.get(relation.getFromVersionId()); + MemoryFactVersion to = versions.get(relation.getToVersionId()); + Assertions.assertNotNull(from); + Assertions.assertNotNull(to); + Assertions.assertEquals(keys.get(from.getId()), keys.get(to.getId())); + if (relation.getType() == VersionRelationType.CONFLICTS_WITH) { + Assertions.assertNotEquals(from.getFact().getLiteralValue(), to.getFact().getLiteralValue()); + Assertions.assertTrue(from.getValidTime().overlaps(to.getValidTime())); + } + } + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + Assertions.assertEquals(snapshot, adapter.fromGraph(adapter.toGraph(snapshot))); + Assertions.assertEquals(snapshot, new FullReplayOracle().replayToSnapshot(snapshot.getEvents())); + } + + private static NormalizedMemoryEvent assertion(String id, String value, + String recordedAt, Evidence evidence) { + return assertion(id, value, recordedAt, evidence, KEY, TimeInterval.unboundedFrom(VALID_FROM)); + } + + private static NormalizedMemoryEvent assertion(String id, String value, + String recordedAt, Evidence evidence, + FactKey key, TimeInterval validTime) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity("alice", "person"), "city", value); + return new EventNormalizer().normalize(MemoryEvent.add(id, fact, + validTime, Instant.parse(recordedAt), Collections.singletonList(evidence)), key); + } + + private static TimeInterval interval(String start, String end) { + return new TimeInterval(Instant.parse(start), Instant.parse(end)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/TemporalCandidateApiTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/TemporalCandidateApiTest.java new file mode 100644 index 000000000..929d9be11 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/TemporalCandidateApiTest.java @@ -0,0 +1,229 @@ +/* + * 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.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import org.apache.geaflow.ai.temporal.candidate.EvidenceCandidate; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TemporalCandidateApiTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + + @Test + public void testReadOnlyStableResultIncludesRuleScoresAndEvidenceSources() throws Exception { + TemporalMemoryFacade facade = populated(); + facade.query(QueryMode.CURRENT, KEY, at(50), null); + CanonicalSnapshot before = facade.getSnapshot(); + JsonArray operations = facade.getOperations(); + int decisions = facade.getDecisionTraces().size(); + int queries = facade.getQueryTraces().size(); + + CandidateResult result = facade.candidates(QueryMode.CURRENT, KEY, "renewable", + at(50), null, 1); + Assertions.assertEquals(2, result.getFacts().size()); + Assertions.assertTrue(result.getFacts().get(0).isMandatory()); + Assertions.assertEquals(KEY, result.getFacts().get(0).getFactKey()); + Assertions.assertEquals(1.0, result.getFacts().get(0).getScores().get("RULE")); + Assertions.assertTrue(result.getFacts().get(0).getScores().get("BM25") > 0); + Assertions.assertFalse(result.getFacts().get(1).isMandatory()); + Assertions.assertEquals(1, result.getEvidence().size()); + JsonObject encoded = result.toJson(); + JsonObject evidence = encoded.getAsJsonArray("evidence").get(0).getAsJsonObject(); + Assertions.assertEquals("registry", evidence.getAsJsonObject("evidence") + .getAsJsonObject("source").get("id").getAsString()); + Assertions.assertTrue(evidence.getAsJsonObject("scores").get("BM25").getAsDouble() > 0); + Assertions.assertEquals(1, evidence.getAsJsonArray("versionIds").size()); + Assertions.assertEquals(encoded, facade.candidates(QueryMode.CURRENT, KEY, + "renewable", at(50), null, 1).toJson()); + encoded.remove("facts"); + Assertions.assertTrue(result.toJson().has("facts")); + Assertions.assertThrows(UnsupportedOperationException.class, () -> result.getFacts().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, () -> result.getEvidence().clear()); + Assertions.assertSame(before, facade.getSnapshot()); + Assertions.assertEquals(operations, facade.getOperations()); + Assertions.assertEquals(decisions, facade.getDecisionTraces().size()); + Assertions.assertEquals(queries, facade.getQueryTraces().size()); + } + + @Test + public void testScopeAndTimeSlicesExcludeInvisibleVersions() throws Exception { + TemporalMemoryFacade facade = populated(); + CandidateResult past = facade.candidates(QueryMode.AS_OF, KEY, "renewable", + at(50), at(14), 20); + Assertions.assertEquals(1, past.getFacts().size()); + Assertions.assertEquals("e-public", past.getEvidence().get(0).getEvidence().getId()); + CandidateResult future = facade.candidates(QueryMode.AS_OF, KEY, "renewable", + at(50), at(9), 20); + Assertions.assertTrue(future.getFacts().isEmpty()); + Assertions.assertTrue(future.getEvidence().isEmpty()); + CandidateResult otherScope = facade.candidates(QueryMode.CURRENT, + new FactKey("alice", "city", "missing"), "renewable", at(50), null, 20); + Assertions.assertTrue(otherScope.getFacts().isEmpty()); + Assertions.assertTrue(otherScope.getEvidence().isEmpty()); + for (QueryMode mode : QueryMode.values()) { + CandidateResult atEnd = facade.candidates(mode, KEY, "renewable", at(100), + mode == QueryMode.AS_OF ? at(30) : null, 20); + Assertions.assertTrue(atEnd.getFacts().isEmpty()); + Assertions.assertTrue(atEnd.getEvidence().isEmpty()); + } + Assertions.assertTrue(facade.candidates(QueryMode.CURRENT, KEY, "secret", at(50), + null, 20).getEvidence().isEmpty()); + } + + @Test + public void testEmptyTextKeepsMandatoryFactsAndNoEvidenceHits() throws Exception { + TemporalMemoryFacade facade = populated(); + CandidateResult result = facade.candidates(QueryMode.CURRENT, KEY, "", at(50), null, 20); + Assertions.assertEquals(1, result.getFacts().size()); + Assertions.assertTrue(result.getFacts().get(0).isMandatory()); + Assertions.assertEquals(Collections.singletonMap("RULE", 1.0), + result.getFacts().get(0).getScores()); + Assertions.assertTrue(result.getEvidence().isEmpty()); + Assertions.assertEquals("", result.toJson().get("text").getAsString()); + } + + @Test + public void testInvalidRequestsDoNotChangeJournal() { + TemporalMemoryFacade facade = populated(); + JsonArray before = facade.getOperations(); + Assertions.assertEquals(TemporalErrorCode.REJECT_INPUT, Assertions.assertThrows( + TemporalValidationException.class, + () -> facade.candidates(QueryMode.CURRENT, KEY, "wind", at(50), null, 0)).getCode()); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.candidates(QueryMode.AS_OF, KEY, "wind", at(50), null, 20)); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.candidates(QueryMode.CURRENT, KEY, "wind", at(50), at(10), 20)); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.candidates(null, KEY, "wind", at(50), null, 20)); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.candidates(QueryMode.CURRENT, null, "wind", at(50), null, 20)); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.candidates(QueryMode.CURRENT, KEY, null, at(50), null, 20)); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.candidates(QueryMode.CURRENT, KEY, "wind", null, null, 20)); + Assertions.assertEquals(before, facade.getOperations()); + } + + @Test + public void testLexicalProfilesExposeRealScoresWithoutChangingMemory() throws Exception { + TemporalMemoryFacade facade = populated(); + CanonicalSnapshot snapshot = facade.getSnapshot(); + JsonArray operations = facade.getOperations(); + final RetrievalProfile profile = RetrievalProfile.LEXICAL_FUSION; + CandidateResult result = facade.candidates(QueryMode.CURRENT, KEY, "renewable", + at(50), null, 20, profile); + EvidenceCandidate candidate = result.getEvidence().get(0); + JsonObject encoded = result.toJson().getAsJsonArray("evidence").get(0) + .getAsJsonObject(); + Assertions.assertEquals(candidate.getScore(), encoded.get("score").getAsDouble()); + Assertions.assertEquals(candidate.getChannelScores().get("BM25").doubleValue(), + encoded.getAsJsonObject("scores").get("BM25").getAsDouble()); + Assertions.assertTrue(encoded.getAsJsonObject("scores").has("ALIAS_BM25")); + Assertions.assertTrue(encoded.getAsJsonObject("channelRanks").has("RRF")); + Assertions.assertTrue(result.getFacts().get(0).isMandatory()); + Assertions.assertTrue(result.getFacts().get(0).getScores().containsKey("RRF")); + Assertions.assertEquals(result.toJson(), facade.candidates(QueryMode.CURRENT, KEY, + "renewable", at(50), null, 20, profile).toJson()); + JsonObject baseline = facade.candidates(QueryMode.CURRENT, KEY, "renewable", + at(50), null, 20).toJson().getAsJsonArray("evidence").get(0).getAsJsonObject(); + Assertions.assertFalse(baseline.has("score")); + Assertions.assertFalse(baseline.has("channelRanks")); + Assertions.assertSame(snapshot, facade.getSnapshot()); + Assertions.assertEquals(operations, facade.getOperations()); + } + + private static TemporalMemoryFacade populated() { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + facade.replay(Arrays.asList(add("public", KEY, "Beijing", "renewable wind", 10), + add("other", new FactKey("bob", "city", "profile"), "Shanghai", "renewable solar", 15), + add("private", new FactKey("alice", "city", "private"), "Rome", "secret renewable", 20))); + return facade; + } + + @Test + public void testFourCandidateStrategiesExposeChannelsAndKeepSnapshotReadOnly() throws Exception { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + String text = "renewable wind powers cities"; + facade.replay(Arrays.asList(add("visible", KEY, "Beijing", text, 10), + add("hidden", new FactKey("alice", "city", "private"), "Rome", text, 20))); + CanonicalSnapshot snapshot = facade.getSnapshot(); + JsonArray operations = facade.getOperations(); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + CandidateResult result = facade.candidates(QueryMode.CURRENT, KEY, text, + at(50), null, 1, profile); + Assertions.assertEquals(1, result.getFacts().size()); + Assertions.assertTrue(result.getFacts().get(0).isMandatory()); + Assertions.assertEquals(1, result.getEvidence().size()); + JsonObject encoded = result.toJson(); + JsonObject fact = encoded.getAsJsonArray("facts").get(0).getAsJsonObject(); + JsonObject evidence = encoded.getAsJsonArray("evidence").get(0).getAsJsonObject(); + Assertions.assertTrue(fact.has("score")); + Assertions.assertTrue(fact.getAsJsonObject("channelRanks").has("RULE")); + Assertions.assertTrue(evidence.getAsJsonObject("scores").has("RULE")); + Assertions.assertEquals(profile.usesBm25(), evidence.getAsJsonObject("scores").has("BM25")); + Assertions.assertEquals(profile.usesMinHash(), evidence.getAsJsonObject("scores").has("MINHASH")); + Assertions.assertEquals("e-visible", result.getEvidence().get(0).getEvidence().getId()); + Assertions.assertEquals(1, result.getEvidence().get(0).getVersionIds().size()); + Assertions.assertTrue(facade.candidates(QueryMode.AS_OF, KEY, text, at(50), + at(9), 1, profile).getEvidence().isEmpty()); + Assertions.assertSame(snapshot, facade.getSnapshot()); + Assertions.assertEquals(operations, facade.getOperations()); + } + CandidateResult punctuation = facade.candidates(QueryMode.CURRENT, KEY, + "RENEWABLE, wind powers cities!", at(50), null, 1, RetrievalProfile.RULE_MINHASH); + Assertions.assertEquals(1, punctuation.getEvidence().size()); + Assertions.assertEquals(1.0, punctuation.getEvidence().get(0).getChannelScores().get("MINHASH")); + Assertions.assertFalse(punctuation.getEvidence().get(0).getChannelScores().containsKey("RULE")); + } + + private static NormalizedMemoryEvent add(String id, FactKey key, String city, String text, + long recordedAt) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity(key.getSubjectId(), "person"), key.getPredicate(), city); + return new EventNormalizer().normalize(MemoryEvent.add(id, fact, + new TimeInterval(BASE, at(100)), at(recordedAt), Collections.singletonList( + new Evidence("e-" + id, new Source("registry", "Registry"), text))), key); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/TemporalMemoryFacadeTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/TemporalMemoryFacadeTest.java new file mode 100644 index 000000000..e87189069 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/api/TemporalMemoryFacadeTest.java @@ -0,0 +1,267 @@ +/* + * 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.api; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +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.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.apache.geaflow.ai.temporal.trace.DecisionTrace; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TemporalMemoryFacadeTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testReplayReplacesOnlyAfterTheWholeInputSucceeds() { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + NormalizedMemoryEvent original = add("original", "Beijing", 10); + CanonicalSnapshot before = facade.applyBatch(Collections.singletonList(original)) + .getSnapshot(); + NormalizedMemoryEvent staged = add("staged", "Shanghai", 20); + NormalizedMemoryEvent invalid = correct("invalid", 30, "absent-version"); + + TemporalValidationException error = Assertions.assertThrows( + TemporalValidationException.class, + () -> facade.replay(Arrays.asList(staged, invalid))); + + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, error.getCode()); + Assertions.assertEquals(before, facade.getSnapshot()); + Assertions.assertTrue(facade.validateInvariants().isValid()); + ReplayResult replaced = facade.replay(Collections.singletonList(staged)); + Assertions.assertEquals(Collections.singletonList(staged), replaced.getSnapshot().getEvents()); + Assertions.assertEquals(new FullReplayOracle().replayToSnapshot( + Collections.singletonList(staged)), replaced.getSnapshot()); + Assertions.assertEquals("Shanghai", facade.query(QueryMode.CURRENT, KEY, at(1), null) + .getVersions().get(0).getFact().getLiteralValue().get()); + Assertions.assertFalse(replaced.getMethod().toString().isEmpty()); + Assertions.assertFalse(replaced.getStatistics().isEmpty()); + Assertions.assertNotNull(replaced.getFallbackStatus()); + } + + @Test + public void testLateSameTimeInputRevisesPriorMaterializationAndPreservesAttempt() { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + NormalizedMemoryEvent first = add("a", "Beijing", 10); + String target = VersionIdentity.create(first, first.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, first.getValidTime(), null); + facade.applyBatch(Collections.singletonList(first)); + DecisionTrace firstAttempt = latestAccepted(facade, "a"); + Assertions.assertEquals(target, firstAttempt.toJson().getAsJsonArray( + "generatedVersionIds").get(0).getAsString()); + NormalizedMemoryEvent second = correct("b", 10, target); + + facade.applyBatch(Collections.singletonList(second)); + + Assertions.assertEquals(1, facade.getSnapshot().getState().getVersions().size()); + Assertions.assertTrue(facade.getSnapshot().getState().getRelations().isEmpty()); + Assertions.assertTrue(facade.getTrace("a").contains(firstAttempt)); + JsonObject revised = latestAccepted(facade, "a").toJson(); + Assertions.assertTrue(revised.get("revision").getAsLong() + > firstAttempt.toJson().get("revision").getAsLong()); + Assertions.assertEquals(0, revised.getAsJsonArray("generatedVersionIds").size()); + Assertions.assertEquals(target, revised.getAsJsonArray("omittedVersionIds") + .get(0).getAsString()); + Assertions.assertTrue(facade.validateInvariants().getViolations().isEmpty()); + } + + @Test + public void testDuplicateAndRejectedAttemptsDoNotConsumeCommittedIdentity() { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + NormalizedMemoryEvent original = add("original", "Beijing", 10); + facade.applyBatch(Collections.singletonList(original)); + CanonicalSnapshot before = facade.getSnapshot(); + String acceptedId = latestAccepted(facade, "original").getId(); + ReplayResult duplicate = facade.applyBatch(Collections.singletonList(original)); + Assertions.assertEquals("DUPLICATE_NOOP", + duplicate.getDecisionTraces().get(0).toJson().get("code").getAsString()); + Assertions.assertEquals(acceptedId, latestAccepted(facade, "original").getId()); + Assertions.assertEquals(before, facade.getSnapshot()); + + NormalizedMemoryEvent staged = add("retry", "Shanghai", 20); + Assertions.assertThrows(TemporalValidationException.class, () -> facade.applyBatch( + Arrays.asList(staged, correct("invalid", 30, "absent-version")))); + Assertions.assertEquals(before, facade.getSnapshot()); + Assertions.assertEquals("BATCH_ROLLED_BACK", + facade.getTrace("retry").get(0).toJson().get("code").getAsString()); + NormalizedMemoryEvent changedRetry = add("retry", "Tianjin", 20); + ReplayResult retry = facade.applyBatch(Collections.singletonList(changedRetry)); + Assertions.assertEquals("ACCEPTED", + retry.getDecisionTraces().get(0).toJson().get("code").getAsString()); + Assertions.assertEquals(2, facade.getSnapshot().getEvents().size()); + } + + @Test + public void testEmptyAndUnknownQueriesHaveStableTraceAndNoFutureEvidence() { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + QueryResult empty = facade.query(QueryMode.CURRENT, KEY, at(1), null); + Assertions.assertTrue(empty.getVersions().isEmpty()); + Assertions.assertEquals(empty.getTrace().toJson(), + facade.getQueryTrace(empty.getTrace().getId()).toJson()); + facade.applyBatch(Collections.singletonList(add("a", "Beijing", 10))); + QueryResult past = facade.query(QueryMode.AS_OF, KEY, at(1), at(9)); + Assertions.assertTrue(past.getVersions().isEmpty()); + Assertions.assertEquals(0, + past.getTrace().toJson().getAsJsonArray("evidenceSources").size()); + QueryResult unknown = facade.query(QueryMode.CURRENT, + new FactKey("alice", "city", "missing"), at(1), null); + Assertions.assertTrue(unknown.getVersions().isEmpty()); + Assertions.assertEquals(3, facade.getQueryTraces().size()); + Assertions.assertTrue(facade.getTrace("unknown-event").isEmpty()); + } + + @Test + public void testPublicCityExampleAgainstHandAuthoredQueryExpectations() throws Exception { + TemporalJsonCodec codec = new TemporalJsonCodec(); + List events = new ArrayList<>(); + for (String line : resourceLines("example-events.jsonl")) { + events.add(codec.decodeEvent(line)); + } + Assertions.assertEquals(5, events.size()); + NormalizedMemoryEvent supplement = events.get(1); + String mergedId = VersionIdentity.create(supplement, + supplement.getEvent().getFact().get(), MemoryFactVersionStatus.ACTIVE, + events.get(0).getValidTime(), null); + Assertions.assertEquals(mergedId, events.get(2).getTargetVersionId().get(), + "The correction targets the supplemented Beijing branch"); + NormalizedMemoryEvent correction = events.get(2); + String shanghaiId = VersionIdentity.create(correction, + correction.getEvent().getFact().get(), MemoryFactVersionStatus.ACTIVE, + correction.getValidTime(), mergedId); + Assertions.assertEquals(shanghaiId, events.get(4).getTargetVersionId().get(), + "The retraction targets Shanghai, leaving the Tianjin conflict intact"); + + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + ReplayResult result = facade.replay(events); + Assertions.assertEquals(5, result.getSnapshot().getEvents().size()); + Assertions.assertEquals(9, result.getSnapshot().getState().getVersions().size()); + Assertions.assertTrue(facade.validateInvariants().isValid()); + int queries = 0; + for (String line : resourceLines("example-queries.jsonl")) { + JsonObject expected = codec.parseObject(line); + QueryResult actual = facade.query(QueryMode.valueOf(expected.get("mode").getAsString()), + KEY, Instant.parse(expected.get("validAt").getAsString()), + expected.get("systemAt").isJsonNull() ? null + : Instant.parse(expected.get("systemAt").getAsString())); + List expectedValues = new ArrayList<>(); + for (JsonElement value : expected.getAsJsonArray("values")) { + expectedValues.add(value.getAsString()); + } + List actualValues = new ArrayList<>(); + Set actualEvidence = new TreeSet<>(); + actual.getVersions().forEach(version -> { + actualValues.add(version.getFact().getLiteralValue().get()); + version.getEvidence().forEach(item -> actualEvidence.add(item.getId())); + }); + Collections.sort(actualValues); + Assertions.assertEquals(expectedValues, actualValues, line); + List expectedEvidence = new ArrayList<>(); + for (JsonElement value : expected.getAsJsonArray("evidenceIds")) { + expectedEvidence.add(value.getAsString()); + } + Assertions.assertEquals(expectedEvidence, new ArrayList<>(actualEvidence), line); + queries++; + } + Assertions.assertEquals(13, queries); + } + + private List resourceLines(String name) throws Exception { + List lines = new ArrayList<>(); + try (InputStream input = getClass().getResourceAsStream("/temporal/" + name)) { + Assertions.assertNotNull(input, name); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + } + } + return lines; + } + + private DecisionTrace latestAccepted(TemporalMemoryFacade facade, String eventId) { + List traces = facade.getTrace(eventId); + for (int index = traces.size() - 1; index >= 0; index--) { + DecisionTrace trace = traces.get(index); + if ("ACCEPTED".equals(trace.toJson().get("code").getAsString())) { + return trace; + } + } + throw new AssertionError("No accepted trace for " + eventId); + } + + private NormalizedMemoryEvent add(String id, String value, long recordedAt) { + return normalizer.normalize(MemoryEvent.add(id, fact(value), interval(), at(recordedAt), + evidence(id)), KEY); + } + + private NormalizedMemoryEvent correct(String id, long recordedAt, String target) { + return normalizer.normalize(MemoryEvent.correct(id, fact("Shanghai"), interval(), + at(recordedAt), evidence(id)).targeting(target), KEY); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-city", new MemoryEntity("alice", "person"), + "city", value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("registry", "Registry"), "Evidence for " + id)); + } + + private static TimeInterval interval() { + return new TimeInterval(BASE, at(5)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/baseline/BaselineReplayTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/baseline/BaselineReplayTest.java new file mode 100644 index 000000000..00f5f5ced --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/baseline/BaselineReplayTest.java @@ -0,0 +1,532 @@ +/* + * 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.baseline; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.ReplayMethod; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Defines deterministic current-state semantics for the two replay baselines. + */ +public class BaselineReplayTest { + + private static final FactKey PROFILE_KEY = new FactKey( + "person:alice", + "city", + "profile"); + private static final FactKey ACCOUNT_KEY = new FactKey( + "person:alice", + "city", + "account"); + private static final TimeInterval ALL_VALID_TIME = + TimeInterval.unboundedFrom(Instant.ofEpochMilli(Long.MIN_VALUE)); + + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testSameTimeConflictUsesEventIdTieBreakAndIgnoresValidTime() { + Instant recordedAt = Instant.ofEpochMilli(1000); + NormalizedMemoryEvent eventA = add( + "event-a", + "fact-a", + PROFILE_KEY, + "Beijing", + interval(100, 200), + recordedAt); + NormalizedMemoryEvent eventB = add( + "event-b", + "fact-b", + PROFILE_KEY, + "Rome", + interval(300, 400), + recordedAt); + List forward = Arrays.asList(eventA, eventB); + List reversed = Arrays.asList(eventB, eventA); + + ReplayMethod lww = new LwwBaseline(); + CanonicalSnapshot expectedLww = snapshot( + forward, + versions(PROFILE_KEY, version(eventB)), + eventB); + Assertions.assertEquals(expectedLww, lww.replayToSnapshot(forward)); + Assertions.assertEquals(expectedLww, lww.replayToSnapshot(reversed)); + + ReplayMethod singleTimestamp = new SingleTimestampBaseline(); + CanonicalSnapshot expectedSingle = snapshot( + forward, + versions(PROFILE_KEY, version(eventA), version(eventB)), + Collections.singletonList(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + versionId(eventA), + versionId(eventB))), + eventA, + eventB); + Assertions.assertEquals( + expectedSingle, + singleTimestamp.replayToSnapshot(forward)); + Assertions.assertEquals( + expectedSingle, + singleTimestamp.replayToSnapshot(reversed)); + } + + @Test + public void testSingleTimestampReplacesSameValueAndBuildsAllCurrentConflicts() { + NormalizedMemoryEvent firstBeijing = add( + "event-a", + "fact-a", + PROFILE_KEY, + "Beijing", + interval(100, 200), + Instant.ofEpochMilli(1000)); + NormalizedMemoryEvent latestBeijing = add( + "event-b", + "fact-b", + PROFILE_KEY, + "Beijing", + interval(300, 400), + Instant.ofEpochMilli(2000)); + NormalizedMemoryEvent rome = add( + "event-c", + "fact-c", + PROFILE_KEY, + "Rome", + interval(500, 600), + Instant.ofEpochMilli(3000)); + NormalizedMemoryEvent paris = add( + "event-d", + "fact-d", + PROFILE_KEY, + "Paris", + interval(700, 800), + Instant.ofEpochMilli(4000)); + List ordered = Arrays.asList( + firstBeijing, + latestBeijing, + rome, + paris); + List reversed = Arrays.asList( + paris, + rome, + latestBeijing, + firstBeijing); + + ReplayMethod method = new SingleTimestampBaseline(); + CanonicalSnapshot snapshot = method.replayToSnapshot(ordered); + + Assertions.assertEquals(snapshot, method.replayToSnapshot(reversed)); + Assertions.assertEquals( + Arrays.asList( + versionId(latestBeijing), + versionId(rome), + versionId(paris)), + versionIds(snapshot)); + Assertions.assertEquals( + latestBeijing.getEventId(), + snapshot.getGeneratingEventIds().get( + versionId(latestBeijing))); + Assertions.assertEquals( + Arrays.asList( + conflict(latestBeijing, rome), + conflict(latestBeijing, paris), + conflict(rome, paris)), + snapshot.getState().getRelations()); + } + + @Test + public void testLatePartialCorrectionReplacesCurrentStateWithoutHistory() { + NormalizedMemoryEvent first = add( + "event-add-a", + "fact-a", + PROFILE_KEY, + "Beijing", + interval(100, 900), + Instant.ofEpochMilli(1000)); + NormalizedMemoryEvent conflict = add( + "event-add-b", + "fact-b", + PROFILE_KEY, + "Rome", + interval(100, 900), + Instant.ofEpochMilli(2000)); + NormalizedMemoryEvent correction = correct( + "event-correct", + "fact-a", + PROFILE_KEY, + "Shanghai", + interval(400, 500), + Instant.ofEpochMilli(3000), + targetVersionId(first)); + List chronological = + Arrays.asList(first, conflict, correction); + List arrivalOrder = + Arrays.asList(correction, conflict, first); + CanonicalSnapshot expected = snapshot( + chronological, + versions(PROFILE_KEY, version(correction)), + correction); + + for (ReplayMethod method : methods()) { + Assertions.assertEquals( + expected, + method.replayToSnapshot(chronological)); + Assertions.assertEquals( + expected, + method.replayToSnapshot(arrivalOrder)); + } + } + + @Test + public void testRetractClearsCurrentStateAndLaterAddRestoresIt() { + NormalizedMemoryEvent first = add( + "event-add-a", + "fact-a", + PROFILE_KEY, + "Beijing", + interval(100, 900), + Instant.ofEpochMilli(1000)); + NormalizedMemoryEvent second = add( + "event-add-b", + "fact-b", + PROFILE_KEY, + "Rome", + interval(100, 900), + Instant.ofEpochMilli(2000)); + NormalizedMemoryEvent retract = retract( + "event-retract", + "fact-b", + PROFILE_KEY, + interval(450, 460), + Instant.ofEpochMilli(3000), + targetVersionId(second)); + NormalizedMemoryEvent recovery = add( + "event-recover", + "fact-c", + PROFILE_KEY, + "Paris", + interval(700, 800), + Instant.ofEpochMilli(4000)); + List throughRetract = + Arrays.asList(retract, second, first); + CanonicalSnapshot expectedRetracted = snapshot( + throughRetract, + Collections.emptyMap()); + List throughRecovery = + Arrays.asList(recovery, retract, second, first); + CanonicalSnapshot expectedRecovered = snapshot( + throughRecovery, + versions(PROFILE_KEY, version(recovery)), + recovery); + NormalizedMemoryEvent correctionWithoutCurrent = correct( + "event-orphan-correct", + "fact-a", + PROFILE_KEY, + "London", + interval(100, 200), + Instant.ofEpochMilli(1000), + "unknown-target-version"); + NormalizedMemoryEvent retractWithoutCurrent = retract( + "event-orphan-retract", + "fact-a", + PROFILE_KEY, + interval(100, 200), + Instant.ofEpochMilli(1000), + "unknown-target-version"); + + for (ReplayMethod method : methods()) { + Assertions.assertEquals( + expectedRetracted, + method.replayToSnapshot(throughRetract)); + Assertions.assertEquals( + expectedRecovered, + method.replayToSnapshot(throughRecovery)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> method.replayToSnapshot( + Collections.singletonList(correctionWithoutCurrent))); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> method.replayToSnapshot( + Collections.singletonList(retractWithoutCurrent))); + Assertions.assertEquals( + expectedRetracted, + method.replayToSnapshot(throughRetract)); + } + } + + @Test + public void testDuplicateIsNoopAndEventIdReuseIsAtomic() { + NormalizedMemoryEvent original = add( + "event-a", + "fact-a", + PROFILE_KEY, + "Beijing", + interval(100, 900), + Instant.ofEpochMilli(1000)); + NormalizedMemoryEvent reused = add( + "event-a", + "fact-a", + PROFILE_KEY, + "Rome", + interval(100, 900), + Instant.ofEpochMilli(1000)); + NormalizedMemoryEvent retract = retract( + "event-retract", + "fact-a", + PROFILE_KEY, + interval(200, 300), + Instant.ofEpochMilli(2000), + targetVersionId(original)); + CanonicalSnapshot expected = snapshot( + Collections.singletonList(original), + versions(PROFILE_KEY, version(original)), + original); + CanonicalSnapshot expectedRetracted = snapshot( + Arrays.asList(original, retract), + Collections.emptyMap()); + + for (ReplayMethod method : methods()) { + Assertions.assertEquals( + expected, + method.replayToSnapshot( + Arrays.asList(original, original))); + Assertions.assertEquals( + expectedRetracted, + method.replayToSnapshot( + Arrays.asList(original, retract, retract))); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> method.replayToSnapshot( + Arrays.asList(original, reused))); + Assertions.assertEquals( + expected, + method.replayToSnapshot( + Collections.singletonList(original))); + } + } + + @Test + public void testFactKeyScopeKeepsIndependentCurrentState() { + Instant recordedAt = Instant.ofEpochMilli(1000); + NormalizedMemoryEvent profile = add( + "event-profile", + "fact-shared", + PROFILE_KEY, + "Beijing", + interval(100, 200), + recordedAt); + NormalizedMemoryEvent account = add( + "event-account", + "fact-shared", + ACCOUNT_KEY, + "Rome", + interval(300, 400), + recordedAt); + Map> current = new LinkedHashMap<>(); + current.put(PROFILE_KEY, Collections.singletonList(version(profile))); + current.put(ACCOUNT_KEY, Collections.singletonList(version(account))); + CanonicalSnapshot expected = snapshot( + Arrays.asList(profile, account), + current, + profile, + account); + + for (ReplayMethod method : methods()) { + Assertions.assertEquals( + expected, + method.replayToSnapshot(Arrays.asList(profile, account))); + } + } + + private static ReplayMethod[] methods() { + return new ReplayMethod[] { + new LwwBaseline(), + new SingleTimestampBaseline() + }; + } + + private NormalizedMemoryEvent add( + String eventId, + String factId, + FactKey key, + String value, + TimeInterval validTime, + Instant recordedAt) { + return normalizer.normalize( + MemoryEvent.add( + eventId, + fact(factId, key, value), + validTime, + recordedAt, + evidence(eventId)), + key); + } + + private NormalizedMemoryEvent correct( + String eventId, + String factId, + FactKey key, + String value, + TimeInterval validTime, + Instant recordedAt, + String targetVersionId) { + return normalizer.normalize( + MemoryEvent.correct( + eventId, + fact(factId, key, value), + validTime, + recordedAt, + evidence(eventId)).targeting(targetVersionId), + key); + } + + private NormalizedMemoryEvent retract( + String eventId, + String factId, + FactKey key, + TimeInterval validTime, + Instant recordedAt, + String targetVersionId) { + return normalizer.normalize( + MemoryEvent.retract( + eventId, + factId, + validTime, + recordedAt, + evidence(eventId)).targeting(targetVersionId), + key); + } + + private static MemoryFact fact( + String factId, + FactKey key, + String value) { + return MemoryFact.attribute( + factId, + new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), + value); + } + + private static List evidence(String eventId) { + return Collections.singletonList(new Evidence( + "evidence-" + eventId, + new Source("source-registry", "registry"), + "Evidence for " + eventId)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval( + Instant.ofEpochMilli(start), + Instant.ofEpochMilli(end)); + } + + private static MemoryFactVersion version( + NormalizedMemoryEvent event) { + return new MemoryFactVersion( + versionId(event), + event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + ALL_VALID_TIME, + TimeInterval.unboundedFrom(event.getRecordedAt()), + event.getEvidence()); + } + + private static String versionId(NormalizedMemoryEvent event) { + return event.getEventId() + ":version:0"; + } + + private static String targetVersionId(NormalizedMemoryEvent event) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, event.getValidTime(), null); + } + + private static List versionIds(CanonicalSnapshot snapshot) { + List ids = new ArrayList<>(); + for (MemoryFactVersion version : snapshot.getState().getVersions()) { + ids.add(version.getId()); + } + return ids; + } + + private static VersionRelation conflict( + NormalizedMemoryEvent left, + NormalizedMemoryEvent right) { + return new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + versionId(left), + versionId(right)); + } + + private static Map> versions( + FactKey key, + MemoryFactVersion... values) { + return Collections.singletonMap(key, Arrays.asList(values)); + } + + private static CanonicalSnapshot snapshot( + List events, + Map> versions, + NormalizedMemoryEvent... generatingEvents) { + return snapshot( + events, + versions, + Collections.emptyList(), + generatingEvents); + } + + private static CanonicalSnapshot snapshot( + List events, + Map> versions, + List relations, + NormalizedMemoryEvent... generatingEvents) { + Map generating = new LinkedHashMap<>(); + for (NormalizedMemoryEvent event : generatingEvents) { + generating.put(versionId(event), event.getEventId()); + } + return new CanonicalSnapshot( + new TemporalState(versions, relations), + events, + generating); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/ClimateFeverDatasetTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/ClimateFeverDatasetTest.java new file mode 100644 index 000000000..cdc7effc7 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/ClimateFeverDatasetTest.java @@ -0,0 +1,187 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class ClimateFeverDatasetTest { + + private static final Gson GSON = new Gson(); + @TempDir + Path directory; + + @Test + public void testNumericOrderTextDeduplicationGoldAndSourceMappings() throws Exception { + ClimateFeverDataset dataset = load("fixture.jsonl", fixture()); + Assertions.assertEquals(Arrays.asList("2", "3", "4", "10"), dataset.getClaims().stream() + .map(ClimateFeverDataset.Claim::getId).collect(Collectors.toList())); + Assertions.assertEquals(3, dataset.getCorpus().size()); + Assertions.assertEquals(2, dataset.getClaims().get(0).getGoldIds().size()); + Assertions.assertEquals(1, dataset.getClaims().get(1).getGoldIds().size()); + Assertions.assertEquals(2, dataset.getClaims().get(2).getGoldIds().size()); + Assertions.assertTrue(dataset.getClaims().get(3).getGoldIds().isEmpty()); + JsonObject manifest = dataset.getManifest(); + Assertions.assertEquals(5, manifest.get("positivePairCount").getAsInt()); + Assertions.assertEquals(1, manifest.get("zeroGoldClaimCount").getAsInt()); + Assertions.assertEquals(1, manifest.get("notEnoughInfoClaimCount").getAsInt()); + Assertions.assertEquals("unknown", manifest.get("license").getAsString()); + Assertions.assertFalse(manifest.get("officialReleaseBytes").getAsBoolean()); + dataset.writeArtifacts(directory); + String mappings = read(directory.resolve("source-mappings.jsonl")); + Assertions.assertEquals(7, mappings.trim().split("\n").length); + Assertions.assertTrue(mappings.contains("alpha-alias")); + Assertions.assertTrue(mappings.contains("Other article")); + for (String entry : new String[] {"subset", "corpus", "gold"}) { + Assertions.assertEquals(manifest.get(entry + "Sha256").getAsString(), + ClimateFeverDataset.sha256(Files.readAllBytes(directory.resolve(entry + ".jsonl")))); + } + List reversed = fixture(); + Collections.reverse(reversed); + ClimateFeverDataset reordered = load("reversed.jsonl", reversed); + for (String name : new String[] {"subsetSha256", "corpusSha256", "mappingsSha256", "goldSha256"}) { + Assertions.assertEquals(manifest.get(name), reordered.getManifest().get(name)); + } + manifest.addProperty("license", "changed"); + Assertions.assertEquals("unknown", dataset.getManifest().get("license").getAsString()); + } + + @Test + public void testFixedFirstThousandUseNumericClaimOrder() throws Exception { + List records = new ArrayList<>(); + for (int id = 1004; id >= 0; id--) { + records.add(record(Integer.toString(id), "query", "NOT_ENOUGH_INFO")); + } + ClimateFeverDataset dataset = load("prefix.jsonl", records); + Assertions.assertEquals(1000, dataset.getClaims().size()); + Assertions.assertEquals("0", dataset.getClaims().get(0).getId()); + Assertions.assertEquals("999", dataset.getClaims().get(999).getId()); + Assertions.assertEquals(1005, dataset.getManifest().get("rawClaimCount").getAsInt()); + Assertions.assertEquals(1000, dataset.getManifest().get("zeroGoldClaimCount").getAsInt()); + } + + @Test + public void testHashMismatchDuplicateIdsAndInvalidBoundaryData() throws Exception { + Path path = write("valid.jsonl", fixture()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> ClimateFeverDataset.load(path, String.join("", Collections.nCopies(64, "0")))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> ClimateFeverDataset.load(path, "wrong")); + List> invalid = Arrays.asList( + Arrays.asList(record("1", "query", "SUPPORTS"), record("1", "other", "REFUTES")), + Collections.singletonList(record("01", "query", "NOT_ENOUGH_INFO")), + Collections.singletonList(record("-1", "query", "NOT_ENOUGH_INFO")), + Collections.singletonList(record("1", " ", "NOT_ENOUGH_INFO")), + Collections.singletonList(record("1", "query", "unknown")), + Collections.singletonList(record("1", "query", "SUPPORTS", + evidence("id", "alpha", "SUPPORTS", "article"), + evidence("id", "alpha", "REFUTES", "article"))), + Arrays.asList(record("1", "query", "SUPPORTS", evidence("id", "alpha", "SUPPORTS", "article")), + record("2", "query", "SUPPORTS", evidence("id", "different", "SUPPORTS", "article"))), + Collections.singletonList(record("1", "query", "SUPPORTS", + evidence("id", "alpha", "DISPUTED", "article")))); + for (int index = 0; index < invalid.size(); index++) { + List records = invalid.get(index); + String name = "invalid-" + index + ".jsonl"; + Assertions.assertThrows(IllegalArgumentException.class, () -> load(name, records), name); + } + JsonObject numericId = record("1", "query", "NOT_ENOUGH_INFO"); + numericId.addProperty("claim_id", 1); + Assertions.assertThrows(IllegalArgumentException.class, + () -> load("numeric-id.jsonl", Collections.singletonList(numericId))); + Path malformed = directory.resolve("malformed.jsonl"); + Files.write(malformed, "{broken}\n".getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, () -> ClimateFeverDataset.load(malformed, + ClimateFeverDataset.sha256(Files.readAllBytes(malformed)))); + } + + @Test + public void testCaseSensitiveIdentityRemainsSeparateFromRetrievalNormalization() throws Exception { + ClimateFeverDataset dataset = load("case.jsonl", Collections.singletonList(record("0", "alpha", + "SUPPORTS", evidence("lower", "alpha", "SUPPORTS", "A"), + evidence("upper", "ALPHA", "SUPPORTS", "A")))); + Assertions.assertEquals(2, dataset.getCorpus().size()); + Assertions.assertEquals(2, dataset.getClaims().get(0).getGoldIds().size()); + } + + static List fixture() { + return new ArrayList<>(Arrays.asList( + record("10", "gamma", "NOT_ENOUGH_INFO", evidence("g", "gamma", "NOT_ENOUGH_INFO", "Gamma")), + record("4", "alpha beta", "DISPUTED", evidence("a", "alpha", "SUPPORTS", "Alpha"), + evidence("b", "beta", "REFUTES", "Beta")), + record("2", "alpha", "DISPUTED", evidence("a", "alpha", "SUPPORTS", "Alpha"), + evidence("alpha-alias", "alpha", "SUPPORTS", "Other article"), + evidence("b", "beta", "REFUTES", "Beta")), + record("3", "beta", "REFUTES", evidence("b", "beta", "REFUTES", "Beta")))); + } + + static JsonObject record(String id, String text, String label, JsonObject... evidences) { + JsonObject record = new JsonObject(); + record.addProperty("claim_id", id); + record.addProperty("claim", text); + record.addProperty("claim_label", label); + JsonArray array = new JsonArray(); + for (JsonObject evidence : evidences) { + array.add(evidence); + } + record.add("evidences", array); + return record; + } + + static JsonObject evidence(String id, String text, String label, String article) { + JsonObject evidence = new JsonObject(); + evidence.addProperty("evidence_id", id); + evidence.addProperty("evidence", text); + evidence.addProperty("evidence_label", label); + evidence.addProperty("article", article); + return evidence; + } + + private ClimateFeverDataset load(String name, List records) throws Exception { + Path path = write(name, records); + return ClimateFeverDataset.load(path, ClimateFeverDataset.sha256(Files.readAllBytes(path))); + } + + private Path write(String name, List records) throws Exception { + Path path = directory.resolve(name); + StringBuilder content = new StringBuilder(); + for (JsonObject record : records) { + content.append(GSON.toJson(record)).append('\n'); + } + Files.write(path, content.toString().getBytes(StandardCharsets.UTF_8)); + return path; + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/FactGoldEvaluationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/FactGoldEvaluationTest.java new file mode 100644 index 000000000..46ddc1082 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/FactGoldEvaluationTest.java @@ -0,0 +1,207 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class FactGoldEvaluationTest { + + private static final TemporalJsonCodec CODEC = new TemporalJsonCodec(); + + @TempDir + Path directory; + + @Test + public void testFullGoldRunsAllProductionStrategiesAndPublishesDiagnostics() throws Exception { + final Path output = directory.resolve("full"); + final JsonObject manifest = new FactGoldEvaluation().evaluate(gold(), output); + Assertions.assertTrue(manifest.get("passed").getAsBoolean()); + Assertions.assertEquals(12, manifest.get("fixtureCount").getAsInt()); + Assertions.assertEquals(48, manifest.get("caseCount").getAsInt()); + Assertions.assertEquals(1, manifest.get("topK").getAsInt()); + Assertions.assertEquals(12, manifest.getAsJsonObject("inputSha256").entrySet().size()); + for (JsonElement item : manifest.getAsJsonArray("profiles")) { + final JsonObject result = item.getAsJsonObject(); + Assertions.assertEquals(12, result.get("caseCount").getAsInt()); + Assertions.assertEquals(12, result.get("passedCaseCount").getAsInt()); + Assertions.assertEquals(3, result.get("conflictTP").getAsInt()); + Assertions.assertEquals(0, result.get("conflictFP").getAsInt()); + Assertions.assertEquals(0, result.get("conflictFN").getAsInt()); + Assertions.assertEquals(1.0, result.get("conflictF1").getAsDouble(), 0.0); + Assertions.assertEquals(10, result.get("correctNegativeCaseCount").getAsInt()); + Assertions.assertEquals(5, result.get("rejectedBatchCount").getAsInt()); + for (String category : Arrays.asList("semantic", "graph", "query", "decision", "oracle")) { + Assertions.assertEquals(12, result.get(category + "PassedCount").getAsInt()); + } + } + for (JsonElement item : manifest.getAsJsonArray("cases")) { + final JsonObject result = item.getAsJsonObject(); + if (result.get("negativeCaseCount").getAsInt() == 1) { + Assertions.assertTrue(result.get("conflictPrecision").isJsonNull()); + Assertions.assertTrue(result.get("conflictRecall").isJsonNull()); + Assertions.assertTrue(result.get("conflictF1").isJsonNull()); + } + final Path diagnostics = output.resolve("diagnostics") + .resolve(result.get("profile").getAsString()).resolve(result.get("fixture").getAsString()); + for (String file : Arrays.asList("actual-snapshot.json", "expected-snapshot.json", + "checks.json", "decision-traces.jsonl", "query-traces.jsonl")) { + Assertions.assertTrue(Files.isRegularFile(diagnostics.resolve(file)), file); + } + } + Assertions.assertEquals(5, Files.readAllLines(output.resolve("comparison.csv")).size()); + Assertions.assertEquals(49, Files.readAllLines(output.resolve("cases.csv")).size()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new FactGoldEvaluation().evaluate(gold(), output)); + } + + @Test + public void testMetricsCountRealFalsePositivesAndFalseNegativesAndCanonicalEdges() { + final VersionRelation common = conflict("a", "b"); + final JsonObject result = FactGoldEvaluation.conflictMetrics( + Arrays.asList(common, conflict("b", "c")), + Arrays.asList(common, conflict("d", "c"), conflict("b", "a"), + new VersionRelation(VersionRelationType.SUPERSEDES, "x", "y"))); + Assertions.assertEquals(1, result.get("conflictTP").getAsInt()); + Assertions.assertEquals(1, result.get("conflictFP").getAsInt()); + Assertions.assertEquals(1, result.get("conflictFN").getAsInt()); + Assertions.assertEquals(0.5, result.get("conflictF1").getAsDouble(), 0.0); + final JsonObject empty = FactGoldEvaluation.conflictMetrics( + Collections.emptyList(), Collections.emptyList()); + Assertions.assertTrue(empty.get("conflictF1").isJsonNull()); + Assertions.assertTrue(empty.get("conflictPrecision").isJsonNull()); + Assertions.assertTrue(empty.get("conflictRecall").isJsonNull()); + final JsonObject missed = FactGoldEvaluation.conflictMetrics( + Collections.singletonList(common), Collections.emptyList()); + Assertions.assertEquals(1, missed.get("conflictFN").getAsInt()); + Assertions.assertEquals(0.0, missed.get("conflictF1").getAsDouble(), 0.0); + } + + @Test + public void testChangedManualGoldProducesRealFpFnAndNonzeroCliWithoutRewritingGold() throws Exception { + final Path input = copyGold("changed-gold"); + final Path positive = input.resolve("05-conflict-coexistence.json"); + final JsonObject removed = read(positive); + removed.getAsJsonObject("expected").add("relations", new JsonArray()); + write(positive, removed); + final Path negative = input.resolve("06-adjacent-intervals.json"); + final JsonObject added = read(negative); + final JsonObject edge = new JsonObject(); + edge.addProperty("type", "CONFLICTS_WITH"); + edge.addProperty("from", "left-version"); + edge.addProperty("to", "right-version"); + added.getAsJsonObject("expected").getAsJsonArray("relations").add(edge); + write(negative, added); + final byte[] before = Files.readAllBytes(negative); + final Path output = directory.resolve("mismatch"); + + Assertions.assertEquals(1, cli(input, output, new ByteArrayOutputStream())); + + Assertions.assertArrayEquals(before, Files.readAllBytes(negative)); + final JsonObject manifest = read(output.resolve("manifest.json")); + Assertions.assertFalse(manifest.get("passed").getAsBoolean()); + Assertions.assertEquals(48, manifest.getAsJsonArray("cases").size()); + for (JsonElement item : manifest.getAsJsonArray("profiles")) { + Assertions.assertEquals(1, item.getAsJsonObject().get("conflictFP").getAsInt()); + Assertions.assertEquals(1, item.getAsJsonObject().get("conflictFN").getAsInt()); + Assertions.assertEquals(10, item.getAsJsonObject().get("passedCaseCount").getAsInt()); + } + } + + @Test + public void testCliRejectsUnknownAliasNonemptyOutputAndUnknownOptions() throws Exception { + final Path input = copyGold("unknown-alias"); + final Path file = input.resolve("01-assert-entity-reference.json"); + final JsonObject fixture = read(file); + final JsonArray unknown = new JsonArray(); + unknown.add(new JsonPrimitive("unknown-version")); + fixture.getAsJsonObject("expected").getAsJsonArray("queries").get(0) + .getAsJsonObject().add("versions", unknown); + write(file, fixture); + final ByteArrayOutputStream errors = new ByteArrayOutputStream(); + Assertions.assertEquals(2, cli(input, directory.resolve("bad"), errors)); + Assertions.assertTrue(new String(errors.toByteArray(), StandardCharsets.UTF_8) + .contains("Unknown Gold query version alias")); + final Path occupied = Files.createDirectory(directory.resolve("occupied")); + final Path sentinel = occupied.resolve("keep.txt"); + Files.write(sentinel, Collections.singletonList("keep"), StandardCharsets.UTF_8); + Assertions.assertEquals(2, cli(gold(), occupied, new ByteArrayOutputStream())); + Assertions.assertEquals(Collections.singletonList("keep"), Files.readAllLines(sentinel)); + try (PrintStream sink = new PrintStream(new ByteArrayOutputStream())) { + Assertions.assertEquals(2, new TemporalFactGoldCli().run( + new String[] {"--gold-directory", "x", "--unknown", "x"}, sink, sink)); + } + } + + private int cli(Path input, Path output, ByteArrayOutputStream errors) throws Exception { + try (PrintStream out = new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"); + PrintStream err = new PrintStream(errors, true, "UTF-8")) { + return new TemporalFactGoldCli().run(new String[] { + "--gold-directory", input.toString(), "--output", output.toString()}, out, err); + } + } + + private Path copyGold(String name) throws Exception { + final Path copy = Files.createDirectory(directory.resolve(name)); + try (Stream paths = Files.list(gold())) { + final List files = paths.collect(Collectors.toList()); + for (Path file : files) { + Files.copy(file, copy.resolve(file.getFileName())); + } + } + return copy; + } + + private Path gold() throws Exception { + return Paths.get(getClass().getResource("/temporal/gold/01-assert-entity-reference.json") + .toURI()).getParent(); + } + + private static VersionRelation conflict(String from, String to) { + return new VersionRelation(VersionRelationType.CONFLICTS_WITH, from, to); + } + + private static JsonObject read(Path path) throws Exception { + return CODEC.parseObject(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); + } + + private static void write(Path path, JsonObject value) throws Exception { + Files.write(path, CODEC.toJson(value).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineEvaluationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineEvaluationTest.java new file mode 100644 index 000000000..735cfe2d4 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineEvaluationTest.java @@ -0,0 +1,178 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class RetrievalBaselineEvaluationTest { + + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + + @TempDir + Path directory; + + @Test + public void testRulePriorityNegativeLogitsAndEvidenceIdTies() { + final JsonArray candidates = candidates("z", "b", "a", "rule-a", "rule-z"); + Assertions.assertEquals(Arrays.asList(3, 4, 0, 2, 1), + RetrievalBaselineEvaluation.rankCandidates(candidates, + new double[] {-1, -2, -2, -99, -100}, new boolean[] {false, false, false, true, true})); + Assertions.assertEquals(Arrays.asList(1, 0), RetrievalBaselineEvaluation.rankCandidates( + candidates("z", "a"), new double[] {0.0, -0.0}, new boolean[] {false, false})); + Assertions.assertThrows(IllegalArgumentException.class, () -> RetrievalBaselineEvaluation.rankCandidates( + candidates("a"), new double[] {Double.NaN}, new boolean[] {false})); + } + + @Test + public void testZeroGoldExcludedFromEveryRecallDenominator() { + final RetrievalBaselineEvaluation.Metrics totals = new RetrievalBaselineEvaluation.Metrics(); + totals.add(0, 0, 0, 200); + final JsonObject empty = totals.summary(); + Assertions.assertTrue(empty.get("macroRecall").isJsonNull()); + Assertions.assertTrue(empty.get("microRecall").isJsonNull()); + Assertions.assertTrue(empty.get("zeroHitRate").isJsonNull()); + totals.add(2, 1, 2, 200); + totals.add(1, 0, 0, 3); + final JsonObject result = totals.summary(); + Assertions.assertEquals(3, result.get("evaluatedQueryCount").getAsInt()); + Assertions.assertEquals(2, result.get("positiveQueryCount").getAsInt()); + Assertions.assertEquals(1, result.get("zeroGoldQueryCount").getAsInt()); + Assertions.assertEquals(403, result.get("candidatePairCount").getAsInt()); + Assertions.assertEquals(0.25, result.get("macroRecall").getAsDouble()); + Assertions.assertEquals(1.0 / 3, result.get("microRecall").getAsDouble()); + Assertions.assertEquals(0.5, result.get("anyHitRate").getAsDouble()); + Assertions.assertEquals(0.0, result.get("allGoldHitRate").getAsDouble()); + Assertions.assertEquals(0.5, result.get("zeroHitRate").getAsDouble()); + Assertions.assertEquals(0.5, result.get("poolMacroRecall").getAsDouble()); + Assertions.assertEquals(2.0 / 3, result.get("poolMicroRecall").getAsDouble()); + } + + @Test + public void testScoreRequiresCompleteOrderedPoolAndFiniteLogits() { + final JsonObject query = query(candidates("a", "b")); + final JsonObject score = score(query, -4.5, -8.0); + Assertions.assertDoesNotThrow(() -> RetrievalBaselineEvaluation.scoreRow(score, query)); + score.getAsJsonArray("scores").get(0).getAsJsonObject().addProperty("evidenceId", "b"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.scoreRow(score, query)); + final JsonObject wrongPool = score(query, -4.5, -8.0); + wrongPool.addProperty("poolSha256", repeat('0', 64)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.scoreRow(wrongPool, query)); + final JsonObject missing = score(query, -4.5); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.scoreRow(missing, query)); + final JsonObject overflow = score(query, -4.5, -8.0); + overflow.getAsJsonArray("scores").get(0).getAsJsonObject().addProperty("logit", Double.POSITIVE_INFINITY); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.scoreRow(overflow, query)); + final JsonObject wrongSchemaType = score(query, -4.5, -8.0); + wrongSchemaType.addProperty("schemaVersion", true); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.scoreRow(wrongSchemaType, query)); + } + + @Test + public void testPoolFingerprintAndDuplicateFieldsAreRejected() { + final JsonObject query = query(candidates("a", "b")); + Assertions.assertDoesNotThrow(() -> RetrievalBaselineEvaluation.validateQuery(query)); + query.getAsJsonArray("candidates").get(0).getAsJsonObject().addProperty("text", "changed"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.validateQuery(query)); + final JsonObject duplicateCandidate = query(candidates("a", "a")); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.validateQuery(duplicateCandidate)); + final TemporalJsonCodec codec = new TemporalJsonCodec(); + Assertions.assertThrows(IllegalArgumentException.class, + () -> codec.parseObject("{\"queryId\":\"1\",\"queryId\":\"2\"}")); + Assertions.assertThrows(IllegalArgumentException.class, + () -> codec.parseObject("{\"logit\":NaN}")); + } + + @Test + public void testPreparedFileHashMismatchIsRejected() throws Exception { + final JsonObject hashes = new JsonObject(); + for (String file : new String[] {"subset.jsonl", "corpus.jsonl", "gold.jsonl", "source-mappings.jsonl", + "titles.jsonl", "title-input.jsonl", "candidates.csv"}) { + final Path path = directory.resolve(file); + Files.write(path, (file + "\n").getBytes(StandardCharsets.UTF_8)); + hashes.addProperty(file, RetrievalBaselineEvaluation.sha256(path)); + } + Assertions.assertDoesNotThrow(() -> RetrievalBaselineEvaluation.verifyFiles(directory, hashes)); + Files.write(directory.resolve("title-input.jsonl"), "changed\n".getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineEvaluation.verifyFiles(directory, hashes)); + } + + private static JsonArray candidates(String... ids) { + final JsonArray result = new JsonArray(); + for (String id : ids) { + final JsonObject candidate = new JsonObject(); + candidate.addProperty("evidenceId", id); + candidate.addProperty("text", "Article\nOriginal body " + id + "\u2028"); + result.add(candidate); + } + return result; + } + + private static JsonObject query(JsonArray candidates) { + final JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", 1); + result.addProperty("queryId", "1"); + result.addProperty("queryText", "Claim"); + result.add("candidates", candidates); + result.addProperty("poolSha256", ClimateFeverDataset.sha256(JSON.toJson(result).getBytes(StandardCharsets.UTF_8))); + return result; + } + + private static JsonObject score(JsonObject query, double... logits) { + final JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", 1); + result.addProperty("queryId", "1"); + result.add("poolSha256", query.get("poolSha256")); + result.addProperty("elapsedNanos", 1); + final JsonArray scores = new JsonArray(); + for (int index = 0; index < logits.length; index++) { + final JsonObject value = new JsonObject(); + value.add("evidenceId", query.getAsJsonArray("candidates").get(index).getAsJsonObject().get("evidenceId")); + value.addProperty("logit", logits[index]); + scores.add(value); + } + result.add("scores", scores); + return result; + } + + private static String repeat(char value, int count) { + final char[] result = new char[count]; + Arrays.fill(result, value); + return new String(result); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselinePreparationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselinePreparationTest.java new file mode 100644 index 000000000..e7209bb12 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselinePreparationTest.java @@ -0,0 +1,117 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.apache.geaflow.ai.temporal.candidate.EvidenceCandidate; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.Source; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class RetrievalBaselinePreparationTest { + + @TempDir + Path directory; + + @Test + public void testRuleFirstOriginalHundredAndDeduplicatedEnglishAppend() { + final List english = new ArrayList<>(); + for (int index = 0; index < 220; index++) { + final Evidence evidence = new Evidence("e" + index, new Source("source", "article"), + "text " + index); + english.add(new EvidenceCandidate(evidence, Collections.emptyList(), 1.0, index + 1)); + } + final Evidence rule = english.get(219).getEvidence(); + final List pool = RetrievalBaselinePreparation.candidatePool( + Collections.singletonList(rule), english.subList(0, 100), english); + Assertions.assertEquals(200, pool.size()); + Assertions.assertSame(rule, pool.get(0)); + Assertions.assertEquals("e98", pool.get(99).getId()); + Assertions.assertEquals("e99", pool.get(100).getId()); + Assertions.assertEquals("e198", pool.get(199).getId()); + Assertions.assertEquals(200, pool.stream().map(Evidence::getId).distinct().count()); + } + + @Test + public void testGlobalTitleBodyIdentityRuleAndInputsIndependentOfGold() throws Exception { + final RetrievalBaselinePreparation preparation = new RetrievalBaselinePreparation(); + final Path first = directory.resolve("first"); + final Path relabeled = directory.resolve("relabeled"); + final JsonObject manifest = preparation.prepare(dataset("SUPPORTS"), first, 1000); + preparation.prepare(dataset("NOT_ENOUGH_INFO"), relabeled, 1000); + for (String file : new String[] {"title-input.jsonl", "candidates.csv"}) { + Assertions.assertArrayEquals(Files.readAllBytes(first.resolve(file)), + Files.readAllBytes(relabeled.resolve(file))); + } + final JsonObject query = new JsonParser().parse(new String( + Files.readAllBytes(first.resolve("title-input.jsonl")), StandardCharsets.UTF_8).trim()) + .getAsJsonObject(); + final JsonObject exact = query.getAsJsonArray("candidates").get(0).getAsJsonObject(); + final String bodyId = "climate-evidence-" + ClimateFeverDataset.sha256( + "alpha climate".getBytes(StandardCharsets.UTF_8)); + Assertions.assertEquals(bodyId, exact.get("evidenceId").getAsString()); + Assertions.assertEquals("A article\nalpha climate", exact.get("text").getAsString()); + Assertions.assertEquals(5, query.entrySet().size()); + Assertions.assertEquals(2, exact.entrySet().size()); + Assertions.assertEquals("0,1," + bodyId + ",true", + Files.readAllLines(first.resolve("candidates.csv"), StandardCharsets.UTF_8).get(1)); + Assertions.assertEquals("retrieval-baseline-inputs-v1", manifest.get("schema").getAsString()); + Assertions.assertEquals("CE_TITLE_BODY", manifest.get("profile").getAsString()); + Assertions.assertEquals(7, manifest.getAsJsonObject("outputSha256").entrySet().size()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> preparation.prepare(dataset("SUPPORTS"), first, 1000)); + final Path rejected = directory.resolve("rejected"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> preparation.prepare(dataset("SUPPORTS"), rejected, 0)); + Assertions.assertFalse(Files.exists(rejected.resolve("manifest.json"))); + } + + private ClimateFeverDataset dataset(String label) throws Exception { + final JsonObject claim = new JsonObject(); + claim.addProperty("claim_id", "0"); + claim.addProperty("claim", "ALPHA climate"); + claim.addProperty("claim_label", label); + final JsonArray evidences = new JsonArray(); + for (int index = 0; index < 3; index++) { + final JsonObject evidence = new JsonObject(); + evidence.addProperty("evidence_id", "raw-" + index); + evidence.addProperty("evidence", index == 2 ? "climate changes" : "alpha climate"); + evidence.addProperty("article", index == 0 ? "Z article" : "A article"); + evidence.addProperty("evidence_label", label); + evidences.add(evidence); + } + claim.add("evidences", evidences); + final byte[] bytes = (new Gson().toJson(claim) + "\n").getBytes(StandardCharsets.UTF_8); + final Path raw = directory.resolve(label + ".jsonl"); + Files.write(raw, bytes); + return ClimateFeverDataset.load(raw, ClimateFeverDataset.sha256(bytes)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineRunnerTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineRunnerTest.java new file mode 100644 index 000000000..3aeec26fc --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBaselineRunnerTest.java @@ -0,0 +1,84 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Locale; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class RetrievalBaselineRunnerTest { + + @TempDir + Path directory; + + @Test + public void testProtectedInputsAndNonemptyOutputRemainUntouched() throws Exception { + final Path module = Files.createDirectory(directory.resolve("module")); + final Path cache = Files.createDirectories(module.resolve("target/cross-encoder-cache")); + final Path weights = cache.resolve("weights.bin"); + final byte[] original = "keep model cache".getBytes(StandardCharsets.UTF_8); + Files.write(weights, original); + final Path input = module.resolve("target/climate-fever/raw.jsonl"); + final Path python = module.resolve("target/cross-encoder-cuda-venv/Scripts/python.exe"); + final Path reused = Files.createDirectory(directory.resolve("completed-inference")); + for (Path output : Arrays.asList(module, module.resolve("src/results"), cache.resolve("new-run"), + module.resolve("target"), reused.resolve("new-run"))) { + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineRunner.initializeOutput(module, input, output, python, cache, reused)); + } + final Path nonempty = Files.createDirectory(directory.resolve("existing-results")); + final Path marker = nonempty.resolve("manifest.json"); + Files.write(marker, original); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalBaselineRunner.initializeOutput(module, input, nonempty, python, cache, reused)); + Assertions.assertArrayEquals(original, Files.readAllBytes(weights)); + Assertions.assertArrayEquals(original, Files.readAllBytes(marker)); + final Path empty = directory.resolve("new-results"); + Assertions.assertEquals(empty.toAbsolutePath(), + RetrievalBaselineRunner.initializeOutput(module, input, empty, python, cache, reused)); + } + + @Test + public void testNonzeroProcessStopsAndRetainsErrorLog() throws Exception { + final boolean windows = System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("windows"); + final Path java = Paths.get(System.getProperty("java.home"), "bin", windows ? "java.exe" : "java"); + final Path logs = Files.createDirectory(directory.resolve("logs with spaces")); + final JsonArray processes = new JsonArray(); + final IOException failure = Assertions.assertThrows(IOException.class, + () -> RetrievalBaselineRunner.runScoring(Arrays.asList(java.toString(), + "-invalid-temporal-test-option"), directory, logs, processes)); + Assertions.assertTrue(failure.getMessage().contains("exit code")); + Assertions.assertTrue(Files.size(logs.resolve("scoring.stderr.log")) > 0); + Assertions.assertEquals(1, processes.size()); + final JsonObject record = processes.get(0).getAsJsonObject(); + Assertions.assertEquals("FAILED", record.get("status").getAsString()); + Assertions.assertNotEquals(0, record.get("exitCode").getAsInt()); + Assertions.assertTrue(record.get("wallMillis").getAsDouble() >= 0); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBodyViewScoresTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBodyViewScoresTest.java new file mode 100644 index 000000000..b86e137a8 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalBodyViewScoresTest.java @@ -0,0 +1,235 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class RetrievalBodyViewScoresTest { + + private static final Gson JSON = new GsonBuilder().disableHtmlEscaping().create(); + + @TempDir + Path directory; + + @Test + public void testProjectionUsesIdsAndRetainsComplete500SourceMetadata() throws Exception { + final Fixture fixture = fixture(500); + try (RetrievalBodyViewScores source = fixture.open()) { + Assertions.assertArrayEquals(new double[] {150, -280}, source.project(fixture.current)); + final JsonObject provenance = source.finish(); + Assertions.assertEquals(500, provenance.get("sourceMaxCandidates").getAsInt()); + Assertions.assertEquals(500, provenance.get("sourcePairCount").getAsInt()); + Assertions.assertEquals(2, provenance.get("projectedPairCount").getAsInt()); + Assertions.assertEquals(7, provenance.get("sourceInferenceNanos").getAsInt()); + Assertions.assertEquals(fixture.metadata, provenance.getAsJsonObject("originalMetadata")); + Assertions.assertEquals(500, + provenance.getAsJsonObject("originalMetadata").get("maxCandidates").getAsInt()); + } + } + + @Test + public void testMissingCoverageAndWrongBodyAreRejected() throws Exception { + final Fixture fixture = fixture(200); + fixture.corpus.put("absent", new RetrievalBaselineEvaluation.Body("Absent body", "Article")); + fixture.current.getAsJsonArray("candidates").get(0).getAsJsonObject().addProperty("evidenceId", "absent"); + fixture.current.getAsJsonArray("candidates").get(0).getAsJsonObject() + .addProperty("text", "Article\nAbsent body"); + sign(fixture.current); + try (RetrievalBodyViewScores source = fixture.open()) { + Assertions.assertThrows(IllegalArgumentException.class, () -> source.project(fixture.current)); + } + fixture.input.getAsJsonArray("candidates").get(0).getAsJsonObject().addProperty("text", "Wrong body"); + fixture.save(); + try (RetrievalBodyViewScores source = fixture.open()) { + Assertions.assertThrows(IllegalArgumentException.class, () -> source.project(fixture.current)); + } + } + + @Test + public void testUnselectedSourceScoreMustStillBeFiniteAndOrdered() throws Exception { + final Fixture fixture = fixture(500); + fixture.score.getAsJsonArray("scores").get(499).getAsJsonObject() + .addProperty("logit", new BigDecimal("1e999")); + fixture.save(); + try (RetrievalBodyViewScores source = fixture.open()) { + Assertions.assertThrows(IllegalArgumentException.class, () -> source.project(fixture.current)); + } + fixture.score.getAsJsonArray("scores").get(499).getAsJsonObject().addProperty("logit", 199); + fixture.score.getAsJsonArray("scores").get(499).getAsJsonObject().addProperty("evidenceId", "wrong-id"); + fixture.save(); + try (RetrievalBodyViewScores source = fixture.open()) { + Assertions.assertThrows(IllegalArgumentException.class, () -> source.project(fixture.current)); + } + } + + @Test + public void testSourceTotalsTrailingRowsAndMutationAreRejected() throws Exception { + final Fixture fixture = fixture(200); + fixture.metadata.addProperty("inferenceNanos", 8); + fixture.save(); + try (RetrievalBodyViewScores source = fixture.open()) { + source.project(fixture.current); + Assertions.assertThrows(IllegalArgumentException.class, source::finish); + } + fixture.metadata.addProperty("inferenceNanos", 7); + fixture.save(); + Files.write(fixture.scoresPath, (JSON.toJson(fixture.score) + "\n" + JSON.toJson(fixture.score) + "\n") + .getBytes(StandardCharsets.UTF_8)); + fixture.metadata.addProperty("scoresSha256", RetrievalBaselineEvaluation.sha256(fixture.scoresPath)); + fixture.writeMetadata(); + try (RetrievalBodyViewScores source = fixture.open()) { + source.project(fixture.current); + Assertions.assertThrows(IllegalArgumentException.class, source::finish); + } + fixture.save(); + try (RetrievalBodyViewScores source = fixture.open()) { + source.project(fixture.current); + Files.write(fixture.metadataPath, (JSON.toJson(fixture.metadata) + "\n\n").getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, source::finish); + } + } + + @Test + public void testWrongInputHashQueryTextAndSourceDepthAreRejected() throws Exception { + final Fixture fixture = fixture(200); + fixture.metadata.addProperty("inputSha256", RetrievalBaselineEvaluation.hashText("different")); + fixture.writeMetadata(); + Assertions.assertThrows(IllegalArgumentException.class, fixture::open); + fixture.save(); + fixture.current.addProperty("queryText", "Different query"); + sign(fixture.current); + try (RetrievalBodyViewScores source = fixture.open()) { + Assertions.assertThrows(IllegalArgumentException.class, () -> source.project(fixture.current)); + } + fixture.metadata.addProperty("maxCandidates", 1000); + fixture.writeMetadata(); + Assertions.assertThrows(IllegalArgumentException.class, fixture::open); + } + + private Fixture fixture(int depth) throws Exception { + return new Fixture(depth); + } + + private static void sign(JsonObject query) { + query.remove("poolSha256"); + query.addProperty("poolSha256", RetrievalBaselineEvaluation.hashText(JSON.toJson(query))); + } + + private final class Fixture { + private final Path inputPath = directory.resolve("body-input.jsonl"); + private final Path scoresPath = directory.resolve("scores.jsonl"); + private final Path metadataPath = directory.resolve("metadata.json"); + private final Map corpus = new HashMap<>(); + private final JsonObject input = query(); + private final JsonObject current = query(); + private final JsonObject score = new JsonObject(); + private final JsonObject metadata = new JsonObject(); + + private Fixture(int depth) throws Exception { + final JsonArray candidates = new JsonArray(); + final JsonArray values = new JsonArray(); + for (int index = 0; index < depth; index++) { + final String id = "evidence-" + index; + final RetrievalBaselineEvaluation.Body body = + new RetrievalBaselineEvaluation.Body("Body " + index, "Article"); + corpus.put(id, body); + candidates.add(candidate(id, body.text)); + final JsonObject value = new JsonObject(); + value.addProperty("evidenceId", id); + value.addProperty("logit", (double) index - 300); + values.add(value); + } + input.add("candidates", candidates); + final JsonArray selected = new JsonArray(); + for (int index : new int[] {depth - 50, 20}) { + final String id = "evidence-" + index; + selected.add(candidate(id, "Article\n" + corpus.get(id).text)); + } + current.add("candidates", selected); + sign(current); + score.addProperty("schemaVersion", 1); + score.addProperty("queryId", "1"); + score.addProperty("elapsedNanos", 7); + score.add("scores", values); + metadata.addProperty("schemaVersion", 1); + metadata.addProperty("modelId", "cross-encoder/ms-marco-MiniLM-L6-v2"); + metadata.addProperty("modelRevision", "233902d25c440f23af6f7d6e94d2946bac0bee0a"); + metadata.addProperty("device", "cpu"); + metadata.addProperty("maxLength", 512); + metadata.addProperty("maxCandidates", depth); + metadata.addProperty("batchSize", 16); + metadata.addProperty("threads", 4); + metadata.addProperty("scoreKind", "raw_logit"); + metadata.addProperty("timingScope", "complete_pool"); + metadata.addProperty("queryCount", 1); + metadata.addProperty("pairCount", depth); + metadata.addProperty("loadNanos", 3); + metadata.addProperty("inferenceNanos", 7); + metadata.addProperty("torchVersion", "2.8.0"); + metadata.addProperty("transformersVersion", "4.57.1"); + save(); + } + + private void save() throws Exception { + sign(input); + score.add("poolSha256", input.get("poolSha256")); + Files.write(inputPath, (JSON.toJson(input) + "\n").getBytes(StandardCharsets.UTF_8)); + Files.write(scoresPath, (JSON.toJson(score) + "\n").getBytes(StandardCharsets.UTF_8)); + metadata.addProperty("inputSha256", RetrievalBaselineEvaluation.sha256(inputPath)); + metadata.addProperty("scoresSha256", RetrievalBaselineEvaluation.sha256(scoresPath)); + writeMetadata(); + } + + private void writeMetadata() throws Exception { + Files.write(metadataPath, (JSON.toJson(metadata) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + private RetrievalBodyViewScores open() throws Exception { + return new RetrievalBodyViewScores(inputPath, scoresPath, metadataPath, corpus, 1); + } + + private JsonObject query() { + final JsonObject result = new JsonObject(); + result.addProperty("schemaVersion", 1); + result.addProperty("queryId", "1"); + result.addProperty("queryText", "Claim"); + return result; + } + + private JsonObject candidate(String id, String body) { + final JsonObject result = new JsonObject(); + result.addProperty("evidenceId", id); + result.addProperty("text", body); + return result; + } + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalCandidateEvaluationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalCandidateEvaluationTest.java new file mode 100644 index 000000000..98dab8cff --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalCandidateEvaluationTest.java @@ -0,0 +1,231 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class RetrievalCandidateEvaluationTest { + + private static final Gson GSON = new Gson(); + private static final List ARTIFACTS = Arrays.asList("subset.jsonl", "corpus.jsonl", + "source-mappings.jsonl", "gold.jsonl", "titles.jsonl", "comparison.csv", + "query-results.csv", "final-candidates.csv"); + @TempDir + Path directory; + + @Test + public void testFourProfilesSixCutoffsAndGoldDenominators() throws Exception { + final ClimateFeverDataset dataset = load("fixture.jsonl", ClimateFeverDatasetTest.fixture()); + final Path output = directory.resolve("result"); + final JsonObject result = new RetrievalCandidateEvaluation().evaluate(dataset, output); + Assertions.assertEquals("retrieval-candidates-v1", result.get("schema").getAsString()); + Assertions.assertEquals("COMPLETED", result.get("status").getAsString()); + Assertions.assertEquals("cpu", result.get("device").getAsString()); + Assertions.assertFalse(result.get("modelInference").getAsBoolean()); + Assertions.assertEquals(4, result.get("queryCount").getAsInt()); + Assertions.assertEquals(3, result.get("corpusCount").getAsInt()); + Assertions.assertEquals(100, result.get("maxCandidates").getAsInt()); + Assertions.assertEquals(24, result.getAsJsonArray("comparison").size()); + final Set combinations = new HashSet<>(); + for (JsonElement item : result.getAsJsonArray("comparison")) { + final JsonObject summary = item.getAsJsonObject(); + Assertions.assertEquals(4, summary.get("evaluatedQueryCount").getAsInt()); + Assertions.assertEquals(3, summary.get("positiveQueryCount").getAsInt()); + Assertions.assertEquals(1, summary.get("zeroGoldQueryCount").getAsInt()); + Assertions.assertEquals(5, summary.get("positivePairCount").getAsInt()); + Assertions.assertTrue(combinations.add(summary.get("profile").getAsString() + + ":" + summary.get("k").getAsInt())); + } + for (String profile : Arrays.asList("RULE_ONLY", "RULE_BM25", "RULE_MINHASH", "RULE_ALL")) { + for (int k : new int[] {1, 5, 10, 20, 50, 100}) { + Assertions.assertTrue(combinations.contains(profile + ":" + k)); + } + } + assertMetrics(summary(result, "RULE_ONLY", 1), 2, 0.5, 0.4, 2.0 / 3, 1.0 / 3, 1.0 / 3); + assertMetrics(summary(result, "RULE_ONLY", 100), 2, 0.5, 0.4, 2.0 / 3, 1.0 / 3, 1.0 / 3); + assertMetrics(summary(result, "RULE_BM25", 1), 3, 2.0 / 3, 0.6, 1, 1.0 / 3, 0); + assertMetrics(summary(result, "RULE_BM25", 100), 4, 5.0 / 6, 0.8, 1, 2.0 / 3, 0); + final List candidates = Files.readAllLines(output.resolve("final-candidates.csv"), + StandardCharsets.UTF_8); + Assertions.assertEquals(Arrays.asList("RULE_ONLY,2,1," + evidenceId("alpha"), + "RULE_ONLY,3,1," + evidenceId("beta"), "RULE_ONLY,10,1," + evidenceId("gamma")), candidates.stream() + .filter(line -> line.startsWith("RULE_ONLY,")) + .map(line -> String.join(",", Arrays.copyOf(line.split(",", 5), 4))) + .collect(Collectors.toList())); + Assertions.assertEquals(Arrays.asList("RULE_BM25,2,1," + evidenceId("alpha"), + "RULE_BM25,3,1," + evidenceId("beta"), "RULE_BM25,4,1," + evidenceId("alpha"), + "RULE_BM25,4,2," + evidenceId("beta"), "RULE_BM25,10,1," + evidenceId("gamma")), candidates.stream() + .filter(line -> line.startsWith("RULE_BM25,")) + .map(line -> String.join(",", Arrays.copyOf(line.split(",", 5), 4))) + .collect(Collectors.toList())); + final String queries = read(output.resolve("query-results.csv")); + Assertions.assertTrue(queries.contains("RULE_ONLY,1,10,NOT_ENOUGH_INFO,0,1,1,0,0,")); + Assertions.assertEquals(25, Files.readAllLines(output.resolve("comparison.csv")).size()); + Assertions.assertEquals(97, Files.readAllLines(output.resolve("query-results.csv")).size()); + assertOutputHashes(result, output); + // Compare the persisted JSON; float primitives and parsed decimals have different equality semantics. + Assertions.assertEquals(new TemporalJsonCodec().toJson(result), + read(output.resolve("manifest.json")).trim()); + } + + @Test + public void testLabelsCannotChangeCandidateInputsOrRankingAndRerunsAreDeterministic() throws Exception { + final List records = ClimateFeverDatasetTest.fixture(); + final ClimateFeverDataset original = load("original.jsonl", records); + final RetrievalCandidateEvaluation evaluation = new RetrievalCandidateEvaluation(); + final Path first = directory.resolve("first"); + final Path repeated = directory.resolve("repeated"); + evaluation.evaluate(original, first); + evaluation.evaluate(original, repeated); + for (String name : ARTIFACTS) { + Assertions.assertArrayEquals(Files.readAllBytes(first.resolve(name)), + Files.readAllBytes(repeated.resolve(name)), name); + } + for (JsonObject record : records) { + record.addProperty("claim_label", "NOT_ENOUGH_INFO"); + for (JsonElement item : record.getAsJsonArray("evidences")) { + final JsonObject evidence = item.getAsJsonObject(); + final String label = evidence.get("evidence_label").getAsString(); + evidence.addProperty("evidence_label", "SUPPORTS".equals(label) ? "REFUTES" : "SUPPORTS"); + } + } + final Path relabeled = directory.resolve("relabeled"); + evaluation.evaluate(load("relabeled.jsonl", records), relabeled); + for (String name : Arrays.asList("corpus.jsonl", "titles.jsonl")) { + Assertions.assertArrayEquals(Files.readAllBytes(first.resolve(name)), + Files.readAllBytes(relabeled.resolve(name)), name); + } + Assertions.assertEquals(rankings(first), rankings(relabeled)); + } + + @Test + public void testCandidateBudgetIsOneHundredEvenWithMoreMatchingGold() throws Exception { + final JsonObject[] evidence = new JsonObject[101]; + for (int index = 0; index < evidence.length; index++) { + evidence[index] = ClimateFeverDatasetTest.evidence("item-" + index, + "shared token" + index, "SUPPORTS", "Article"); + } + final ClimateFeverDataset dataset = load("large.jsonl", Collections.singletonList( + ClimateFeverDatasetTest.record("0", "shared", "SUPPORTS", evidence))); + final Path output = directory.resolve("large"); + final JsonObject result = new RetrievalCandidateEvaluation().evaluate(dataset, output); + final JsonObject summary = summary(result, "RULE_BM25", 100); + Assertions.assertEquals(100, summary.get("candidatePairCount").getAsInt()); + Assertions.assertEquals(100, summary.get("hits").getAsInt()); + Assertions.assertEquals(100.0 / 101, summary.get("microRecall").getAsDouble(), 1e-12); + final List rows = Files.readAllLines(output.resolve("final-candidates.csv")); + for (String profile : Arrays.asList("RULE_ONLY", "RULE_BM25", "RULE_MINHASH", "RULE_ALL")) { + final List candidates = rows.stream().filter(line -> line.startsWith(profile + ",")) + .collect(Collectors.toList()); + Assertions.assertTrue(candidates.size() <= 100); + for (int rank = 0; rank < candidates.size(); rank++) { + Assertions.assertEquals(rank + 1, Integer.parseInt(candidates.get(rank).split(",", 5)[2])); + } + } + } + + @Test + public void testExistingOutputIsNeverOverwritten() throws Exception { + final ClimateFeverDataset dataset = load("fixture.jsonl", ClimateFeverDatasetTest.fixture()); + final Path output = directory.resolve("existing"); + Files.createDirectory(output); + final Path sentinel = output.resolve("keep.txt"); + Files.write(sentinel, "keep".getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new RetrievalCandidateEvaluation().evaluate(dataset, output)); + Assertions.assertEquals("keep", read(sentinel)); + try (java.util.stream.Stream entries = Files.list(output)) { + Assertions.assertEquals(1, entries.count()); + } + } + + private ClimateFeverDataset load(String name, List records) throws Exception { + final Path input = directory.resolve(name); + final StringBuilder contents = new StringBuilder(); + for (JsonObject record : records) { + contents.append(GSON.toJson(record)).append('\n'); + } + Files.write(input, contents.toString().getBytes(StandardCharsets.UTF_8)); + return ClimateFeverDataset.load(input, ClimateFeverDataset.sha256(Files.readAllBytes(input))); + } + + private static JsonObject summary(JsonObject result, String profile, int k) { + for (JsonElement item : result.getAsJsonArray("comparison")) { + final JsonObject row = item.getAsJsonObject(); + if (profile.equals(row.get("profile").getAsString()) && row.get("k").getAsInt() == k) { + return row; + } + } + throw new AssertionError("Missing profile/cutoff: " + profile + "/" + k); + } + + private static void assertMetrics(JsonObject summary, int hits, double macro, double micro, + double any, double all, double zero) { + Assertions.assertEquals(hits, summary.get("hits").getAsInt()); + Assertions.assertEquals(macro, summary.get("macroRecall").getAsDouble(), 1e-12); + Assertions.assertEquals(micro, summary.get("microRecall").getAsDouble(), 1e-12); + Assertions.assertEquals(any, summary.get("anyHitRate").getAsDouble(), 1e-12); + Assertions.assertEquals(all, summary.get("allGoldHitRate").getAsDouble(), 1e-12); + Assertions.assertEquals(zero, summary.get("zeroHitRate").getAsDouble(), 1e-12); + } + + private static void assertOutputHashes(JsonObject result, Path output) throws Exception { + final JsonObject hashes = result.getAsJsonObject("outputSha256"); + Assertions.assertEquals(new HashSet<>(ARTIFACTS), hashes.entrySet().stream() + .map(Map.Entry::getKey).collect(Collectors.toSet())); + for (String name : ARTIFACTS) { + Assertions.assertEquals(ClimateFeverDataset.sha256(Files.readAllBytes(output.resolve(name))), + hashes.get(name).getAsString(), name); + } + } + + private static List rankings(Path output) throws Exception { + final List result = new ArrayList<>(); + for (String line : Files.readAllLines(output.resolve("final-candidates.csv"))) { + result.add(line.substring(0, line.lastIndexOf(','))); + } + return result; + } + + private static String evidenceId(String text) { + return "climate-evidence-" + ClimateFeverDataset.sha256(text.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewEvaluationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewEvaluationTest.java new file mode 100644 index 000000000..c274d1660 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewEvaluationTest.java @@ -0,0 +1,146 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class RetrievalDualViewEvaluationTest { + + @Test + public void testThreeToOneRrfUsesRanksInsteadOfLogitMagnitudes() { + // Title ranks A..F and body ranks B..F,A give B > A > C with k=60 and 3:1 weights. + Assertions.assertEquals(Arrays.asList(1, 0, 2, 3, 4, 5), + RetrievalDualViewEvaluation.rankCandidates(candidates("A", "B", "C", "D", "E", "F"), + new double[] {1000000, 5, 4, 3, 2, 1}, + new double[] {-1000000, 1000, 999, 998, 997, 996}, new boolean[6])); + } + + @Test + public void testRulesStayFirstInOriginalPoolOrder() { + Assertions.assertEquals(Arrays.asList(3, 4, 0, 2, 1), + RetrievalDualViewEvaluation.rankCandidates(candidates("z", "b", "a", "rule-z", "rule-a"), + new double[] {-1, -2, -2, -99, -100}, + new double[] {-100, -3, -3, -2, -1}, + new boolean[] {false, false, false, true, true})); + } + + @Test + public void testPureTitleWeightPreservesBaselineIncludingTiesAndNegativeScores() { + final JsonArray candidates = candidates("z", "b", "a", "plus", "minus", "rule-z", "rule-a"); + final double[] title = {-1, -2, -2, 0.0, -0.0, -100, -99}; + final boolean[] rules = {false, false, false, false, false, true, true}; + final List expected = Arrays.asList(5, 6, 4, 3, 0, 2, 1); + Assertions.assertEquals(expected, RetrievalBaselineEvaluation.rankCandidates(candidates, title, rules)); + Assertions.assertEquals(expected, RetrievalDualViewEvaluation.rankCandidates(candidates, title, + new double[] {100, 99, 98, 97, 96, 95, 94}, rules, 1.0)); + } + + @Test + public void testCeRanksUseEvidenceIdForEqualLogitsIncludingSignedZero() { + Assertions.assertArrayEquals(new int[] {1, 3, 2, 4}, RetrievalDualViewEvaluation.ceRanks( + candidates("z", "b", "a", "last"), new double[] {-1, -2, -2, -3})); + Assertions.assertArrayEquals(new int[] {2, 1}, RetrievalDualViewEvaluation.ceRanks( + candidates("z", "a"), new double[] {0.0, -0.0})); + } + + @Test + public void testEqualRrfScoresUseTitleRankBeforeOriginalPoolOrder() { + final String[] ids = new String[126]; + final double[] title = new double[126]; + final double[] body = new double[126]; + for (int index = 0; index < ids.length; index++) { + ids[index] = "e" + index; + title[index] = -index; + body[index] = -index; + } + title[125] = -122; + title[122] = -125; + body[125] = 1; + body[122] = 2; + // (title 123, body 2) and (title 126, body 1) tie: .75/183=.25/61 and .75/186=.25/62. + final List order = RetrievalDualViewEvaluation.rankCandidates(candidates(ids), + title, body, new boolean[126]); + Assertions.assertTrue(order.indexOf(125) < order.indexOf(122)); + } + + @Test + public void testInvalidScoresAndPoolSizesAreRejected() { + final JsonArray candidates = candidates("a"); + for (double value : new double[] {Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY}) { + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.ceRanks(candidates, new double[] {value})); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.rankCandidates(candidates, new double[] {value}, + new double[] {0}, new boolean[1])); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.rankCandidates(candidates, new double[] {0}, + new double[] {value}, new boolean[1])); + } + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.ceRanks(candidates, new double[0])); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.rankCandidates(candidates, new double[0], + new double[1], new boolean[1])); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.rankCandidates(candidates, new double[1], + new double[0], new boolean[1])); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.rankCandidates(candidates, new double[1], + new double[1], new boolean[0])); + final JsonArray oversized = candidates(new String[201]); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewEvaluation.rankCandidates(oversized, new double[201], + new double[201], new boolean[201])); + } + + @Test + public void testRankingPreservesAllTwoHundredCandidates() { + final String[] ids = new String[200]; + final double[] logits = new double[200]; + final List expected = new ArrayList<>(); + for (int index = 0; index < ids.length; index++) { + ids[index] = "e" + index; + logits[index] = -index; + expected.add(index); + } + Assertions.assertEquals(expected, RetrievalDualViewEvaluation.rankCandidates(candidates(ids), + logits, logits, new boolean[200])); + Assertions.assertEquals(Collections.emptyList(), RetrievalDualViewEvaluation.rankCandidates( + candidates(), new double[0], new double[0], new boolean[0])); + } + + private static JsonArray candidates(String... ids) { + final JsonArray result = new JsonArray(); + for (int index = 0; index < ids.length; index++) { + final JsonObject candidate = new JsonObject(); + candidate.addProperty("evidenceId", ids[index] == null ? "e" + index : ids[index]); + candidate.addProperty("text", "Article\nOriginal body " + index); + result.add(candidate); + } + return result; + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewRunnerTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewRunnerTest.java new file mode 100644 index 000000000..b0fff09d8 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/RetrievalDualViewRunnerTest.java @@ -0,0 +1,98 @@ +/* + * 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.benchmark; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class RetrievalDualViewRunnerTest { + + @TempDir + Path directory; + + @Test + public void testBodyCommandKeepsFixedModelSettingsAndUsesBodyInput() { + final Path input = directory.resolve("body input.jsonl"); + final List command = RetrievalDualViewRunner.bodyScoringCommand(directory.resolve("python"), + directory.resolve("scorer.py"), input, directory.resolve("inference"), directory.resolve("cache"), + "cuda", true); + Assertions.assertEquals(input.toString(), command.get(command.indexOf("--input") + 1)); + Assertions.assertEquals("200", command.get(command.indexOf("--max-candidates") + 1)); + Assertions.assertEquals("512", command.get(command.indexOf("--max-length") + 1)); + Assertions.assertEquals("16", command.get(command.indexOf("--batch-size") + 1)); + Assertions.assertEquals("4", command.get(command.indexOf("--threads") + 1)); + Assertions.assertTrue(command.contains("--local-files-only")); + } + + @Test + public void testReusedBodySourcesCannotOverlapOutputOrBeSuppliedAlone() throws Exception { + final Path module = Files.createDirectory(directory.resolve("module")); + final Path input = module.resolve("target/climate-fever/raw.jsonl"); + final Path python = module.resolve("target/cross-encoder-cuda-venv/Scripts/python.exe"); + final Path cache = module.resolve("target/cross-encoder-cache"); + final Path body = Files.createDirectories(directory.resolve("historical/body-inference")); + final Path bodyInput = directory.resolve("historical/prepared/rerank-input.jsonl"); + Files.createDirectories(bodyInput.getParent()); + final byte[] original = "historical evidence".getBytes(StandardCharsets.UTF_8); + Files.write(bodyInput, original); + final Path unusedOutput = directory.resolve("unused-output"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewRunner.initializeOutput(module, input, unusedOutput, python, cache, + null, body, null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewRunner.initializeOutput(module, input, unusedOutput, python, cache, + null, null, bodyInput)); + Assertions.assertFalse(Files.exists(unusedOutput)); + for (Path output : Arrays.asList(directory.resolve("historical"), body, body.resolve("nested"), + bodyInput, bodyInput.resolve("nested"), bodyInput.getParent(), + bodyInput.getParent().resolve("new-results"))) { + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewRunner.initializeOutput(module, input, output, python, cache, + null, body, bodyInput)); + } + Assertions.assertArrayEquals(original, Files.readAllBytes(bodyInput)); + try (Stream files = Files.list(body)) { + Assertions.assertEquals(0, files.count()); + } + } + + @Test + public void testCompletedSourceRunRejectsNewSiblingDirectories() throws Exception { + final Path module = Files.createDirectory(directory.resolve("module")); + final Path input = module.resolve("target/climate-fever/raw.jsonl"); + final Path python = module.resolve("target/cross-encoder-cuda-venv/Scripts/python.exe"); + final Path cache = module.resolve("target/cross-encoder-cache"); + final Path historical = Files.createDirectory(directory.resolve("completed-run")); + final Path inference = Files.createDirectory(historical.resolve("inference")); + Files.write(historical.resolve("manifest.json"), "{}".getBytes(StandardCharsets.UTF_8)); + final Path attemptedOutput = historical.resolve("new-experiment"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> RetrievalDualViewRunner.initializeOutput(module, input, attemptedOutput, python, cache, + inference, null, null)); + Assertions.assertFalse(Files.exists(attemptedOutput)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineCliTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineCliTest.java new file mode 100644 index 000000000..ef7c0baa7 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineCliTest.java @@ -0,0 +1,55 @@ +/* + * 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.benchmark; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TemporalRetrievalBaselineCliTest { + + @Test + public void testRejectsMissingUnknownAndDuplicateOptionsWithoutStartingInference() throws Exception { + rejected(); + rejected("--output", "unused"); + rejected("--module-root", "unused", "--output", "unused", "--unknown", "value"); + rejected("--module-root", "unused", "--output", "unused", "--output", "duplicate"); + } + + @Test + public void testRejectsInvalidOfflineValueWithoutStartingInference() throws Exception { + rejected("--module-root", "unused", "--output", "unused", "--offline", "maybe"); + } + + private static void rejected(String... arguments) throws Exception { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final ByteArrayOutputStream errors = new ByteArrayOutputStream(); + try (PrintStream out = new PrintStream(output, true, StandardCharsets.UTF_8.name()); + PrintStream err = new PrintStream(errors, true, StandardCharsets.UTF_8.name())) { + Assertions.assertEquals(2, new TemporalRetrievalBaselineCli().run(arguments, out, err)); + } + Assertions.assertEquals(0, output.size()); + Assertions.assertEquals("REJECT_INPUT", new TemporalJsonCodec().parseObject( + new String(errors.toByteArray(), StandardCharsets.UTF_8)).get("code").getAsString()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineIT.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineIT.java new file mode 100644 index 000000000..a2a8fece3 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalBaselineIT.java @@ -0,0 +1,74 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonObject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Explicit full benchmark: run this class in an IDE or with -Dtest=TemporalRetrievalBaselineIT. */ +public class TemporalRetrievalBaselineIT { + + @Test + public void evaluateFixedTitleBodyBaseline() throws Exception { + final Path module = moduleRoot(); + final Path configuredOutput = optionalPath("output"); + final Path output = configuredOutput == null + ? module.getParent().resolve("tmp/retrieval-baseline-" + + Instant.now().toString().replace(':', '-')) : configuredOutput; + final String offline = System.getProperty("temporal.retrieval.offline", "true"); + Assertions.assertTrue("true".equals(offline) || "false".equals(offline), + "temporal.retrieval.offline must be true or false"); + final JsonObject result = new RetrievalBaselineRunner().run(module, optionalPath("input"), output, + System.getProperty("temporal.retrieval.device", "cuda"), optionalPath("python"), + optionalPath("reuseInference"), Boolean.parseBoolean(offline)); + Assertions.assertEquals("COMPLETED", result.get("status").getAsString()); + Assertions.assertEquals("CE_TITLE_BODY", result.get("profile").getAsString()); + Assertions.assertEquals(1000, result.get("queryCount").getAsInt()); + Assertions.assertEquals(200, result.get("depth").getAsInt()); + Assertions.assertEquals(20, result.get("finalK").getAsInt()); + Assertions.assertTrue(result.get("pairCount").getAsLong() > 0); + Assertions.assertTrue(Files.isRegularFile(output.resolve("comparison.csv"))); + } + + private static Path moduleRoot() { + final Path configured = optionalPath("moduleRoot"); + if (configured != null) { + return configured.toAbsolutePath().normalize(); + } + final Path current = Paths.get("").toAbsolutePath().normalize(); + if (Files.isDirectory(current.resolve("geaflow-ai/src"))) { + return current.resolve("geaflow-ai"); + } + if (Files.isDirectory(current.resolve("src/main/java/org/apache/geaflow/ai"))) { + return current; + } + throw new IllegalArgumentException("Set temporal.retrieval.moduleRoot to the geaflow-ai directory"); + } + + private static Path optionalPath(String property) { + final String value = System.getProperty("temporal.retrieval." + property); + return value == null || value.trim().isEmpty() ? null : Paths.get(value); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesCliTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesCliTest.java new file mode 100644 index 000000000..620a1a88e --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesCliTest.java @@ -0,0 +1,94 @@ +/* + * 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.benchmark; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TemporalRetrievalCandidatesCliTest { + + @TempDir + Path directory; + + @Test + public void testRejectsMissingUnknownDuplicateAndEmptyOptions() throws Exception { + rejected(2); + rejected(2, "--output", "unused"); + rejected(2, "--module-root", "unused", "--output"); + rejected(2, "--module-root", "unused", "--output", ""); + rejected(2, "--module-root", "unused", "--output", "unused", "--output", "again"); + rejected(2, "--module-root", "unused", "--output", "unused", "--device", "cuda"); + rejected(2, "--module-root", "unused", "--output", "unused", "--expected-sha256", "custom"); + } + + @Test + public void testMissingPinnedLocalDatasetIsExecutionError() throws Exception { + final Path output = directory.resolve("missing-input-result"); + rejected(3, "--module-root", directory.toString(), "--output", output.toString()); + Assertions.assertFalse(Files.exists(output.resolve("manifest.json"))); + } + + @Test + public void testCustomDatasetCannotBypassOfficialHashCheck() throws Exception { + final Path input = directory.resolve("custom.jsonl"); + final String raw = new Gson().toJson(ClimateFeverDatasetTest.fixture().get(0)) + "\n"; + Files.write(input, raw.getBytes(StandardCharsets.UTF_8)); + final Path output = directory.resolve("custom-result"); + rejected(2, "--module-root", directory.toString(), "--input", input.toString(), + "--output", output.toString()); + Assertions.assertFalse(Files.exists(output.resolve("manifest.json"))); + Assertions.assertEquals(raw, new String(Files.readAllBytes(input), StandardCharsets.UTF_8)); + } + + @Test + public void testSourceOutputOverlapIsRejectedBeforeReadingData() throws Exception { + final Path output = directory.resolve("bin"); + Files.createDirectory(output); + final Path sentinel = output.resolve("keep.txt"); + Files.write(sentinel, "keep".getBytes(StandardCharsets.UTF_8)); + rejected(2, "--module-root", directory.toString(), "--output", output.toString()); + Assertions.assertEquals("keep", new String(Files.readAllBytes(sentinel), StandardCharsets.UTF_8)); + Assertions.assertFalse(Files.exists(output.resolve("manifest.json"))); + } + + private static void rejected(int exitCode, String... arguments) throws Exception { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final ByteArrayOutputStream errors = new ByteArrayOutputStream(); + try (PrintStream out = new PrintStream(output, true, StandardCharsets.UTF_8.name()); + PrintStream err = new PrintStream(errors, true, StandardCharsets.UTF_8.name())) { + Assertions.assertEquals(exitCode, new TemporalRetrievalCandidatesCli().run(arguments, out, err)); + } + Assertions.assertEquals(0, output.size()); + final JsonObject error = new TemporalJsonCodec().parseObject( + new String(errors.toByteArray(), StandardCharsets.UTF_8)); + Assertions.assertEquals(exitCode == 2 ? "REJECT_INPUT" : "REJECT_EXECUTION", + error.get("code").getAsString()); + Assertions.assertFalse(error.get("message").getAsString().trim().isEmpty()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesIT.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesIT.java new file mode 100644 index 000000000..8aa67bd46 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalCandidatesIT.java @@ -0,0 +1,111 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Explicit CPU benchmark: run in an IDE or with -Dtest=TemporalRetrievalCandidatesIT. */ +public class TemporalRetrievalCandidatesIT { + + @Test + public void compareFourCandidateProfilesOnFixedEvidence() throws Exception { + final Path module = moduleRoot(); + final Path configuredOutput = optionalPath("output"); + final Path output = configuredOutput == null + ? module.getParent().resolve("tmp/retrieval-candidates-" + + Instant.now().toString().replace(':', '-')) : configuredOutput; + final List arguments = new ArrayList<>(Arrays.asList( + "--module-root", module.toString(), "--output", output.toString())); + final Path input = optionalPath("input"); + if (input != null) { + arguments.add("--input"); + arguments.add(input.toString()); + } + final ByteArrayOutputStream standardOutput = new ByteArrayOutputStream(); + final ByteArrayOutputStream errors = new ByteArrayOutputStream(); + try (PrintStream out = new PrintStream(standardOutput, true, StandardCharsets.UTF_8.name()); + PrintStream err = new PrintStream(errors, true, StandardCharsets.UTF_8.name())) { + Assertions.assertEquals(0, new TemporalRetrievalCandidatesCli() + .run(arguments.toArray(new String[0]), out, err), + new String(errors.toByteArray(), StandardCharsets.UTF_8)); + } + final JsonObject result = new TemporalJsonCodec().parseObject( + new String(standardOutput.toByteArray(), StandardCharsets.UTF_8)); + Assertions.assertEquals("retrieval-candidates-v1", result.get("schema").getAsString()); + Assertions.assertEquals("COMPLETED", result.get("status").getAsString()); + Assertions.assertEquals("cpu", result.get("device").getAsString()); + Assertions.assertFalse(result.get("modelInference").getAsBoolean()); + Assertions.assertEquals(1000, result.get("queryCount").getAsInt()); + final JsonArray comparison = result.getAsJsonArray("comparison"); + Assertions.assertEquals(24, comparison.size()); + final Set actual = new HashSet<>(); + final Set expected = new HashSet<>(); + for (String profile : Arrays.asList("RULE_ONLY", "RULE_BM25", "RULE_MINHASH", "RULE_ALL")) { + for (int k : new int[] {1, 5, 10, 20, 50, 100}) { + expected.add(profile + ":" + k); + } + } + for (JsonElement element : comparison) { + final JsonObject row = element.getAsJsonObject(); + Assertions.assertTrue(actual.add(row.get("profile").getAsString() + ":" + row.get("k").getAsInt())); + Assertions.assertEquals(1000, row.get("evaluatedQueryCount").getAsInt()); + } + Assertions.assertEquals(expected, actual); + Assertions.assertTrue(Files.isRegularFile(output.resolve("manifest.json"))); + System.out.println(new String(Files.readAllBytes(output.resolve("comparison.csv")), StandardCharsets.UTF_8)); + } + + private static Path moduleRoot() { + final Path configured = optionalPath("moduleRoot"); + if (configured != null) { + return configured.toAbsolutePath().normalize(); + } + final Path current = Paths.get("").toAbsolutePath().normalize(); + if (Files.isDirectory(current.resolve("geaflow-ai/src"))) { + return current.resolve("geaflow-ai"); + } + if (Files.isDirectory(current.resolve("src/main/java/org/apache/geaflow/ai"))) { + return current; + } + throw new IllegalArgumentException("Set temporal.retrieval.candidates.moduleRoot to the geaflow-ai directory"); + } + + private static Path optionalPath(String property) { + final String value = System.getProperty("temporal.retrieval.candidates." + property); + return value == null || value.trim().isEmpty() ? null : Paths.get(value); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalDualViewIT.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalDualViewIT.java new file mode 100644 index 000000000..9f07cb24c --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/benchmark/TemporalRetrievalDualViewIT.java @@ -0,0 +1,87 @@ +/* + * 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.benchmark; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Explicit experiment: run this class in an IDE or with -Dtest=TemporalRetrievalDualViewIT. */ +public class TemporalRetrievalDualViewIT { + + @Test + public void compareFixedTitleBaselineWithWeightedRrf() throws Exception { + final Path module = moduleRoot(); + final Path configuredOutput = optionalPath("output"); + final Path output = configuredOutput == null + ? module.getParent().resolve("tmp/retrieval-dual-view-" + + Instant.now().toString().replace(':', '-')) : configuredOutput; + final String offline = System.getProperty("temporal.retrieval.dualView.offline", "true"); + Assertions.assertTrue("true".equals(offline) || "false".equals(offline), + "temporal.retrieval.dualView.offline must be true or false"); + final JsonObject result = new RetrievalDualViewRunner().run(module, optionalPath("input"), output, + System.getProperty("temporal.retrieval.dualView.device", "cuda"), optionalPath("python"), + optionalPath("reuseTitleInference"), optionalPath("reuseBodyInference"), + optionalPath("reuseBodyInput"), Boolean.parseBoolean(offline)); + Assertions.assertEquals("COMPLETED", result.get("status").getAsString()); + Assertions.assertEquals("CE_TITLE_BODY_RRF_75_25", result.get("profile").getAsString()); + Assertions.assertEquals(0.75, result.get("titleWeight").getAsDouble()); + Assertions.assertEquals(0.25, result.get("bodyWeight").getAsDouble()); + Assertions.assertEquals(60, result.get("rrfConstant").getAsInt()); + Assertions.assertEquals(1000, result.get("queryCount").getAsInt()); + Assertions.assertEquals(200, result.get("depth").getAsInt()); + Assertions.assertEquals(20, result.get("finalK").getAsInt()); + Assertions.assertTrue(result.get("pairCount").getAsLong() > 0); + final JsonArray comparison = result.getAsJsonArray("comparison"); + Assertions.assertEquals(2, comparison.size()); + Assertions.assertEquals("CE_TITLE_BODY", comparison.get(0).getAsJsonObject().get("profile").getAsString()); + Assertions.assertEquals("CE_TITLE_BODY_RRF_75_25", + comparison.get(1).getAsJsonObject().get("profile").getAsString()); + final Path comparisonFile = output.resolve("comparison.csv"); + Assertions.assertTrue(Files.isRegularFile(comparisonFile)); + System.out.println(new String(Files.readAllBytes(comparisonFile), StandardCharsets.UTF_8)); + } + + private static Path moduleRoot() { + final Path configured = optionalPath("moduleRoot"); + if (configured != null) { + return configured.toAbsolutePath().normalize(); + } + final Path current = Paths.get("").toAbsolutePath().normalize(); + if (Files.isDirectory(current.resolve("geaflow-ai/src"))) { + return current.resolve("geaflow-ai"); + } + if (Files.isDirectory(current.resolve("src/main/java/org/apache/geaflow/ai"))) { + return current; + } + throw new IllegalArgumentException("Set temporal.retrieval.dualView.moduleRoot to the geaflow-ai directory"); + } + + private static Path optionalPath(String property) { + final String value = System.getProperty("temporal.retrieval.dualView." + property); + return value == null || value.trim().isEmpty() ? null : Paths.get(value); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/Bm25IndexTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/Bm25IndexTest.java new file mode 100644 index 000000000..563d396b3 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/Bm25IndexTest.java @@ -0,0 +1,177 @@ +/* + * 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.candidate; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.Source; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class Bm25IndexTest { + + @Test + public void testUnicodeWhitespaceAndLocaleIndependentTextOnlyNormalization() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(new Locale("tr", "TR")); + Assertions.assertEquals("istanbul café climate", TextNormalizer.normalize( + " \tISTANBUL\u00a0CAFE\u0301\u2003Climate \r\n")); + Assertions.assertEquals("", TextNormalizer.normalize("\u00a0\u2003\t")); + Evidence evidence = new Evidence("MixedCase-ID", new Source("SOURCE", "Report"), + "UPPER Text"); + TextNormalizer.normalize(evidence.getContent()); + Assertions.assertEquals("MixedCase-ID", evidence.getId()); + Assertions.assertEquals("UPPER Text", evidence.getContent()); + } finally { + Locale.setDefault(previous); + } + } + + @Test + public void testStableTieBreakAtTopKBoundaryAndLiteralQuery() throws Exception { + Map> documents = new LinkedHashMap<>(); + for (String id : Arrays.asList("z", "c", "b", "a")) { + documents.put(id, Collections.singletonMap("evidence", "climate warming evidence")); + } + try (Bm25Index index = new Bm25Index(documents)) { + List hits = index.search("CLIMATE", 2); + Assertions.assertEquals(Arrays.asList("a", "b"), ids(hits)); + Assertions.assertTrue(hits.get(0).getScore() > 0); + Assertions.assertEquals(hits.get(0).getScore(), hits.get(1).getScore()); + Assertions.assertEquals(1, hits.get(0).getRank()); + Assertions.assertEquals(2, hits.get(1).getRank()); + Assertions.assertEquals(ids(hits), ids(index.search("climate", 2))); + Assertions.assertTrue(index.search("*:*", 20).isEmpty()); + Assertions.assertTrue(index.search("unmatchedterm", 20).isEmpty()); + Assertions.assertTrue(index.search(" \u00a0 ", 20).isEmpty()); + Assertions.assertThrows(IllegalArgumentException.class, () -> index.search("x", 0)); + } + } + + @Test + public void testFieldMatchesCorpusReplacementAndLifecycle() throws Exception { + Map> documents = new LinkedHashMap<>(); + Map fields = new LinkedHashMap<>(); + fields.put("subject", "Alice"); + fields.put("predicate", "residence"); + fields.put("object", "Paris"); + fields.put("evidence", "official report"); + documents.put("first", fields); + Bm25Index index = new Bm25Index(documents); + try { + for (String query : Arrays.asList("alice", "residence", "paris", "official")) { + Assertions.assertEquals(Collections.singletonList("first"), + ids(index.search(query, 20))); + } + } finally { + index.close(); + } + index.close(); + Assertions.assertThrows(IllegalStateException.class, () -> index.search("alice", 20)); + try (Bm25Index replacement = new Bm25Index(Collections.singletonMap("second", + Collections.singletonMap("evidence", "Berlin")))) { + Assertions.assertTrue(replacement.search("paris", 20).isEmpty()); + Assertions.assertEquals(Collections.singletonList("second"), + ids(replacement.search("berlin", 20))); + } + try (Bm25Index empty = new Bm25Index(Collections.emptyMap())) { + Assertions.assertTrue(empty.search("anything", 20).isEmpty()); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> new Bm25Index( + Collections.singletonMap("id", Collections.singletonMap("unexpected", "x")))); + } + + @Test + public void testEvidenceIdentityValidationAndScores() throws Exception { + Evidence first = new Evidence("e-a", new Source("s", "Source"), "warming climate"); + Evidence second = new Evidence("e-b", first.getSource(), "warming climate"); + try (EvidenceRetriever retriever = new EvidenceRetriever(Arrays.asList(second, first, first))) { + List hits = retriever.search("warming", 20); + Assertions.assertEquals(Arrays.asList("e-a", "e-b"), hits.stream() + .map(hit -> hit.getEvidence().getId()).collect(Collectors.toList())); + Assertions.assertTrue(hits.get(0).getVersionIds().isEmpty()); + Assertions.assertTrue(hits.get(0).getScore() > 0); + } + Evidence reused = new Evidence("e-a", first.getSource(), "different text"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new EvidenceRetriever(Arrays.asList(first, reused))); + } + + @Test + public void testCandidateStrategiesPrioritizeExactBodyAndLimitTotalEvidence() throws Exception { + final String text = "the earth climate temperature is rising across the planet each year"; + List corpus = new ArrayList<>(); + for (int i = 24; i >= 0; i--) { + corpus.add(new Evidence(String.format("exact-%02d", i), new Source("s", "unrelated"), + " \tTHE earth climate temperature is rising across the planet each year\n")); + } + corpus.add(new Evidence("a-title-only", new Source("t", text), "unrelated statement")); + corpus.add(new Evidence("a-soft", new Source("u", "unrelated"), text + " in observations")); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + try (EvidenceRetriever retriever = new EvidenceRetriever(corpus, profile)) { + List hits = retriever.search(text, 20); + Assertions.assertEquals(20, hits.size()); + Assertions.assertEquals("exact-00", hits.get(0).getEvidence().getId()); + for (EvidenceCandidate hit : hits) { + Assertions.assertTrue(hit.getEvidence().getId().startsWith("exact-")); + Assertions.assertEquals(1.0, hit.getChannelScores().get("RULE")); + Assertions.assertTrue(hit.getChannelRanks().containsKey("RULE")); + Assertions.assertTrue(hit.getChannelRanks().containsKey("RRF")); + } + Assertions.assertTrue(retriever.search(" \u00a0 ", 20).isEmpty()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> retriever.search(text, 0)); + } + } + EvidenceRetriever closed = new EvidenceRetriever(corpus, RetrievalProfile.RULE_ONLY); + closed.close(); + closed.close(); + Assertions.assertThrows(IllegalStateException.class, () -> closed.search(text, 20)); + } + + @Test + public void testMinHashChannelIsAvailableWithoutBm25AndDoesNotAcceptOnlySharedWords() + throws Exception { + final String text = "the earth climate temperature is rising across the planet each year"; + final Evidence exact = new Evidence("exact", new Source("s", "report"), text); + final Evidence reordered = new Evidence("different", exact.getSource(), + "year each planet the across rising is temperature climate earth the"); + try (EvidenceRetriever retriever = new EvidenceRetriever(Arrays.asList(exact, reordered), + RetrievalProfile.RULE_MINHASH)) { + List result = retriever.search(text, 20); + Assertions.assertEquals(1, result.size()); + Assertions.assertTrue(result.get(0).getChannelScores().containsKey("MINHASH")); + Assertions.assertFalse(result.get(0).getChannelScores().containsKey("BM25")); + } + } + + private static List ids(List hits) { + return hits.stream().map(Bm25Index.Hit::getId).collect(Collectors.toList()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/CandidateMergerTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/CandidateMergerTest.java new file mode 100644 index 000000000..37096cef3 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/CandidateMergerTest.java @@ -0,0 +1,118 @@ +/* + * 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.candidate; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CandidateMergerTest { + + @Test + public void testMandatoryPriorityDeduplicationAndOriginalChannelTrace() { + Map> channels = new LinkedHashMap<>(); + channels.put("MINHASH", Arrays.asList(hit("b", 0.8, 1), hit("c", 0.6, 2))); + channels.put("BM25", Arrays.asList(hit("a", 9.0, 1), hit("b", 7.0, 2))); + List result = CandidateMerger.merge(channels, + Collections.singleton("z"), 1); + Assertions.assertEquals(Arrays.asList("z", "b"), ids(result)); + Assertions.assertEquals(1, result.get(0).getRank()); + Assertions.assertEquals(3, result.get(0).getChannelRanks().get("RRF")); + Bm25Index.Hit fused = result.get(1); + Assertions.assertEquals(7.0, fused.getChannelScores().get("BM25")); + Assertions.assertEquals(0.8, fused.getChannelScores().get("MINHASH")); + Assertions.assertEquals(2, fused.getChannelRanks().get("BM25")); + Assertions.assertEquals(1, fused.getChannelRanks().get("MINHASH")); + Assertions.assertEquals(1.0 / 62 + 1.0 / 61, fused.getScore()); + Assertions.assertEquals(1, fused.getChannelRanks().get("RRF")); + Assertions.assertEquals(2, fused.getRank()); + Assertions.assertThrows(UnsupportedOperationException.class, result::clear); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> fused.getChannelScores().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> fused.getChannelRanks().clear()); + } + + @Test + public void testMandatoryCandidatesAreCompleteAndInputOrderDoesNotAffectRanking() { + List mandatory = new ArrayList<>(); + for (int i = 30; i > 0; i--) { + mandatory.add(String.format("rule-%02d", i)); + } + Map> channels = new LinkedHashMap<>(); + channels.put("BM25", Arrays.asList(hit("soft-z", 1, 2), hit("soft-a", 2, 1))); + channels.put("MINHASH", Collections.singletonList(hit("soft-z", 1, 1))); + List first = CandidateMerger.merge(channels, mandatory, 1); + Assertions.assertEquals(31, first.size()); + Assertions.assertEquals("rule-01", first.get(0).getId()); + Assertions.assertEquals("rule-30", first.get(29).getId()); + Assertions.assertEquals("soft-z", first.get(30).getId()); + Collections.reverse(mandatory); + Map> reversed = new LinkedHashMap<>(); + reversed.put("MINHASH", channels.get("MINHASH")); + reversed.put("BM25", Arrays.asList(hit("soft-a", 2, 1), hit("soft-z", 1, 2))); + List second = CandidateMerger.merge(reversed, mandatory, 1); + Assertions.assertEquals(ids(first), ids(second)); + for (int i = 0; i < first.size(); i++) { + Assertions.assertEquals(first.get(i).getScore(), second.get(i).getScore()); + Assertions.assertEquals(first.get(i).getChannelRanks(), second.get(i).getChannelRanks()); + } + } + + @Test + public void testRejectsInvalidRankScoreAndDuplicateChannelEntries() { + for (Bm25Index.Hit invalid : Arrays.asList(hit("a", Double.NaN, 1), + hit("a", Double.POSITIVE_INFINITY, 1), hit("a", -1, 1), hit("a", 1, 0), + hit(" ", 1, 1))) { + Assertions.assertThrows(IllegalArgumentException.class, () -> CandidateMerger.merge( + Collections.singletonMap("BM25", Collections.singletonList(invalid)), + Collections.emptyList(), 1)); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> CandidateMerger.merge( + Collections.singletonMap("BM25", Arrays.asList(hit("a", 2, 1), hit("a", 1, 2))), + Collections.emptyList(), 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> CandidateMerger.merge( + Collections.singletonMap("BM25", Arrays.asList(hit("a", 2, 1), hit("b", 1, 1))), + Collections.emptyList(), 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> CandidateMerger.merge( + Collections.singletonMap("RRF", Collections.emptyList()), Collections.emptyList(), 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> CandidateMerger.merge( + Collections.emptyMap(), Collections.singleton(" "), 1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> CandidateMerger.merge( + Collections.emptyMap(), Collections.emptyList(), 0)); + Assertions.assertTrue(CandidateMerger.merge(Collections.singletonMap("BM25", + Collections.singletonList(hit("a", 1, Integer.MAX_VALUE))), + Collections.emptyList(), 1).get(0).getScore() > 0); + } + + private static Bm25Index.Hit hit(String id, double score, int rank) { + return new Bm25Index.Hit(id, score, rank, Collections.emptyMap(), Collections.emptyMap()); + } + + private static List ids(List hits) { + return hits.stream().map(Bm25Index.Hit::getId).collect(Collectors.toList()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/EventCandidateServiceTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/EventCandidateServiceTest.java new file mode 100644 index 000000000..8d998cf71 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/EventCandidateServiceTest.java @@ -0,0 +1,147 @@ +/* + * 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.candidate; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class EventCandidateServiceTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private static final String CONTENT = "climate temperature increases across the planet each year"; + + @Test + public void testEffectiveIntervalKeepsAllMandatoryAndTopKOnlyLimitsSoftCandidates() + throws Exception { + final List mandatory = new ArrayList<>(); + for (int i = 0; i < 30; i++) { + mandatory.add(version("v-" + i, KEY, interval(0, 40), interval(0, null), + MemoryFactVersionStatus.ACTIVE)); + } + final Map> groups = new TreeMap<>(); + groups.put(KEY, mandatory); + FactKey other = new FactKey("bob", "city", "profile"); + groups.put(other, Arrays.asList( + version("soft-a", other, interval(0, 40), interval(0, null), + MemoryFactVersionStatus.ACTIVE), + version("soft-b", other, interval(0, 40), interval(0, null), + MemoryFactVersionStatus.ACTIVE))); + TemporalState state = new TemporalState(groups, Collections.emptyList()); + NormalizedMemoryEvent event = event(interval(50, 60)); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + List candidates = new TemporalCandidateService(profile) + .eventFacts(state, event, interval(0, 60), 1); + Assertions.assertEquals(30, candidates.stream().filter(FactCandidate::isMandatory) + .count()); + Assertions.assertTrue(candidates.size() >= 30 && candidates.size() <= 31); + for (int i = 0; i < 30; i++) { + Assertions.assertTrue(candidates.get(i).isMandatory()); + Assertions.assertEquals(KEY, candidates.get(i).getFactKey()); + Assertions.assertEquals(1.0, candidates.get(i).getScores().get("RULE")); + } + if (profile.usesBm25()) { + Assertions.assertEquals(31, candidates.size()); + Assertions.assertFalse(candidates.get(30).isMandatory()); + } + } + Assertions.assertEquals(32, state.getVersions().size()); + Assertions.assertTrue(state.getRelations().isEmpty()); + } + + @Test + public void testHiddenFutureClosedBoundaryAndRetractedVersionsCannotChangeScores() + throws Exception { + final Map> baseline = new TreeMap<>(); + baseline.put(KEY, Collections.singletonList(version("visible", KEY, interval(0, 20), + interval(0, null), MemoryFactVersionStatus.ACTIVE))); + final Map> all = new TreeMap<>(baseline); + FactKey other = new FactKey("bob", "city", "profile"); + all.put(other, Arrays.asList( + version("future", other, interval(0, 20), interval(11, null), + MemoryFactVersionStatus.ACTIVE), + version("closed", other, interval(0, 20), interval(0, 10), + MemoryFactVersionStatus.ACTIVE), + version("boundary", other, interval(20, 30), interval(0, null), + MemoryFactVersionStatus.ACTIVE), + version("retracted", other, interval(0, 20), interval(0, null), + MemoryFactVersionStatus.RETRACTED))); + FactKey secret = new FactKey("alice", "city", "secret"); + all.put(secret, Collections.singletonList(version("secret", secret, interval(0, 20), + interval(0, null), MemoryFactVersionStatus.ACTIVE))); + final NormalizedMemoryEvent event = event(interval(5, 20)); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + TemporalCandidateService service = new TemporalCandidateService(profile); + List expected = service.eventFacts(new TemporalState(baseline, + Collections.emptyList()), event, event.getValidTime(), 20); + List actual = service.eventFacts(new TemporalState(all, + Collections.emptyList()), event, event.getValidTime(), 20); + Assertions.assertEquals(Collections.singletonList("visible"), actual.stream() + .map(candidate -> candidate.getVersion().getId()).collect(Collectors.toList())); + Assertions.assertEquals(expected.get(0).getScores(), actual.get(0).getScores()); + Assertions.assertEquals(expected.get(0).getChannelRanks(), actual.get(0).getChannelRanks()); + } + } + + private static NormalizedMemoryEvent event(TimeInterval valid) { + MemoryFact fact = MemoryFact.attribute("incoming-fact", new MemoryEntity("alice", "person"), + "city", "Shanghai"); + return new EventNormalizer().normalize(MemoryEvent.add("incoming", fact, valid, + BASE.plusSeconds(10), Collections.singletonList(evidence("incoming-e"))), KEY); + } + + private static MemoryFactVersion version(String id, FactKey key, TimeInterval valid, + TimeInterval system, MemoryFactVersionStatus status) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity(key.getSubjectId(), "person"), key.getPredicate(), "Shanghai"); + return new MemoryFactVersion(id, fact, status, valid, system, + Collections.singletonList(evidence("e-" + id))); + } + + private static Evidence evidence(String id) { + return new Evidence(id, new Source("source", "Registry"), CONTENT); + } + + private static TimeInterval interval(long from, Integer to) { + return new TimeInterval(BASE.plusSeconds(from), to == null ? null : BASE.plusSeconds(to)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/LexicalRetrievalTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/LexicalRetrievalTest.java new file mode 100644 index 000000000..465e36aaf --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/LexicalRetrievalTest.java @@ -0,0 +1,147 @@ +/* + * 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.candidate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LexicalRetrievalTest { + + @Test + public void testFixedAliasesHaveBoundariesAndAreIdempotent() { + final String original = "CO₂ CO2 CO 2 carbon dioxide CH4 methane N2O nitrous oxide"; + String canonical = LexicalTerms.canonicalize(original); + Assertions.assertEquals("carbon dioxide carbon dioxide carbon dioxide carbon dioxide " + + "methane methane nitrous oxide nitrous oxide", canonical); + Assertions.assertEquals(canonical, LexicalTerms.canonicalize(canonical)); + Assertions.assertEquals("CO₂ CO2 CO 2 carbon dioxide CH4 methane N2O nitrous oxide", + original); + Assertions.assertEquals("fooco2 co20 xch4 n2o2 carbon dioxides _co2 co2_", + LexicalTerms.canonicalize("fooCO2 CO20 xCH4 N2O2 carbon dioxides _CO2 CO2_")); + Assertions.assertEquals("residency time lifetime", + LexicalTerms.canonicalize("residency time lifetime")); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> LexicalTerms.getAliases().put("custom", "other")); + } + + @Test + public void testRawChannelRetainsEnglishScoresAndAliasChannelAddsCandidates() throws Exception { + final Map> documents = new LinkedHashMap<>(); + documents.put("formula", fields("CO2 warms atmosphere")); + documents.put("name", fields("Carbon dioxide warms atmosphere")); + documents.put("methane", fields("Methane traps heat")); + try (Bm25Index english = new Bm25Index(documents, RetrievalProfile.ENGLISH); + Bm25Index fusion = new Bm25Index(documents, RetrievalProfile.LEXICAL_FUSION)) { + final List raw = english.search("CO2", 100); + final Map fused = byId(fusion.search("CO2", 20)); + Assertions.assertEquals(Collections.singletonList("formula"), ids(raw)); + Assertions.assertEquals(new HashSet<>(Arrays.asList("formula", "name")), + fused.keySet()); + Assertions.assertEquals(raw.get(0).getScore(), + fused.get("formula").getChannelScores().get("BM25").doubleValue()); + Assertions.assertEquals(Integer.valueOf(raw.get(0).getRank()), + fused.get("formula").getChannelRanks().get("BM25")); + Assertions.assertFalse(fused.get("name").getChannelScores().containsKey("BM25")); + Assertions.assertTrue(fused.get("name").getChannelScores() + .containsKey("ALIAS_BM25")); + Assertions.assertEquals(Collections.singletonList("methane"), + ids(fusion.search("CH4", 20))); + Assertions.assertEquals("CO2 warms atmosphere", documents.get("formula").get("evidence")); + } + } + + @Test + public void testFusionDeduplicatesAndKeepsStableIdTiesAndImmutableChannels() throws Exception { + final Map> documents = new LinkedHashMap<>(); + for (String id : Arrays.asList("z", "b", "a")) { + documents.put(id, fields("carbon dioxide atmospheric evidence")); + } + try (Bm25Index fusion = new Bm25Index(documents, RetrievalProfile.LEXICAL_FUSION)) { + List hits = fusion.search("carbon dioxide", 2); + Assertions.assertEquals(Arrays.asList("a", "b"), ids(hits)); + Assertions.assertEquals(ids(hits), ids(fusion.search("carbon dioxide", 2))); + Assertions.assertEquals(2.0 / (RetrievalProfile.RRF_K + 1), hits.get(0).getScore()); + Assertions.assertEquals(2, hits.get(1).getRank()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> hits.get(0).getChannelScores().put("other", 0.0)); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> hits.get(0).getChannelRanks().put("other", 0)); + Assertions.assertTrue(fusion.search("unmatchedterm", 20).isEmpty()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> fusion.search("carbon", 0)); + } + } + + @Test + public void testLexicalProfilesDoNotIndexTitlesAndCloseBothChannels() throws Exception { + final RetrievalProfile profile = RetrievalProfile.LEXICAL_FUSION; + Assertions.assertEquals(100, profile.getCandidatePoolSize(20)); + Assertions.assertEquals(150, profile.getCandidatePoolSize(150)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new Bm25Index( + Collections.singletonMap("title", Collections.singletonMap("title", "CO2")), + profile)); + Bm25Index index = new Bm25Index(Collections.singletonMap("body", + fields("carbon dioxide")), profile); + Assertions.assertEquals(Collections.singletonList("body"), + ids(index.search("CO2", 20))); + index.close(); + index.close(); + Assertions.assertThrows(IllegalStateException.class, () -> index.search("CO2", 20)); + } + + @Test + public void testRequestedDepthBeyondPoolMinimumAndEmptyAnalyzedQueries() throws Exception { + Map> documents = new LinkedHashMap<>(); + for (int index = 0; index < 155; index++) { + documents.put(String.format("document-%03d", index), fields("carbon dioxide atmosphere")); + } + final RetrievalProfile profile = RetrievalProfile.LEXICAL_FUSION; + try (Bm25Index index = new Bm25Index(documents, profile)) { + List hits = index.search("CO2", 150); + Assertions.assertEquals(150, hits.size()); + Assertions.assertEquals("document-000", hits.get(0).getId()); + Assertions.assertEquals("document-149", hits.get(149).getId()); + Assertions.assertEquals(150, hits.get(149).getRank()); + Assertions.assertEquals(20, index.search("CO2", 20).size()); + Assertions.assertTrue(index.search("the and of", 20).isEmpty()); + Assertions.assertTrue(index.search("", 20).isEmpty()); + Assertions.assertTrue(index.search("!!!", 20).isEmpty()); + } + } + + private static Map fields(String text) { + return Collections.singletonMap("evidence", text); + } + + private static List ids(List hits) { + return hits.stream().map(Bm25Index.Hit::getId).collect(Collectors.toList()); + } + + private static Map byId(List hits) { + return hits.stream().collect(Collectors.toMap(Bm25Index.Hit::getId, hit -> hit)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/MinHashIndexTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/MinHashIndexTest.java new file mode 100644 index 000000000..ef8b71101 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/MinHashIndexTest.java @@ -0,0 +1,176 @@ +/* + * 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.candidate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class MinHashIndexTest { + + @Test + public void testStableRankingAcrossInputOrderAndCallerMutation() { + final Map> documents = new LinkedHashMap<>(); + for (String id : Arrays.asList("z", "b", "a")) { + documents.put(id, Collections.singletonMap("evidence", "alpha beta gamma delta")); + } + final MinHashIndex first = new MinHashIndex(documents); + final Map> reordered = new LinkedHashMap<>(); + for (String id : Arrays.asList("a", "b", "z")) { + reordered.put(id, documents.get(id)); + } + final MinHashIndex second = new MinHashIndex(reordered); + documents.clear(); + final List hits = first.search("alpha beta gamma delta", 2); + Assertions.assertEquals(Arrays.asList("a", "b"), ids(hits)); + Assertions.assertEquals(ids(hits), ids(second.search("alpha beta gamma delta", 2))); + Assertions.assertEquals(1.0, hits.get(0).getScore()); + Assertions.assertEquals(1, hits.get(0).getRank()); + Assertions.assertEquals(2, hits.get(1).getRank()); + Assertions.assertEquals(Collections.singletonMap("MINHASH", 1.0), + hits.get(0).getChannelScores()); + Assertions.assertEquals(Collections.singletonMap("MINHASH", 2), + hits.get(1).getChannelRanks()); + Assertions.assertThrows(UnsupportedOperationException.class, hits::clear); + } + + @Test + public void testUnicodePunctuationAndRepeatedShingleSets() { + final Map> documents = new LinkedHashMap<>(); + documents.put("MixedCase-ID", Collections.singletonMap("evidence", "Café—CLIMATE…2024")); + documents.put("repeated", Collections.singletonMap("evidence", "echo echo echo echo")); + final MinHashIndex index = new MinHashIndex(documents); + Assertions.assertEquals(Collections.singletonList("MixedCase-ID"), + ids(index.search("CAFE\u0301\u00a0climate\u20032024", 20))); + Assertions.assertEquals("Café—CLIMATE…2024", documents.get("MixedCase-ID").get("evidence")); + Assertions.assertEquals(1.0, index.search("echo echo echo", 20).get(0).getScore()); + Assertions.assertEquals(3, + MinHashIndex.shingles("alpha beta gamma alpha beta gamma").size()); + Assertions.assertEquals(MinHashIndex.shingles("alpha beta gamma"), + MinHashIndex.shingles("ALPHA / BETA, GAMMA")); + } + + @Test + public void testFieldsNeverCreateCrossFieldShinglesAndShortTextsNeverMatch() { + final Map split = new LinkedHashMap<>(); + split.put("subject", "alpha beta"); + split.put("predicate", "gamma delta"); + split.put("evidence", ""); + final Map duplicated = new LinkedHashMap<>(); + duplicated.put("object", "alpha beta gamma"); + duplicated.put("evidence", "alpha beta gamma"); + final Map> documents = new LinkedHashMap<>(); + documents.put("split", split); + documents.put("duplicated", duplicated); + final MinHashIndex index = new MinHashIndex(documents); + Assertions.assertEquals(Collections.singletonList("duplicated"), + ids(index.search("alpha beta gamma", 20))); + Assertions.assertEquals(1.0, index.search("alpha beta gamma", 20).get(0).getScore()); + Assertions.assertTrue(index.search("alpha beta", 20).isEmpty()); + Assertions.assertTrue(index.search(" \u2003...", 20).isEmpty()); + Assertions.assertTrue(new MinHashIndex(Collections.emptyMap()) + .search("alpha beta gamma", 20).isEmpty()); + Assertions.assertEquals(0.0, MinHashIndex.jaccard(Collections.emptySet(), + Collections.emptySet())); + Assertions.assertEquals(0.0, MinHashIndex.jaccard(Collections.singleton("a"), + Collections.emptySet())); + } + + @Test + public void testExactJaccardBoundaryAndLshFalsePositiveVerification() { + final String query = "alpha beta gamma delta"; + final String boundary = collidingExtension(query, false); + final String below = collidingExtension(query, true); + Assertions.assertEquals(0.5, MinHashIndex.jaccard(MinHashIndex.shingles(query), + MinHashIndex.shingles(boundary))); + Assertions.assertEquals(0.4, MinHashIndex.jaccard(MinHashIndex.shingles(query), + MinHashIndex.shingles(below))); + final Map> documents = new LinkedHashMap<>(); + documents.put("boundary", Collections.singletonMap("evidence", boundary)); + documents.put("below", Collections.singletonMap("evidence", below)); + documents.put("exact", Collections.singletonMap("evidence", query)); + final List hits = new MinHashIndex(documents).search(query, 20); + Assertions.assertEquals(Arrays.asList("exact", "boundary"), ids(hits)); + Assertions.assertEquals(0.5, hits.get(1).getScore()); + Assertions.assertEquals(2, hits.get(1).getRank()); + final Set left = new HashSet<>(Arrays.asList("a", "b", "c")); + final Set right = new HashSet<>(Arrays.asList("b", "c", "d")); + Assertions.assertEquals(0.5, MinHashIndex.jaccard(left, right)); + Assertions.assertEquals(MinHashIndex.jaccard(left, right), + MinHashIndex.jaccard(right, left)); + } + + @Test + public void testInvalidInputs() { + Assertions.assertThrows(NullPointerException.class, () -> new MinHashIndex(null)); + Assertions.assertThrows(NullPointerException.class, () -> new MinHashIndex( + Collections.singletonMap(null, Collections.singletonMap("evidence", "a b c")))); + Assertions.assertThrows(IllegalArgumentException.class, () -> new MinHashIndex( + Collections.singletonMap(" ", Collections.singletonMap("evidence", "a b c")))); + Assertions.assertThrows(NullPointerException.class, () -> new MinHashIndex( + Collections.singletonMap("id", null))); + Assertions.assertThrows(IllegalArgumentException.class, () -> new MinHashIndex( + Collections.singletonMap("id", Collections.singletonMap("unknown", "a b c")))); + Assertions.assertThrows(NullPointerException.class, () -> new MinHashIndex( + Collections.singletonMap("id", Collections.singletonMap("evidence", null)))); + final MinHashIndex index = new MinHashIndex(Collections.emptyMap()); + Assertions.assertThrows(NullPointerException.class, () -> index.search(null, 20)); + Assertions.assertThrows(IllegalArgumentException.class, () -> index.search("a b c", 0)); + Assertions.assertThrows(IllegalArgumentException.class, () -> index.search("a b c", -1)); + } + + private static String collidingExtension(String query, boolean belowThreshold) { + final long[] querySignature = MinHashIndex.signature(MinHashIndex.shingles(query)); + for (int index = 0; index < 1000; index++) { + String candidate = query + " extra" + index + " tail" + index + + (belowThreshold ? " final" + index : ""); + long[] signature = MinHashIndex.signature(MinHashIndex.shingles(candidate)); + if (sharesBand(querySignature, signature)) { + return candidate; + } + } + throw new AssertionError("Fixed-seed fixture must include an LSH collision"); + } + + private static boolean sharesBand(long[] left, long[] right) { + for (int band = 0; band < MinHashIndex.BANDS; band++) { + boolean same = true; + for (int row = 0; row < MinHashIndex.ROWS_PER_BAND; row++) { + int offset = band * MinHashIndex.ROWS_PER_BAND + row; + same &= left[offset] == right[offset]; + } + if (same) { + return true; + } + } + return false; + } + + private static List ids(List hits) { + return hits.stream().map(Bm25Index.Hit::getId).collect(Collectors.toList()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/TemporalCandidateServiceTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/TemporalCandidateServiceTest.java new file mode 100644 index 000000000..15657b0c2 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/candidate/TemporalCandidateServiceTest.java @@ -0,0 +1,534 @@ +/* + * 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.candidate; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TemporalCandidateServiceTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("person:alice", "city", "profile"); + private final TemporalCandidateService service = new TemporalCandidateService(); + + @Test + public void testMandatoryCandidatesAreCompleteAndSoftCandidatesHaveOwnLimit() throws Exception { + List mandatory = new ArrayList<>(); + for (int i = 0; i < 30; i++) { + mandatory.add(version(String.format("v-%02d", i), KEY, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, evidence("e-" + i, "Shanghai city"))); + } + Map> groups = new TreeMap<>(); + groups.put(KEY, mandatory); + groups.put(key("bob", "profile"), Arrays.asList( + active("soft-b", key("bob", "profile"), "Shanghai city"), + active("soft-a", key("bob", "profile"), "Shanghai city"))); + CanonicalSnapshot snapshot = snapshot(groups); + + List facts = service.facts(snapshot, QueryMode.CURRENT, + KEY, at(0), null, "Shanghai", 1); + Assertions.assertEquals(31, facts.size()); + for (int i = 0; i < 30; i++) { + Assertions.assertTrue(facts.get(i).isMandatory()); + Assertions.assertEquals(KEY, facts.get(i).getFactKey()); + Assertions.assertEquals(1.0, facts.get(i).getScores().get("RULE")); + Assertions.assertTrue(facts.get(i).getScores().get("BM25") > 0); + Assertions.assertEquals(i + 1, facts.get(i).getRank()); + } + Assertions.assertEquals("soft-a", facts.get(30).getVersion().getId()); + Assertions.assertFalse(facts.get(30).isMandatory()); + Assertions.assertEquals(Collections.singleton("BM25"), + facts.get(30).getScores().keySet()); + Assertions.assertEquals(30, service.facts(snapshot, QueryMode.CURRENT, + KEY, at(0), null, "", 1).size()); + } + + @Test + public void testHalfOpenVisibilityAndTombstonesFollowQueryModes() throws Exception { + MemoryFactVersion old = version("old", KEY, interval(10, 20), interval(0, 30), + MemoryFactVersionStatus.ACTIVE, evidence("old-e", "Shanghai")); + MemoryFactVersion tombstone = version("tombstone", KEY, interval(10, 20), open(30), + MemoryFactVersionStatus.RETRACTED, evidence("retract-e", "Shanghai withdrawn")); + CanonicalSnapshot snapshot = snapshot(Collections.singletonMap(KEY, + Arrays.asList(tombstone, old))); + + Assertions.assertEquals(Collections.singletonList("old"), factIds(service.facts( + snapshot, QueryMode.AS_OF, KEY, at(10), at(29), "Shanghai", 20))); + Assertions.assertTrue(service.facts(snapshot, QueryMode.AS_OF, + KEY, at(10), at(30), "Shanghai", 20).isEmpty()); + Assertions.assertTrue(service.facts(snapshot, QueryMode.CURRENT, + KEY, at(10), null, "Shanghai", 20).isEmpty()); + Assertions.assertEquals(Arrays.asList("old", "tombstone"), factIds(service.facts( + snapshot, QueryMode.HISTORICAL, KEY, at(19), null, "Shanghai", 20))); + Assertions.assertTrue(service.facts(snapshot, QueryMode.HISTORICAL, + KEY, at(20), null, "Shanghai", 20).isEmpty()); + Assertions.assertTrue(service.facts(snapshot, QueryMode.AS_OF, + KEY, at(9), at(29), "Shanghai", 20).isEmpty()); + Assertions.assertEquals(Collections.singletonList("old-e"), evidenceIds(service.evidence( + snapshot, QueryMode.AS_OF, KEY, at(10), at(29), "Shanghai", 20))); + Assertions.assertTrue(service.evidence(snapshot, QueryMode.CURRENT, + KEY, at(10), null, "Shanghai", 20).isEmpty()); + Assertions.assertEquals(2, service.evidence(snapshot, QueryMode.HISTORICAL, + KEY, at(10), null, "Shanghai", 20).size()); + } + + @Test + public void testMandatoryRankingUsesBm25ThenVersionIdAndBlankUsesVersionId() throws Exception { + CanonicalSnapshot snapshot = snapshot(Collections.singletonMap(KEY, Arrays.asList( + active("a-low", KEY, "Beijing"), active("z-high", KEY, "Shanghai Shanghai")))); + + List ranked = service.facts(snapshot, QueryMode.CURRENT, + KEY, at(0), null, "Shanghai", 1); + Assertions.assertEquals(Arrays.asList("z-high", "a-low"), factIds(ranked)); + Assertions.assertTrue(ranked.get(0).getScores().get("BM25") + > ranked.get(1).getScores().get("BM25")); + Assertions.assertEquals(Arrays.asList("a-low", "z-high"), factIds(service.facts( + snapshot, QueryMode.CURRENT, KEY, at(0), null, "", 1))); + } + + @Test + public void testScopeIsExcludedFromBothCandidatesAndCorpusStatistics() throws Exception { + CanonicalSnapshot baseline = snapshot(Collections.singletonMap(KEY, + Collections.singletonList(active("v", KEY, "Shanghai city")))); + Map> groups = new TreeMap<>( + baseline.getState().getVersionsByFactKey()); + FactKey hidden = key("alice", "secret"); + groups.put(hidden, Collections.singletonList(active("secret-v", hidden, + "Shanghai Shanghai Shanghai city confidential"))); + CanonicalSnapshot withSecret = snapshot(groups); + + List expected = service.evidence(baseline, QueryMode.CURRENT, + KEY, at(1), null, "Shanghai", 20); + List actual = service.evidence(withSecret, QueryMode.CURRENT, + KEY, at(1), null, "Shanghai", 20); + Assertions.assertEquals(evidenceIds(expected), evidenceIds(actual)); + Assertions.assertEquals(expected.get(0).getScore(), actual.get(0).getScore()); + List facts = service.facts(withSecret, QueryMode.CURRENT, + KEY, at(1), null, "Shanghai", 20); + Assertions.assertEquals(Collections.singletonList("v"), factIds(facts)); + Assertions.assertEquals(service.facts(baseline, QueryMode.CURRENT, + KEY, at(1), null, "Shanghai", 20).get(0).getScores(), facts.get(0).getScores()); + } + + @Test + public void testFutureEvidenceAndCorpusCannotChangePastRankingOrScores() throws Exception { + Evidence original = evidence("e-original", "Shanghai city"); + MemoryFactVersion initial = version("old", KEY, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, original); + CanonicalSnapshot before = snapshot(Collections.singletonMap(KEY, + Collections.singletonList(initial))); + MemoryFactVersion old = version("old", KEY, open(0), interval(0, 10), + MemoryFactVersionStatus.ACTIVE, original); + MemoryFactVersion updated = version("new", KEY, open(0), open(10), + MemoryFactVersionStatus.ACTIVE, original, evidence("e-future", "Shanghai confirmed")); + Map> groups = new TreeMap<>(); + groups.put(KEY, Arrays.asList(updated, old)); + FactKey futureKey = key("future", "profile"); + groups.put(futureKey, Collections.singletonList(version("future", futureKey, + open(0), open(10), MemoryFactVersionStatus.ACTIVE, + evidence("future-corpus", "Shanghai Shanghai Shanghai")))); + CanonicalSnapshot after = snapshot(groups); + + List expected = service.evidence(before, QueryMode.AS_OF, + KEY, at(5), at(5), "Shanghai", 20); + List actual = service.evidence(after, QueryMode.AS_OF, + KEY, at(5), at(5), "Shanghai", 20); + Assertions.assertEquals(Collections.singletonList("e-original"), evidenceIds(actual)); + Assertions.assertEquals(expected.get(0).getScore(), actual.get(0).getScore()); + Assertions.assertEquals(Collections.singletonList("old"), actual.get(0).getVersionIds()); + List facts = service.facts(after, QueryMode.AS_OF, + KEY, at(5), at(5), "Shanghai", 20); + Assertions.assertEquals(Collections.singletonList("old"), factIds(facts)); + Assertions.assertEquals(service.facts(before, QueryMode.AS_OF, + KEY, at(5), at(5), "Shanghai", 20).get(0).getScores(), facts.get(0).getScores()); + } + + @Test + public void testEvidenceDeduplicatesVisibleReferencesAndStableTies() throws Exception { + Evidence shared = evidence("e-a", "Shanghai city"); + MemoryFactVersion first = version("v-z", KEY, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, shared, evidence("e-z", "Shanghai city")); + FactKey other = key("bob", "profile"); + MemoryFactVersion second = version("v-a", other, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, shared); + Map> groups = new TreeMap<>(); + groups.put(other, Collections.singletonList(second)); + groups.put(KEY, Collections.singletonList(first)); + CanonicalSnapshot snapshot = snapshot(groups); + + List candidates = service.evidence(snapshot, QueryMode.CURRENT, + KEY, at(1), null, "Shanghai", 20); + Assertions.assertEquals(Arrays.asList("e-a", "e-z"), evidenceIds(candidates)); + Assertions.assertEquals(Arrays.asList("v-a", "v-z"), candidates.get(0).getVersionIds()); + Assertions.assertEquals(1, candidates.get(0).getRank()); + Assertions.assertEquals(2, candidates.get(1).getRank()); + Assertions.assertEquals(candidates.get(0).getScore(), candidates.get(1).getScore()); + Assertions.assertEquals(Collections.singletonList("v-z"), + candidates.get(1).getVersionIds()); + } + + @Test + public void testEventCandidatesUseIntervalOverlapRatherThanOnlyStartPoint() { + MemoryFactVersion first = version("first", KEY, interval(0, 10), open(0), + MemoryFactVersionStatus.ACTIVE, evidence("first-e", "first")); + MemoryFactVersion later = version("later", KEY, interval(10, 20), open(0), + MemoryFactVersionStatus.ACTIVE, evidence("later-e", "later")); + MemoryFactVersion boundary = version("boundary", KEY, interval(20, 30), open(0), + MemoryFactVersionStatus.ACTIVE, evidence("boundary-e", "boundary")); + MemoryFactVersion future = version("future", KEY, open(0), open(30), + MemoryFactVersionStatus.ACTIVE, evidence("future-e", "future")); + MemoryFactVersion tombstone = version("tombstone", KEY, open(0), open(0), + MemoryFactVersionStatus.RETRACTED, evidence("tombstone-e", "withdrawn")); + CanonicalSnapshot snapshot = snapshot(Collections.singletonMap(KEY, + Arrays.asList(first, later, boundary, future, tombstone))); + MemoryEvent event = MemoryEvent.add("incoming", first.getFact(), interval(5, 20), + at(1), first.getEvidence()); + + Assertions.assertEquals(Arrays.asList("first", "later"), factIds(service.eventFacts( + snapshot, new EventNormalizer().normalize(event, KEY)))); + } + + @Test + public void testInvalidInputsAndEvidenceCollisionDoNotChangeSnapshot() throws Exception { + MemoryFactVersion first = active("first", KEY, "Shanghai city"); + CanonicalSnapshot good = snapshot(Collections.singletonMap(KEY, + Collections.singletonList(first))); + int originalHash = good.hashCode(); + Assertions.assertThrows(IllegalArgumentException.class, () -> service.facts( + good, QueryMode.CURRENT, KEY, at(0), null, "Shanghai", 0)); + Assertions.assertThrows(NullPointerException.class, () -> service.facts( + good, QueryMode.AS_OF, KEY, at(0), null, "Shanghai", 20)); + Assertions.assertThrows(IllegalArgumentException.class, () -> service.evidence( + good, QueryMode.CURRENT, KEY, at(0), at(0), "Shanghai", 20)); + Assertions.assertEquals(originalHash, good.hashCode()); + Assertions.assertEquals(Collections.singletonList(first), good.getState().getVersions()); + + MemoryFactVersion left = version("left", KEY, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, evidence("same-id", "Shanghai")); + MemoryFactVersion right = version("right", KEY, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, evidence("same-id", "Beijing")); + CanonicalSnapshot collision = snapshot(Collections.singletonMap(KEY, + Arrays.asList(left, right))); + int collisionHash = collision.hashCode(); + Assertions.assertThrows(IllegalArgumentException.class, () -> service.evidence( + collision, QueryMode.CURRENT, KEY, at(0), null, "Shanghai", 20)); + Assertions.assertEquals(collisionHash, collision.hashCode()); + Assertions.assertEquals(Arrays.asList(left, right), collision.getState().getVersions()); + } + + @Test + public void testCandidateAndResultCollectionsAreImmutable() throws Exception { + MemoryFactVersion version = active("v", KEY, "Shanghai"); + Map scores = new HashMap<>(); + scores.put("RULE", 1.0); + FactCandidate fact = new FactCandidate(KEY, version, true, scores, 1); + scores.clear(); + Assertions.assertEquals(1.0, fact.getScores().get("RULE")); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> fact.getScores().clear()); + List ids = new ArrayList<>(Arrays.asList("v-z", "v-a", "v-a")); + EvidenceCandidate evidence = new EvidenceCandidate(version.getEvidence().get(0), ids, 1, 1); + ids.clear(); + Assertions.assertEquals(Arrays.asList("v-a", "v-z"), evidence.getVersionIds()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> evidence.getVersionIds().clear()); + Map channelScores = new HashMap<>(); + channelScores.put("ALIAS_BM25", 2.0); + Map channelRanks = new HashMap<>(); + channelRanks.put("ALIAS_BM25", 1); + EvidenceCandidate traced = new EvidenceCandidate(evidence.getEvidence(), ids, 0.02, 1, + channelScores, channelRanks); + channelScores.clear(); + channelRanks.clear(); + Assertions.assertEquals(Collections.singletonMap("ALIAS_BM25", 2.0), + traced.getChannelScores()); + Assertions.assertEquals(Collections.singletonMap("ALIAS_BM25", 1), + traced.getChannelRanks()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> traced.getChannelScores().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> traced.getChannelRanks().clear()); + Assertions.assertThrows(IllegalArgumentException.class, () -> new EvidenceCandidate( + evidence.getEvidence(), ids, 0.02, 1, Collections.emptyMap(), + Collections.singletonMap("missing", 1))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new EvidenceCandidate(evidence.getEvidence(), ids, Double.NaN, 1)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new FactCandidate(KEY, version, true, Collections.emptyMap(), 0)); + CanonicalSnapshot snapshot = snapshot(Collections.singletonMap(KEY, + Collections.singletonList(version))); + Assertions.assertThrows(UnsupportedOperationException.class, () -> service.facts( + snapshot, QueryMode.CURRENT, KEY, at(0), null, "Shanghai", 20).clear()); + Assertions.assertThrows(UnsupportedOperationException.class, () -> service.evidence( + snapshot, QueryMode.CURRENT, KEY, at(0), null, "Shanghai", 20).clear()); + } + + @Test + public void testLexicalProfilesKeepMandatoryFactsBeyondPoolAndOnlyVisibleEvidence() + throws Exception { + List mandatory = new ArrayList<>(); + for (int i = 0; i < 105; i++) { + mandatory.add(active(String.format("v-%03d", i), KEY, "carbon dioxide atmosphere")); + } + Map> visible = new TreeMap<>(); + visible.put(KEY, mandatory); + FactKey other = key("bob", "profile"); + visible.put(other, Collections.singletonList(active("soft", other, "carbon dioxide"))); + CanonicalSnapshot before = snapshot(visible); + Map> all = new TreeMap<>(visible); + FactKey hidden = key("alice", "private"); + all.put(hidden, Collections.singletonList(active("secret", hidden, "CO2 secret"))); + FactKey future = key("future", "profile"); + all.put(future, Collections.singletonList(version("future", future, open(0), open(10), + MemoryFactVersionStatus.ACTIVE, evidence("e-future", "CO2 future")))); + CanonicalSnapshot after = snapshot(all); + final RetrievalProfile profile = RetrievalProfile.LEXICAL_FUSION; + TemporalCandidateService lexical = new TemporalCandidateService(profile); + List facts = lexical.facts(after, QueryMode.AS_OF, + KEY, at(5), at(5), "CO2", 1); + Assertions.assertEquals(106, facts.size()); + for (int i = 0; i < mandatory.size(); i++) { + Assertions.assertTrue(facts.get(i).isMandatory()); + Assertions.assertEquals(KEY, facts.get(i).getFactKey()); + Assertions.assertEquals(1.0, facts.get(i).getScores().get("RULE")); + } + Assertions.assertEquals("soft", facts.get(105).getVersion().getId()); + List expected = lexical.evidence(before, QueryMode.AS_OF, + KEY, at(5), at(5), "CO2", 20); + List actual = lexical.evidence(after, QueryMode.AS_OF, + KEY, at(5), at(5), "CO2", 20); + Assertions.assertEquals(evidenceIds(expected), evidenceIds(actual)); + for (int i = 0; i < expected.size(); i++) { + Assertions.assertEquals(expected.get(i).getScore(), actual.get(i).getScore()); + Assertions.assertEquals(expected.get(i).getChannelScores(), + actual.get(i).getChannelScores()); + Assertions.assertEquals(expected.get(i).getChannelRanks(), + actual.get(i).getChannelRanks()); + Assertions.assertEquals(expected.get(i).getVersionIds(), + actual.get(i).getVersionIds()); + } + Assertions.assertEquals(105, lexical.facts(after, QueryMode.AS_OF, + KEY, at(5), at(5), "", 1).size()); + Assertions.assertTrue(lexical.evidence(after, QueryMode.AS_OF, + KEY, at(5), at(5), "", 20).isEmpty()); + } + + @Test + public void testAllCandidateStrategiesKeepMandatoryCompleteAndHonorTemporalScope() + throws Exception { + final String text = "the earth climate temperature is rising across the planet each year"; + final List mandatory = new ArrayList<>(); + for (int i = 0; i < 105; i++) { + mandatory.add(active(String.format("v-%03d", i), KEY, text)); + } + final Map> groups = new TreeMap<>(); + groups.put(KEY, mandatory); + FactKey soft = key("bob", "profile"); + groups.put(soft, Collections.singletonList(active("soft", soft, text))); + final CanonicalSnapshot before = snapshot(groups); + final Map> withHidden = new TreeMap<>(groups); + FactKey secret = key("alice", "secret"); + withHidden.put(secret, Collections.singletonList(active("secret", secret, text))); + FactKey future = key("future", "profile"); + withHidden.put(future, Collections.singletonList(version("future", future, open(0), + open(10), MemoryFactVersionStatus.ACTIVE, evidence("future-e", text)))); + final CanonicalSnapshot after = snapshot(withHidden); + final int snapshotHash = after.hashCode(); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + TemporalCandidateService strategy = new TemporalCandidateService(profile); + List expectedFacts = strategy.facts(before, QueryMode.AS_OF, + KEY, at(5), at(5), text, 1); + List actualFacts = strategy.facts(after, QueryMode.AS_OF, + KEY, at(5), at(5), text, 1); + Assertions.assertEquals(factIds(expectedFacts), factIds(actualFacts)); + Assertions.assertTrue(actualFacts.size() >= 105 && actualFacts.size() <= 106); + for (int i = 0; i < 105; i++) { + FactCandidate fact = actualFacts.get(i); + Assertions.assertTrue(fact.isMandatory()); + Assertions.assertEquals(KEY, fact.getFactKey()); + Assertions.assertEquals(1.0, fact.getScores().get("RULE")); + Assertions.assertTrue(fact.getChannelRanks().containsKey("RULE")); + Assertions.assertEquals(fact.getScore(), fact.getScores().get("RRF")); + Assertions.assertEquals(expectedFacts.get(i).getScores(), fact.getScores()); + Assertions.assertEquals(expectedFacts.get(i).getChannelRanks(), + fact.getChannelRanks()); + } + if (profile.usesBm25()) { + Assertions.assertEquals(106, actualFacts.size()); + Assertions.assertEquals("soft", actualFacts.get(105).getVersion().getId()); + Assertions.assertFalse(actualFacts.get(105).isMandatory()); + } else if (profile == RetrievalProfile.RULE_ONLY) { + Assertions.assertEquals(105, actualFacts.size()); + } + Assertions.assertEquals(105, strategy.facts(after, QueryMode.AS_OF, + KEY, at(5), at(5), "", 1).size()); + List expected = strategy.evidence(before, QueryMode.AS_OF, + KEY, at(5), at(5), text, 20); + List actual = strategy.evidence(after, QueryMode.AS_OF, + KEY, at(5), at(5), text, 20); + Assertions.assertEquals(evidenceIds(expected), evidenceIds(actual)); + Assertions.assertEquals(20, actual.size()); + for (int i = 0; i < expected.size(); i++) { + Assertions.assertEquals(expected.get(i).getChannelScores(), + actual.get(i).getChannelScores()); + Assertions.assertEquals(expected.get(i).getChannelRanks(), + actual.get(i).getChannelRanks()); + Assertions.assertEquals(expected.get(i).getVersionIds(), actual.get(i).getVersionIds()); + } + } + Assertions.assertEquals(snapshotHash, after.hashCode()); + Assertions.assertEquals(108, after.getEvents().size()); + Assertions.assertTrue(after.getState().getRelations().isEmpty()); + } + + @Test + public void testFactChannelRankValidationAndImmutableTrace() { + final MemoryFactVersion version = active("v", KEY, "near duplicate"); + Map scores = new HashMap<>(); + scores.put("MINHASH", 0.75); + Map ranks = new HashMap<>(); + ranks.put("MINHASH", 2); + FactCandidate candidate = new FactCandidate(KEY, version, false, 0.02, 1, scores, ranks); + scores.clear(); + ranks.clear(); + Assertions.assertEquals(0.02, candidate.getScore()); + Assertions.assertEquals(Collections.singletonMap("MINHASH", 2), candidate.getChannelRanks()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> candidate.getChannelRanks().clear()); + Assertions.assertThrows(IllegalArgumentException.class, () -> new FactCandidate(KEY, + version, false, 0.02, 1, Collections.emptyMap(), + Collections.singletonMap("MINHASH", 1))); + Assertions.assertThrows(IllegalArgumentException.class, () -> new FactCandidate(KEY, + version, false, Double.NaN, 1, Collections.emptyMap(), Collections.emptyMap())); + } + + @Test + public void testMinHashOnlyFindsSoftFactsAndPreservesVisibleEvidenceReferences() throws Exception { + final String text = "the earth climate temperature is rising across the planet each year"; + final FactKey required = new FactKey("alice", "city", "profile"); + final FactKey other = new FactKey("bob", "city", "profile"); + final Evidence shared = evidence("shared", text); + Map> groups = new TreeMap<>(); + groups.put(required, Collections.singletonList(version("required", required, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, shared))); + groups.put(other, Collections.singletonList(version("other", other, open(0), open(0), + MemoryFactVersionStatus.ACTIVE, shared))); + final CanonicalSnapshot snapshot = snapshot(groups); + final TemporalCandidateService minHash = new TemporalCandidateService( + RetrievalProfile.RULE_MINHASH); + List facts = minHash.facts(snapshot, QueryMode.CURRENT, required, + at(1), null, text, 1); + Assertions.assertEquals(Arrays.asList("required", "other"), factIds(facts)); + Assertions.assertEquals(1.0, facts.get(0).getScores().get("MINHASH")); + Assertions.assertEquals(1.0, facts.get(1).getScores().get("MINHASH")); + Assertions.assertFalse(facts.get(1).isMandatory()); + Assertions.assertFalse(facts.get(1).getScores().containsKey("BM25")); + List evidence = minHash.evidence(snapshot, QueryMode.CURRENT, required, + at(1), null, text, 1); + Assertions.assertEquals(Collections.singletonList("shared"), evidenceIds(evidence)); + Assertions.assertEquals(Arrays.asList("other", "required"), evidence.get(0).getVersionIds()); + Assertions.assertTrue(snapshot.getState().getRelations().isEmpty()); + Assertions.assertEquals(2, snapshot.getEvents().size()); + } + + private static CanonicalSnapshot snapshot(Map> groups) { + List events = new ArrayList<>(); + Map origins = new TreeMap<>(); + for (Map.Entry> entry : groups.entrySet()) { + for (MemoryFactVersion version : entry.getValue()) { + String eventId = "event-" + version.getId(); + MemoryEvent event = MemoryEvent.add(eventId, version.getFact(), + version.getValidTime(), version.getTransactionTime().getStart(), + version.getEvidence()); + events.add(new EventNormalizer().normalize(event, entry.getKey())); + origins.put(version.getId(), eventId); + } + } + return new CanonicalSnapshot(new TemporalState(groups, Collections.emptyList()), + events, origins); + } + + private static MemoryFactVersion active(String id, FactKey key, String content) { + return version(id, key, open(0), open(0), MemoryFactVersionStatus.ACTIVE, + evidence("e-" + id, content)); + } + + private static MemoryFactVersion version(String id, FactKey key, TimeInterval valid, + TimeInterval system, MemoryFactVersionStatus status, + Evidence... evidence) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity(key.getSubjectId(), "person"), key.getPredicate(), "Shanghai"); + return new MemoryFactVersion(id, fact, status, valid, system, Arrays.asList(evidence)); + } + + private static Evidence evidence(String id, String content) { + return new Evidence(id, new Source("fixture", "test source"), content); + } + + private static FactKey key(String name, String scope) { + return new FactKey("person:" + name, "city", scope); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static TimeInterval open(long start) { + return TimeInterval.unboundedFrom(at(start)); + } + + private static List factIds(List facts) { + return facts.stream().map(candidate -> candidate.getVersion().getId()) + .collect(Collectors.toList()); + } + + private static List evidenceIds(List evidence) { + return evidence.stream().map(candidate -> candidate.getEvidence().getId()) + .collect(Collectors.toList()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/ConflictResolverCliTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/ConflictResolverCliTest.java new file mode 100644 index 000000000..632b95d46 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/ConflictResolverCliTest.java @@ -0,0 +1,161 @@ +/* + * 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.cli; + +import com.google.gson.JsonObject; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.geaflow.ai.temporal.api.ReplayResult; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class ConflictResolverCliTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + + @TempDir + Path directory; + + @Test + public void testFourProfilesPublishSameOracleStateAndPersistTheirActualConfiguration() throws Exception { + List events = events(); + CanonicalSnapshot expected = new FullReplayOracle().replayToSnapshot(events); + Path input = input(events); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + Path run = directory.resolve(profile.name() + ".json"); + ByteArrayOutputStream errors = new ByteArrayOutputStream(); + int code = invoke(errors, "replay", "--input", input.toString(), "--output", run.toString(), + "--candidate-profile", profile.name(), "--candidate-top-k", "1"); + Assertions.assertEquals(0, code, errors.toString("UTF-8")); + byte[] before = Files.readAllBytes(run); + TemporalMemoryFacade loaded = new TemporalRunStore().load(run); + Assertions.assertEquals(expected, loaded.getSnapshot()); + Assertions.assertEquals(profile.name(), loaded.getOperations().get(0) + .getAsJsonObject().get("candidateProfile").getAsString()); + for (org.apache.geaflow.ai.temporal.trace.DecisionTrace trace : loaded.getDecisionTraces()) { + Assertions.assertEquals("COMPLETED", trace.toJson().get("retrievalStatus").getAsString()); + Assertions.assertTrue(trace.toJson().get("candidateScores").isJsonObject()); + } + ReplayResult duplicate = loaded.applyBatch(Collections.singletonList(events.get(0)), profile, 1); + Assertions.assertEquals("DUPLICATE_NOOP", duplicate.getDecisionTraces().get(0).getCode()); + Assertions.assertTrue(duplicate.getDecisionTraces().get(0).toJson() + .get("candidateScores").isJsonNull()); + Assertions.assertEquals(expected, loaded.getSnapshot()); + Assertions.assertArrayEquals(before, Files.readAllBytes(run)); + Path updated = directory.resolve(profile.name() + "-updated.json"); + new TemporalRunStore().save(updated, loaded); + Assertions.assertEquals(loaded.getOperations(), new TemporalRunStore().load(updated).getOperations()); + } + } + + @Test + public void testLegacyRunAndNewTraceTamperingRemainStrictlyVerified() throws Exception { + TemporalMemoryFacade legacy = new TemporalMemoryFacade(); + legacy.replay(events()); + Path oldRun = directory.resolve("legacy.json"); + new TemporalRunStore().save(oldRun, legacy); + Assertions.assertEquals(legacy.getSnapshot(), new TemporalRunStore().load(oldRun).getSnapshot()); + Assertions.assertFalse(legacy.getOperations().get(0).getAsJsonObject().has("candidateProfile")); + Assertions.assertEquals("NOT_APPLICABLE_WEEK6", legacy.getDecisionTraces().get(0) + .toJson().get("retrievalStatus").getAsString()); + + TemporalMemoryFacade current = new TemporalMemoryFacade(); + current.replay(events(), RetrievalProfile.RULE_ALL, 1); + Path run = directory.resolve("new.json"); + new TemporalRunStore().save(run, current); + JsonObject stored = codec.parseObject(new String(Files.readAllBytes(run), StandardCharsets.UTF_8)); + JsonObject scores = stored.getAsJsonArray("decisionTraces").get(1).getAsJsonObject() + .getAsJsonObject("candidateScores"); + scores.addProperty("invented-version", 1.0); + Files.write(run, codec.toJson(stored).getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(TemporalValidationException.class, () -> new TemporalRunStore().load(run)); + } + + @Test + public void testInvalidCandidateOptionsNeverCreateOutput() throws Exception { + Path output = directory.resolve("must-not-exist.json"); + for (String[] options : Arrays.asList(new String[] {"--candidate-profile", "LEXICAL_FUSION"}, + new String[] {"--candidate-profile", "RULE_ALL", "--candidate-top-k", "0"}, + new String[] {"--candidate-profile", "RULE_ALL", "--candidate-top-k", "1.5"}, + new String[] {"--candidate-top-k", "1"})) { + List arguments = new java.util.ArrayList<>(Arrays.asList("replay", "--input", "unused", + "--output", output.toString())); + arguments.addAll(Arrays.asList(options)); + Assertions.assertEquals(2, invoke(new ByteArrayOutputStream(), arguments.toArray(new String[0]))); + Assertions.assertFalse(Files.exists(output)); + } + } + + private Path input(List events) throws Exception { + Path path = directory.resolve("input with spaces.jsonl"); + StringBuilder json = new StringBuilder(); + for (NormalizedMemoryEvent event : events) { + json.append(codec.toJson(codec.eventToJson(event))).append('\n'); + } + Files.write(path, json.toString().getBytes(StandardCharsets.UTF_8)); + return path; + } + + private static List events() { + return Arrays.asList(add("other", new FactKey("bob", "city", "profile"), "Rome", 5), + add("first", KEY, "Paris", 10), add("conflict", KEY, "Berlin", 20), + add("duplicate", KEY, "Paris", 30)); + } + + private static NormalizedMemoryEvent add(String id, FactKey key, String city, int time) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), city); + return new EventNormalizer().normalize(MemoryEvent.add(id, fact, + TimeInterval.unboundedFrom(BASE), BASE.plusSeconds(time), Collections.singletonList( + new Evidence("e-" + id, new Source("registry", "Registry"), + "City registry records the current location"))), key); + } + + private static int invoke(ByteArrayOutputStream errors, String... args) throws Exception { + return new TemporalMemoryCli().run(args, new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(errors, true, "UTF-8")); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/TemporalCandidateCliTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/TemporalCandidateCliTest.java new file mode 100644 index 000000000..fa1364aa5 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/TemporalCandidateCliTest.java @@ -0,0 +1,244 @@ +/* + * 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.cli; + +import com.google.gson.JsonObject; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +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.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TemporalCandidateCliTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + @TempDir + Path directory; + + @Test + public void testApiCliParityAndOldRunRemainsByteIdentical() throws Exception { + TemporalMemoryFacade facade = populated(); + facade.query(QueryMode.CURRENT, KEY, at(50), null); + Path run = directory.resolve("old run.json"); + TemporalRunStore store = new TemporalRunStore(); + store.save(run, facade); + byte[] before = Files.readAllBytes(run); + for (QueryMode mode : QueryMode.values()) { + Instant systemAt = mode == QueryMode.AS_OF ? at(15) : null; + String[] args = mode == QueryMode.AS_OF + ? arguments(run, mode.name(), "--system-at", systemAt.toString()) + : arguments(run, mode.name()); + Invocation actual = invoke(args); + Assertions.assertEquals(0, actual.code, actual.err); + Assertions.assertEquals("", actual.err); + Assertions.assertEquals(facade.candidates(mode, KEY, "renewable", at(50), + systemAt, 20).toJson(), actual.json()); + Assertions.assertArrayEquals(before, Files.readAllBytes(run)); + } + Invocation limited = invoke(arguments(run, "CURRENT", "--top-k", "1")); + Assertions.assertEquals(0, limited.code, limited.err); + Assertions.assertEquals(facade.candidates(QueryMode.CURRENT, KEY, "renewable", at(50), + null, 1).toJson(), limited.json()); + TemporalMemoryFacade restored = store.load(run); + Assertions.assertEquals(facade.getSnapshot(), restored.getSnapshot()); + Assertions.assertEquals(facade.getOperations(), restored.getOperations()); + Assertions.assertEquals(1, restored.getQueryTraces().size()); + Assertions.assertArrayEquals(before, Files.readAllBytes(run)); + } + + @Test + public void testNoHitsAndScopeIsolation() throws Exception { + Path run = saveRun(); + Invocation secret = invoke(replace(arguments(run, "CURRENT"), "--text", "secret")); + Assertions.assertEquals(0, secret.code, secret.err); + Assertions.assertEquals(1, secret.json().getAsJsonArray("facts").size()); + Assertions.assertEquals(0, secret.json().getAsJsonArray("evidence").size()); + Invocation missing = invoke(replace(arguments(run, "CURRENT"), "--scope", "missing")); + Assertions.assertEquals(0, missing.code, missing.err); + Assertions.assertEquals(0, missing.json().getAsJsonArray("facts").size()); + Assertions.assertEquals(0, missing.json().getAsJsonArray("evidence").size()); + Invocation past = invoke(arguments(run, "AS_OF", "--system-at", at(9).toString())); + Assertions.assertEquals(0, past.code, past.err); + Assertions.assertEquals(0, past.json().getAsJsonArray("facts").size()); + Assertions.assertEquals(0, past.json().getAsJsonArray("evidence").size()); + } + + @Test + public void testStrictArgumentsRejectInputWithoutRewritingRun() throws Exception { + Path run = saveRun(); + byte[] before = Files.readAllBytes(run); + List invalid = new ArrayList<>(Arrays.asList( + arguments(run, "unknown"), + arguments(run, "AS_OF"), + arguments(run, "CURRENT", "--system-at", at(15).toString()), + arguments(run, "CURRENT", "--top-k", "0"), + arguments(run, "CURRENT", "--top-k", "-1"), + arguments(run, "CURRENT", "--top-k", "1.5"), + arguments(run, "CURRENT", "--top-k", "2147483648"), + arguments(run, "CURRENT", "--unknown", "value"), + arguments(run, "CURRENT", "--output", directory.resolve("result.json").toString()), + arguments(run, "CURRENT", "--text", "duplicate"), + replace(arguments(run, "CURRENT"), "--valid-at", "2024-01-01"), + replace(arguments(run, "CURRENT"), "--text", ""))); + List missing = new ArrayList<>(Arrays.asList(arguments(run, "CURRENT"))); + int textOption = missing.indexOf("--text"); + missing.remove(textOption); + missing.remove(textOption); + invalid.add(missing.toArray(new String[0])); + for (String[] args : invalid) { + Invocation failure = invoke(args); + Assertions.assertEquals(2, failure.code, Arrays.toString(args) + failure.err); + Assertions.assertEquals("", failure.out); + Assertions.assertEquals("REJECT_INPUT", codec.parseObject(failure.err) + .get("code").getAsString()); + Assertions.assertArrayEquals(before, Files.readAllBytes(run)); + } + } + + @Test + public void testMissingRunReportsExecutionFailure() throws Exception { + Invocation failure = invoke(arguments(directory.resolve("missing.json"), "CURRENT")); + Assertions.assertEquals(3, failure.code); + Assertions.assertEquals("", failure.out); + Assertions.assertEquals("REJECT_EXECUTION", codec.parseObject(failure.err) + .get("code").getAsString()); + } + + @Test + public void testLexicalProfileCliParityAndSavedRunStaysUnchanged() throws Exception { + Path run = saveRun(); + byte[] before = Files.readAllBytes(run); + TemporalMemoryFacade facade = new TemporalRunStore().load(run); + final RetrievalProfile profile = RetrievalProfile.LEXICAL_FUSION; + Invocation actual = invoke(arguments(run, "CURRENT", "--profile", profile.name())); + Assertions.assertEquals(0, actual.code, actual.err); + Assertions.assertEquals(facade.candidates(QueryMode.CURRENT, KEY, "renewable", + at(50), null, 20, profile).toJson(), actual.json()); + Assertions.assertArrayEquals(before, Files.readAllBytes(run)); + } + + private Path saveRun() throws IOException { + Path run = directory.resolve("run.json"); + new TemporalRunStore().save(run, populated()); + return run; + } + + @Test + public void testFourCandidateStrategyProfilesMatchApiWithoutWritingRun() throws Exception { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + String text = "renewable wind powers cities"; + facade.replay(Arrays.asList(add("visible", KEY, "Beijing", text, 10), + add("hidden", new FactKey("alice", "city", "private"), "Rome", text, 20))); + Path run = directory.resolve("candidate strategies.json"); + new TemporalRunStore().save(run, facade); + byte[] before = Files.readAllBytes(run); + for (RetrievalProfile profile : Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL)) { + String[] args = replace(arguments(run, "CURRENT", "--profile", profile.name()), "--text", text); + Invocation actual = invoke(args); + Assertions.assertEquals(0, actual.code, actual.err); + Assertions.assertEquals(facade.candidates(QueryMode.CURRENT, KEY, text, + at(50), null, 20, profile).toJson(), actual.json()); + Assertions.assertArrayEquals(before, Files.readAllBytes(run)); + } + } + + private static TemporalMemoryFacade populated() { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + facade.replay(Arrays.asList(add("public", KEY, "Beijing", "renewable wind", 10), + add("private", new FactKey("alice", "city", "private"), "Rome", "secret renewable", 20))); + return facade; + } + + private static NormalizedMemoryEvent add(String id, FactKey key, String city, String text, + long recordedAt) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity(key.getSubjectId(), "person"), key.getPredicate(), city); + return new EventNormalizer().normalize(MemoryEvent.add(id, fact, + new TimeInterval(BASE, at(100)), at(recordedAt), Collections.singletonList( + new Evidence("e-" + id, new Source("registry", "Registry"), text))), key); + } + + private static String[] arguments(Path run, String mode, String... extra) { + List arguments = new ArrayList<>(Arrays.asList("candidates", "--run", run.toString(), + "--mode", mode, "--subject", "alice", "--predicate", "city", "--scope", "profile", + "--valid-at", at(50).toString(), "--text", "renewable")); + arguments.addAll(Arrays.asList(extra)); + return arguments.toArray(new String[0]); + } + + private static String[] replace(String[] arguments, String option, String value) { + String[] changed = arguments.clone(); + changed[Arrays.asList(changed).indexOf(option) + 1] = value; + return changed; + } + + private Invocation invoke(String... arguments) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + int code = new TemporalMemoryCli().run(arguments, + new PrintStream(out, true, "UTF-8"), new PrintStream(err, true, "UTF-8")); + return new Invocation(code, out.toString("UTF-8"), err.toString("UTF-8")); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } + + private final class Invocation { + + private final int code; + private final String out; + private final String err; + + private Invocation(int code, String out, String err) { + this.code = code; + this.out = out; + this.err = err; + } + + private JsonObject json() { + return codec.parseObject(out); + } + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/TemporalMemoryCliTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/TemporalMemoryCliTest.java new file mode 100644 index 000000000..be3cbc945 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/cli/TemporalMemoryCliTest.java @@ -0,0 +1,385 @@ +/* + * 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.cli; + +import com.google.gson.JsonObject; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.regex.Pattern; +import org.apache.geaflow.ai.temporal.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.codec.TemporalJsonCodec; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TemporalMemoryCliTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + private final EventNormalizer normalizer = new EventNormalizer(); + @TempDir + Path directory; + + @Test + public void testFourCommandsInFreshProcessesAndThreeQueryModes() throws Exception { + NormalizedMemoryEvent first = assertion("first", "北京", 10); + NormalizedMemoryEvent correction = normalizer.normalize(MemoryEvent.correct("change", + fact("罗马"), interval(30, 70), at(30), evidence("change")) + .targeting(versionId(first)), KEY); + Path input = input("events with spaces.jsonl", correction, first, first); + Path run = directory.resolve("run.json"); + Invocation replay = child("replay", "--input", input.toString(), "--output", run.toString()); + Assertions.assertEquals(0, replay.code, replay.err); + Assertions.assertEquals("FULL_REPLAY_MEMORY_GRAPH", replay.json().get("method").getAsString()); + Assertions.assertEquals(1, replay.json().getAsJsonObject("statistics") + .get("duplicateCount").getAsInt()); + + Path queryFile = directory.resolve("query.json"); + Invocation current = child(queryArgs(run, "CURRENT", "--output", queryFile.toString())); + Assertions.assertEquals(0, current.code, current.err); + Assertions.assertEquals("罗马", current.json().getAsJsonArray("versions").get(0) + .getAsJsonObject().getAsJsonObject("value").get("value").getAsString()); + Assertions.assertEquals(current.json(), codec.parseObject(read(queryFile))); + Invocation historical = child(queryArgs(run, "HISTORICAL")); + Assertions.assertEquals(0, historical.code, historical.err); + Assertions.assertEquals(2, historical.json().getAsJsonArray("versions").size()); + Invocation asOf = child(queryArgs(run, "AS_OF", "--system-at", at(20).toString())); + Assertions.assertEquals(0, asOf.code, asOf.err); + Assertions.assertEquals("北京", asOf.json().getAsJsonArray("versions").get(0) + .getAsJsonObject().getAsJsonObject("value").get("value").getAsString()); + + Invocation trace = child("trace", "--run", run.toString(), "--id", "query-3"); + Assertions.assertEquals(0, trace.code, trace.err); + Assertions.assertEquals(asOf.json().get("trace"), trace.json() + .getAsJsonArray("queryTraces").get(0)); + Invocation event = child("trace", "--run", run.toString(), "--id", "first"); + Assertions.assertEquals(0, event.code, event.err); + Assertions.assertEquals(2, event.json().getAsJsonArray("decisionTraces").size()); + Invocation attempt = child("trace", "--run", run.toString(), "--id", "attempt-1"); + Assertions.assertEquals(0, attempt.code, attempt.err); + Assertions.assertEquals(1, attempt.json().getAsJsonArray("decisionTraces").size()); + Invocation unknown = child("trace", "--run", run.toString(), "--id", "unknown"); + Assertions.assertEquals(0, unknown.code, unknown.err); + Assertions.assertEquals(0, unknown.json().getAsJsonArray("decisionTraces").size()); + Assertions.assertEquals(0, unknown.json().getAsJsonArray("queryTraces").size()); + Invocation validate = child("validate", "--run", run.toString()); + Assertions.assertEquals(0, validate.code, validate.err); + Assertions.assertTrue(validate.json().get("valid").getAsBoolean()); + Assertions.assertEquals(3, codec.parseObject(read(run)).getAsJsonArray("queryTraces").size()); + } + + @Test + public void testMainExitCodesForInvalidArgumentsAndIoFailure() throws Exception { + Invocation missing = child("replay", "--input", directory.resolve("events.jsonl").toString()); + Assertions.assertEquals(2, missing.code); + Assertions.assertEquals("REJECT_INPUT", codec.parseObject(missing.err).get("code").getAsString()); + Invocation nonexistent = child("validate", "--run", directory.resolve("missing.json").toString()); + Assertions.assertEquals(3, nonexistent.code); + Assertions.assertEquals("REJECT_EXECUTION", + codec.parseObject(nonexistent.err).get("code").getAsString()); + Assertions.assertEquals("", nonexistent.out); + } + + @Test + public void testStrictOptionsAndPathCollisionsPreserveFiles() throws Exception { + Path input = input("input.jsonl", assertion("first", "Beijing", 10)); + String inputBefore = read(input); + Assertions.assertEquals(2, invoke("replay", "--input", input.toString(), "--output", + directory.resolve("subfolder/../input.jsonl").toString()).code); + Assertions.assertEquals(inputBefore, read(input)); + Path run = directory.resolve("run.json"); + Assertions.assertEquals(0, invoke("replay", "--input", input.toString(), "--output", + run.toString()).code); + String before = read(run); + List commands = Arrays.asList( + queryArgs(run, "CURRENT", "--output", run.toString()), + queryArgs(run, "CURRENT", "--system-at", at(20).toString()), + queryArgs(run, "AS_OF"), queryArgs(run, "current"), + new String[] {"validate", "--run", run.toString(), "--unknown", "x"}, + new String[] {"validate", "--run", run.toString(), "--run", run.toString()}, + new String[] {"trace", "--run", run.toString(), "--id"}, + new String[] {"validate", "run", run.toString()}); + for (String[] command : commands) { + Invocation failure = invoke(command); + Assertions.assertEquals(2, failure.code, Arrays.toString(command)); + Assertions.assertEquals("", failure.out); + Assertions.assertEquals(before, read(run)); + } + Assertions.assertEquals(2, invoke(new String[0]).code); + Path outputDirectory = Files.createDirectory(directory.resolve("output-directory")); + Assertions.assertEquals(3, invoke("replay", "--input", input.toString(), "--output", + outputDirectory.toString()).code); + Assertions.assertTrue(Files.isDirectory(outputDirectory)); + } + + @Test + public void testWholeJsonlReplayAndHashValidationAreAtomic() throws Exception { + NormalizedMemoryEvent first = assertion("first", "Beijing", 10); + Path good = input("good.jsonl", first); + Path run = directory.resolve("run.json"); + Assertions.assertEquals(0, invoke("replay", "--input", good.toString(), "--output", + run.toString()).code); + String before = read(run); + Path bad = directory.resolve("bad.jsonl"); + write(bad, codec.toJson(codec.eventToJson(first)) + "\n{broken}\n"); + Invocation syntax = invoke("replay", "--input", bad.toString(), "--output", run.toString()); + Assertions.assertEquals(2, syntax.code); + Assertions.assertEquals(2, codec.parseObject(syntax.err).get("line").getAsInt()); + Assertions.assertEquals(before, read(run)); + NormalizedMemoryEvent missingTarget = normalizer.normalize(MemoryEvent.retract("invalid", + "fact-city", interval(30, 40), at(30), evidence("invalid")).targeting("missing"), KEY); + input("bad.jsonl", first, missingTarget); + Invocation semantic = invoke("replay", "--input", bad.toString(), "--output", run.toString()); + Assertions.assertEquals(2, semantic.code); + Assertions.assertEquals("REJECT_TARGET_NOT_FOUND", + codec.parseObject(semantic.err).get("code").getAsString()); + Assertions.assertEquals(2, codec.parseObject(semantic.err) + .getAsJsonArray("decisionTraces").size()); + Assertions.assertEquals("BATCH_ROLLED_BACK", codec.parseObject(semantic.err) + .getAsJsonArray("decisionTraces").get(0).getAsJsonObject().get("code").getAsString()); + Assertions.assertEquals(before, read(run)); + JsonObject tampered = codec.eventToJson(first); + tampered.getAsJsonObject("value").addProperty("value", "Shanghai"); + write(bad, codec.toJson(tampered) + "\n"); + Invocation hash = invoke("replay", "--input", bad.toString(), "--output", run.toString()); + Assertions.assertEquals(2, hash.code); + Assertions.assertTrue(codec.parseObject(hash.err).get("message").getAsString() + .contains("payloadHash")); + Assertions.assertEquals(before, read(run)); + Path newRun = directory.resolve("not-created.json"); + Assertions.assertEquals(2, invoke("replay", "--input", bad.toString(), "--output", + newRun.toString()).code); + Assertions.assertFalse(Files.exists(newRun)); + } + + @Test + public void testRunStoreRestoresRejectedBatchesAndQueries() throws Exception { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + NormalizedMemoryEvent first = assertion("first", "Beijing", 10); + facade.replay(Collections.singletonList(first)); + facade.applyBatch(Collections.singletonList(first)); + NormalizedMemoryEvent invalid = normalizer.normalize(MemoryEvent.retract("invalid", + "fact-city", interval(30, 40), at(30), evidence("invalid")).targeting("missing"), KEY); + Assertions.assertThrows(TemporalValidationException.class, + () -> facade.applyBatch(Arrays.asList(assertion("staged", "Rome", 20), invalid))); + facade.query(QueryMode.AS_OF, KEY, at(40), at(20)); + Path run = directory.resolve("nested/run.json"); + TemporalRunStore store = new TemporalRunStore(); + store.save(run, facade); + TemporalMemoryFacade restored = store.load(run); + Assertions.assertEquals(facade.getSnapshot(), restored.getSnapshot()); + Assertions.assertEquals(facade.getOperations(), restored.getOperations()); + Assertions.assertEquals(codec.toJson(codec.parseObject(read(run))), + codec.toJson(codec.parseObject(read(saveCopy(store, restored))))); + Assertions.assertEquals(2, restored.getTrace("first").size()); + Assertions.assertEquals("BATCH_ROLLED_BACK", restored.getTrace("invalid").get(0).getCode()); + Assertions.assertEquals("query-1", restored.getQueryTrace("query-1").getId()); + try (java.util.stream.Stream paths = Files.walk(directory)) { + Assertions.assertTrue(paths.noneMatch(path -> path.getFileName().toString().endsWith(".tmp"))); + } + } + + @Test + public void testCorruptOrLegacyArtifactsAreRejectedWithoutRewriting() throws Exception { + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + facade.replay(Collections.singletonList(assertion("first", "Beijing", 10))); + facade.query(QueryMode.CURRENT, KEY, at(40), null); + Path run = directory.resolve("run.json"); + new TemporalRunStore().save(run, facade); + String clean = read(run); + List> corruptions = Arrays.asList( + value -> value.addProperty("unknown", true), + value -> value.addProperty("artifactVersion", 2), + value -> value.addProperty("schemaVersion", 1), + value -> value.getAsJsonObject("snapshot").addProperty("unknown", true), + value -> value.getAsJsonArray("decisionTraces").get(0).getAsJsonObject() + .addProperty("code", "DUPLICATE_NOOP"), + value -> value.getAsJsonArray("queryTraces").get(0).getAsJsonObject() + .addProperty("unknown", true), + value -> value.getAsJsonArray("operations").get(0).getAsJsonObject() + .addProperty("expectedError", "REJECT_TARGET_NOT_FOUND"), + value -> value.getAsJsonArray("operations").get(0).getAsJsonObject() + .addProperty("unknown", true), + value -> value.getAsJsonArray("operations").get(0).getAsJsonObject() + .getAsJsonArray("events").get(0).getAsJsonObject().remove("payloadHash"), + value -> value.getAsJsonArray("operations").get(0).getAsJsonObject() + .getAsJsonArray("events").get(0).getAsJsonObject().getAsJsonObject("value") + .addProperty("value", "Rome")); + for (Consumer corruption : corruptions) { + JsonObject changed = codec.parseObject(clean); + corruption.accept(changed); + write(run, codec.toJson(changed)); + String before = read(run); + Invocation failure = invoke(queryArgs(run, "CURRENT")); + Assertions.assertEquals(2, failure.code, failure.err); + Assertions.assertEquals("", failure.out); + Assertions.assertEquals(before, read(run)); + } + } + + private Path saveCopy(TemporalRunStore store, TemporalMemoryFacade facade) throws IOException { + Path copy = directory.resolve("copy.json"); + store.save(copy, facade); + return copy; + } + + private Invocation invoke(String... arguments) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + int code = new TemporalMemoryCli().run(arguments, + new PrintStream(out, true, "UTF-8"), new PrintStream(err, true, "UTF-8")); + return new Invocation(code, out.toString("UTF-8"), err.toString("UTF-8")); + } + + private Invocation child(String... arguments) throws Exception { + String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + List command = new ArrayList<>(Arrays.asList(Paths.get(System.getProperty("java.home"), + "bin", executable).toString(), "-cp", classpathJar().toString(), + TemporalMemoryCli.class.getName())); + command.addAll(Arrays.asList(arguments)); + Path out = Files.createTempFile(directory, "child-", ".out"); + Path err = Files.createTempFile(directory, "child-", ".err"); + Process process = new ProcessBuilder(command).redirectOutput(out.toFile()) + .redirectError(err.toFile()).start(); + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + } + Assertions.assertTrue(finished, "CLI process exceeded 30 seconds"); + return new Invocation(process.exitValue(), read(out), read(err)); + } + + private Path classpathJar() throws IOException { + StringBuilder classpath = new StringBuilder(); + for (String entry : System.getProperty("java.class.path").split( + Pattern.quote(File.pathSeparator), -1)) { + classpath.append(Paths.get(entry.isEmpty() ? "." : entry).toAbsolutePath() + .toUri().toASCIIString()).append(' '); + } + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.CLASS_PATH, classpath.toString().trim()); + Path jar = Files.createTempFile(directory, "classpath-", ".jar"); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + output.finish(); + } + return jar; + } + + private String[] queryArgs(Path run, String mode, String... extras) { + List arguments = new ArrayList<>(Arrays.asList("query", "--run", run.toString(), + "--mode", mode, "--subject", "alice", "--predicate", "city", "--scope", "profile", + "--valid-at", at(40).toString())); + arguments.addAll(Arrays.asList(extras)); + return arguments.toArray(new String[0]); + } + + private Path input(String name, NormalizedMemoryEvent... events) throws IOException { + StringBuilder jsonl = new StringBuilder(); + for (NormalizedMemoryEvent event : events) { + jsonl.append(codec.toJson(codec.eventToJson(event))).append('\n'); + } + Path path = directory.resolve(name); + write(path, jsonl.toString()); + return path; + } + + private NormalizedMemoryEvent assertion(String id, String value, long recorded) { + return normalizer.normalize(MemoryEvent.add(id, fact(value), interval(0, 100), at(recorded), + evidence(id)), KEY); + } + + private static String versionId(NormalizedMemoryEvent event) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, event.getValidTime(), null); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-city", new MemoryEntity("alice", "person"), "city", value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("registry", "Registry"), "Evidence for " + id)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } + + private static void write(Path path, String value) throws IOException { + Files.write(path, value.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private final class Invocation { + + private final int code; + private final String out; + private final String err; + + private Invocation(int code, String out, String err) { + this.code = code; + this.out = out; + this.err = err; + } + + private JsonObject json() { + return codec.parseObject(out); + } + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/codec/TemporalJsonCodecTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/codec/TemporalJsonCodecTest.java new file mode 100644 index 000000000..c6bd0850d --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/codec/TemporalJsonCodecTest.java @@ -0,0 +1,324 @@ +/* + * 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.codec; + +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Validates production input without fixture aliases or reflective model construction. */ +public class TemporalJsonCodecTest { + + private static final String EVENT = "{\"schemaVersion\":2,\"id\":\"event-1\"," + + "\"operation\":\"ASSERT\",\"subject\":{\"id\":\"alice\",\"label\":\"person\"}," + + "\"predicate\":\"city\",\"scope\":\"profile\",\"factId\":\"fact-1\"," + + "\"value\":{\"kind\":\"LITERAL\",\"value\":\"Paris\"}," + + "\"validFrom\":\"2024-01-01T00:00:00.123456789Z\",\"validTo\":null," + + "\"recordedAt\":\"2024-03-01T00:00:00.987654321Z\",\"evidence\":[" + + "{\"id\":\"proof-b\",\"source\":{\"id\":\"source-b\",\"name\":\"Profile B\"}," + + "\"content\":\"Paris is home\"}," + + "{\"id\":\"proof-a\",\"source\":{\"id\":\"source-a\",\"name\":\"Profile A\"}," + + "\"content\":\"Lives in Paris \"}]}"; + + private final TemporalJsonCodec codec = new TemporalJsonCodec(); + + @Test + public void testNormalizedRoundTripIsStableAndKeepsNanoseconds() { + NormalizedMemoryEvent event = codec.decodeEvent(EVENT); + String json = codec.toJson(codec.eventToJson(event)); + Assertions.assertEquals(event, codec.decodeEvent(json)); + Assertions.assertEquals(json, codec.toJson(codec.eventToJson(codec.decodeEvent(json)))); + Assertions.assertEquals(123456789, event.getValidTime().getStart().getNano()); + Assertions.assertEquals(987654321, event.getRecordedAt().getNano()); + Assertions.assertEquals("proof-a", event.getEvidence().get(0).getId()); + Assertions.assertTrue(json.startsWith("{\"schemaVersion\":2,\"id\":\"event-1\"," + + "\"operation\":\"ASSERT\",\"subject\":")); + Assertions.assertTrue(json.contains("\"validTo\":null")); + Assertions.assertTrue(json.contains("\"target\":null")); + Assertions.assertTrue(json.contains("Lives in Paris ")); + Assertions.assertEquals(64, event.getPayloadHash().length()); + } + + @Test + public void testEntityCorrectionPreservesLabelsAndExplicitTarget() { + JsonObject json = codec.parseObject(EVENT); + json.addProperty("operation", "CORRECT"); + json.addProperty("target", "opaque-target-version"); + JsonObject value = json.getAsJsonObject("value"); + value.addProperty("kind", "ENTITY_REF"); + value.addProperty("value", "city:paris"); + value.addProperty("label", "city"); + NormalizedMemoryEvent event = codec.decodeEvent(json); + Assertions.assertEquals("opaque-target-version", event.getTargetVersionId().get()); + Assertions.assertEquals("city", event.getEvent().getFact().get().getTarget().get().getLabel()); + Assertions.assertEquals(event, codec.decodeEvent(codec.toJson(codec.eventToJson(event)))); + } + + @Test + public void testRetractionRoundTripDoesNotInventFactOrSubjectLabel() { + JsonObject json = codec.parseObject(EVENT); + json.addProperty("operation", "RETRACT"); + json.addProperty("target", "opaque-target-version"); + json.add("value", JsonNull.INSTANCE); + NormalizedMemoryEvent event = codec.decodeEvent(json); + JsonObject encoded = codec.eventToJson(event); + Assertions.assertTrue(encoded.get("value").isJsonNull()); + Assertions.assertTrue(encoded.getAsJsonObject("subject").get("label").isJsonNull()); + Assertions.assertFalse(event.getEvent().getFact().isPresent()); + Assertions.assertEquals(event, codec.decodeEvent(codec.toJson(encoded))); + } + + @Test + public void testPayloadHashIsVerifiedAfterNormalization() { + NormalizedMemoryEvent event = codec.decodeEvent(EVENT); + JsonObject json = codec.eventToJson(event); + Assertions.assertEquals(event, codec.decodeEvent(json)); + json.addProperty("payloadHash", "wrong"); + reject(json, TemporalErrorCode.REJECT_INPUT); + json.add("payloadHash", JsonNull.INSTANCE); + reject(json, TemporalErrorCode.REJECT_INPUT); + json.addProperty("payloadHash", event.getPayloadHash()); + json.getAsJsonObject("subject").addProperty("label", "organization"); + reject(json, TemporalErrorCode.REJECT_INPUT); + } + + @Test + public void testMissingBlankWrongTypeAndEmptyFieldsAreRejected() { + JsonObject missing = codec.parseObject(EVENT); + missing.remove("validTo"); + reject(missing, TemporalErrorCode.REJECT_INPUT); + JsonObject blank = codec.parseObject(EVENT); + blank.addProperty("id", " "); + reject(blank, TemporalErrorCode.REJECT_INPUT); + JsonObject numeric = codec.parseObject(EVENT); + numeric.addProperty("factId", 3); + reject(numeric, TemporalErrorCode.REJECT_INPUT); + JsonObject empty = codec.parseObject(EVENT); + empty.add("evidence", new JsonArray()); + reject(empty, TemporalErrorCode.REJECT_INPUT); + JsonObject wrongSource = codec.parseObject(EVENT); + wrongSource.getAsJsonArray("evidence").get(0).getAsJsonObject() + .add("source", new JsonPrimitive(true)); + reject(wrongSource, TemporalErrorCode.REJECT_INPUT); + JsonObject nullValue = codec.parseObject(EVENT); + nullValue.add("value", JsonNull.INSTANCE); + reject(nullValue, TemporalErrorCode.REJECT_INPUT); + } + + @Test + public void testUnknownFieldsAreRejectedAtEveryLevel() { + JsonObject top = codec.parseObject(EVENT); + top.addProperty("alias", "fixture-only"); + reject(top, TemporalErrorCode.REJECT_INPUT); + JsonObject subject = codec.parseObject(EVENT); + subject.getAsJsonObject("subject").addProperty("future", "field"); + reject(subject, TemporalErrorCode.REJECT_INPUT); + JsonObject value = codec.parseObject(EVENT); + value.getAsJsonObject("value").addProperty("label", "invalid-literal-label"); + reject(value, TemporalErrorCode.REJECT_INPUT); + JsonObject evidence = codec.parseObject(EVENT); + evidence.getAsJsonArray("evidence").get(0).getAsJsonObject() + .addProperty("score", 10); + reject(evidence, TemporalErrorCode.REJECT_INPUT); + JsonObject source = codec.parseObject(EVENT); + source.getAsJsonArray("evidence").get(0).getAsJsonObject() + .getAsJsonObject("source").addProperty("rank", 1); + reject(source, TemporalErrorCode.REJECT_INPUT); + } + + @Test + public void testOnlySchemaTwoIsAccepted() { + JsonObject old = codec.parseObject(EVENT); + old.addProperty("schemaVersion", 1); + reject(old, TemporalErrorCode.REJECT_SCHEMA_VERSION); + old.addProperty("schemaVersion", 3); + reject(old, TemporalErrorCode.REJECT_SCHEMA_VERSION); + old.addProperty("schemaVersion", "2"); + reject(old, TemporalErrorCode.REJECT_INPUT); + old.addProperty("schemaVersion", 2.5); + reject(old, TemporalErrorCode.REJECT_INPUT); + } + + @Test + public void testInvalidTimesAndIntervalsAreRejectedWithoutTruncation() { + for (String time : Arrays.asList("yesterday", "2024-02-30T00:00:00Z", + "2024-01-01T00:00:00+00:00", "2024-01-01T24:00:00Z", + "2024-01-01T00:00:00.1234567891Z", "2024-12-31T23:59:60Z")) { + JsonObject json = codec.parseObject(EVENT); + json.addProperty("recordedAt", time); + reject(json, TemporalErrorCode.REJECT_INPUT); + } + JsonObject empty = codec.parseObject(EVENT); + empty.add("validTo", empty.get("validFrom")); + reject(empty, TemporalErrorCode.REJECT_INPUT); + JsonObject reversed = codec.parseObject(EVENT); + reversed.addProperty("validTo", "2023-01-01T00:00:00Z"); + reject(reversed, TemporalErrorCode.REJECT_INPUT); + } + + @Test + public void testTargetsAndRetractionValuesAreNeverGuessed() { + JsonObject correction = codec.parseObject(EVENT); + correction.addProperty("operation", "CORRECT"); + reject(correction, TemporalErrorCode.REJECT_TARGET_REQUIRED); + correction.add("target", JsonNull.INSTANCE); + reject(correction, TemporalErrorCode.REJECT_TARGET_REQUIRED); + correction.addProperty("target", 1); + reject(correction, TemporalErrorCode.REJECT_INPUT); + JsonObject assertion = codec.parseObject(EVENT); + assertion.addProperty("target", "a-version"); + reject(assertion, TemporalErrorCode.REJECT_INPUT); + JsonObject retract = codec.parseObject(EVENT); + retract.addProperty("operation", "RETRACT"); + retract.addProperty("target", "a-version"); + reject(retract, TemporalErrorCode.REJECT_INPUT); + } + + @Test + public void testStrictJsonRejectsMalformedDuplicatesAndTrailingContent() { + for (String json : Arrays.asList("", "null", "[]", "{", "{'id':'x'}", + "{id:\"x\"}", "{\"id\":\"a\",\"id\":\"b\"}", + "{\"nested\":{\"id\":1,\"id\":2}}", "{} trailing", "{}{}", "{/*x*/}")) { + TemporalValidationException exception = Assertions.assertThrows( + TemporalValidationException.class, () -> codec.parseObject(json), json); + Assertions.assertEquals(TemporalErrorCode.REJECT_INPUT, exception.getCode(), json); + } + } + + @Test + public void testStrictJsonRejectsRawControlsAndInvalidEscapes() { + for (int control = 0; control < 0x20; control++) { + String json = "{\"content\":\"before" + (char) control + "after\"}"; + Assertions.assertThrows(TemporalValidationException.class, + () -> codec.parseObject(json), "Raw control " + control); + } + for (String json : Arrays.asList("{\"content\":\"bad\\qescape\"}", + "{\"content\":\"bad\\'escape\"}", "{\"content\":\"bad\\\nescape\"}", + "{\"content\":\"bad\\u12\"}", "{\"content\":\"bad\\u12XZ\"}", + "{\"content\":\"bad\\u1")) { + TemporalValidationException exception = Assertions.assertThrows( + TemporalValidationException.class, () -> codec.parseObject(json), json); + Assertions.assertEquals(TemporalErrorCode.REJECT_INPUT, exception.getCode()); + } + } + + @Test + public void testStrictJsonRejectsNonstandardLiteralCase() { + for (String literal : Arrays.asList("TRUE", "TrUe", "FALSE", "fAlSe", "NULL", "NuLl")) { + Assertions.assertThrows(TemporalValidationException.class, + () -> codec.parseObject("{\"value\":" + literal + "}"), literal); + } + Assertions.assertThrows(TemporalValidationException.class, + () -> codec.decodeEvent(EVENT.replace("\"validTo\":null", "\"validTo\":NULL"))); + } + + @Test + public void testStrictJsonKeepsValidEscapedControlsUnicodeAndNumericExponent() { + JsonObject json = codec.parseObject("{\"content\":\"quote\\\" slash\\/ backslash\\\\" + + " controls\\b\\f\\n\\r\\t unicode\\u4E2D\\u6587\\uD83D\\uDE00\"," + + "\"number\":2E0,\"yes\":true,\"no\":false,\"empty\":null}"); + Assertions.assertEquals("quote\" slash/ backslash\\ controls\b\f\n\r\t unicode中文😀", + json.get("content").getAsString()); + Assertions.assertEquals(2, json.get("number").getAsInt()); + Assertions.assertTrue(json.get("yes").getAsBoolean()); + Assertions.assertFalse(json.get("no").getAsBoolean()); + Assertions.assertTrue(json.get("empty").isJsonNull()); + Assertions.assertEquals(json, codec.parseObject(codec.toJson(json))); + String eventJson = EVENT.replace("Paris is home", "Paris\\n\\u4E2D"); + NormalizedMemoryEvent event = codec.decodeEvent(eventJson); + Assertions.assertEquals(event, codec.decodeEvent(codec.toJson(codec.eventToJson(event)))); + } + + @Test + public void testSnapshotIncludesCompleteCanonicalVersionAndRelations() { + NormalizedMemoryEvent first = codec.decodeEvent(EVENT); + JsonObject secondJson = codec.parseObject(EVENT); + secondJson.addProperty("id", "event-2"); + secondJson.addProperty("operation", "CORRECT"); + secondJson.addProperty("target", "version-first"); + secondJson.addProperty("recordedAt", "2024-04-01T00:00:00.000000001Z"); + secondJson.getAsJsonObject("value").addProperty("value", "Berlin"); + NormalizedMemoryEvent second = codec.decodeEvent(secondJson); + MemoryFactVersion oldVersion = new MemoryFactVersion("version-first", + first.getEvent().getFact().get(), first.getValidTime(), + new TimeInterval(first.getRecordedAt(), second.getRecordedAt()), first.getEvidence()); + MemoryFactVersion newVersion = new MemoryFactVersion("version-second", + second.getEvent().getFact().get(), second.getValidTime(), + TimeInterval.unboundedFrom(second.getRecordedAt()), second.getEvidence()); + VersionRelation relation = new VersionRelation(VersionRelationType.SUPERSEDES, + newVersion.getId(), oldVersion.getId()); + Map generating = new HashMap<>(); + generating.put(newVersion.getId(), second.getEventId()); + generating.put(oldVersion.getId(), first.getEventId()); + CanonicalSnapshot snapshot = new CanonicalSnapshot(new TemporalState( + Collections.singletonMap(first.getFactKey(), Arrays.asList(newVersion, oldVersion)), + Collections.singletonList(relation)), Arrays.asList(second, first), generating); + JsonObject json = codec.snapshotToJson(snapshot); + Assertions.assertEquals(2, json.get("schemaVersion").getAsInt()); + Assertions.assertEquals(first, codec.decodeEvent(json.getAsJsonArray("events") + .get(0).getAsJsonObject())); + JsonObject old = json.getAsJsonArray("versions").get(0).getAsJsonObject(); + Assertions.assertEquals("version-first", old.get("id").getAsString()); + Assertions.assertEquals("event-1", old.get("eventId").getAsString()); + Assertions.assertEquals("profile", old.getAsJsonObject("factKey").get("scope").getAsString()); + Assertions.assertEquals("2024-04-01T00:00:00.000000001Z", old.get("systemTo").getAsString()); + Assertions.assertEquals("2024-01-01T00:00:00.123456789Z", old.get("validFrom").getAsString()); + Assertions.assertEquals("Profile A", old.getAsJsonArray("evidence").get(0) + .getAsJsonObject().getAsJsonObject("source").get("name").getAsString()); + Assertions.assertEquals("Lives in Paris ", old.getAsJsonArray("evidence").get(0) + .getAsJsonObject().get("content").getAsString()); + Assertions.assertEquals("version-first", json.getAsJsonArray("relations").get(0) + .getAsJsonObject().get("to").getAsString()); + Assertions.assertTrue(json.getAsJsonArray("versions").get(1).getAsJsonObject() + .get("systemTo").isJsonNull()); + Assertions.assertEquals(codec.toJson(json), codec.toJson(codec.snapshotToJson(snapshot))); + } + + @Test + public void testJsonObjectNullInputHasStableError() { + TemporalValidationException exception = Assertions.assertThrows( + TemporalValidationException.class, () -> codec.decodeEvent((JsonObject) null)); + Assertions.assertEquals(TemporalErrorCode.REJECT_INPUT, exception.getCode()); + Assertions.assertThrows(TemporalValidationException.class, + () -> codec.decodeEvent((String) null)); + } + + private void reject(JsonObject json, TemporalErrorCode code) { + TemporalValidationException exception = Assertions.assertThrows( + TemporalValidationException.class, () -> codec.decodeEvent(json)); + Assertions.assertEquals(code, exception.getCode()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/CanonicalReplaySnapshotTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/CanonicalReplaySnapshotTest.java new file mode 100644 index 000000000..564c3ee7b --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/CanonicalReplaySnapshotTest.java @@ -0,0 +1,267 @@ +/* + * 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 java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.adapter.MemoryGraphAdapter; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +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.oracle.ReplayMethod; +import org.apache.geaflow.ai.temporal.semantics.CanonicalSnapshot; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CanonicalReplaySnapshotTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + + @Test + public void testEmptySnapshotsAndReplayMethodContract() { + ReplayMethod replay = oracle; + CanonicalSnapshot empty = replay.replayToSnapshot(Collections.emptyList()); + Assertions.assertEquals(new IncrementalTemporalIntegrator().canonicalSnapshot(), empty); + Assertions.assertTrue(empty.getGeneratingEventIds().isEmpty()); + Assertions.assertThrows(NullPointerException.class, () -> replay.replayToSnapshot(null)); + } + + @Test + public void testGraphRoundTripPreservesLifecycleAndGeneratingEvents() { + NormalizedMemoryEvent first = add("first", "Beijing", interval(0, 100), 10); + NormalizedMemoryEvent conflict = add("conflict", "Shanghai", interval(20, 80), 20); + NormalizedMemoryEvent duplicate = add("duplicate", "Beijing", interval(10, 90), 30); + String firstId = id(first, interval(0, 100), null); + String duplicateId = id(duplicate, interval(0, 100), null); + NormalizedMemoryEvent correction = correct("correction", "Chengdu", + interval(30, 70), 40, duplicateId); + String correctedId = id(correction, interval(30, 70), duplicateId); + NormalizedMemoryEvent retraction = retract("retraction", interval(40, 50), 50, + correctedId); + List events = Arrays.asList( + retraction, correction, duplicate, first, conflict, first); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.applyBatch(events); + + CanonicalSnapshot snapshot = oracle.replayToSnapshot(events); + Map expectedGenerating = new TreeMap<>(); + expectedGenerating.put(firstId, first.getEventId()); + expectedGenerating.put(id(conflict, interval(20, 80), null), conflict.getEventId()); + expectedGenerating.put(duplicateId, duplicate.getEventId()); + expectedGenerating.put(correctedId, correction.getEventId()); + expectedGenerating.put(VersionIdentity.create(correction, fact("Beijing"), + MemoryFactVersionStatus.ACTIVE, interval(0, 30), duplicateId), correction.getEventId()); + expectedGenerating.put(VersionIdentity.create(correction, fact("Beijing"), + MemoryFactVersionStatus.ACTIVE, interval(70, 100), duplicateId), correction.getEventId()); + expectedGenerating.put(VersionIdentity.create(retraction, fact("Chengdu"), + MemoryFactVersionStatus.RETRACTED, interval(40, 50), correctedId), retraction.getEventId()); + expectedGenerating.put(VersionIdentity.create(retraction, fact("Chengdu"), + MemoryFactVersionStatus.ACTIVE, interval(30, 40), correctedId), retraction.getEventId()); + expectedGenerating.put(VersionIdentity.create(retraction, fact("Chengdu"), + MemoryFactVersionStatus.ACTIVE, interval(50, 70), correctedId), retraction.getEventId()); + + Assertions.assertEquals(expectedGenerating, snapshot.getGeneratingEventIds()); + Assertions.assertEquals(snapshot, integrator.canonicalSnapshot()); + Assertions.assertEquals(oracle.replayNormalized(events), snapshot.getState()); + Assertions.assertEquals(5, snapshot.getEvents().size()); + MemoryGraphAdapter adapter = new MemoryGraphAdapter(); + Assertions.assertEquals(snapshot, adapter.fromGraph(adapter.toGraph(snapshot))); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> snapshot.getGeneratingEventIds().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> integrator.normalizedEventSnapshot().clear()); + } + + @Test + public void testLateReplayReplacesObsoleteIdentityAndPreservesOtherKeys() { + NormalizedMemoryEvent later = add("later", "Beijing", interval(10, 20), 30); + NormalizedMemoryEvent earlier = add("earlier", "Beijing", interval(0, 15), 10); + FactKey account = new FactKey("alice", "city", "account"); + NormalizedMemoryEvent unrelated = normalizer.normalize(MemoryEvent.add("account", + fact("Rome"), interval(0, 100), at(20), evidence("account")), account); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.applyBatch(Arrays.asList(later, unrelated)); + CanonicalSnapshot before = integrator.canonicalSnapshot(); + integrator.apply(earlier); + + CanonicalSnapshot snapshot = integrator.canonicalSnapshot(); + Assertions.assertEquals(oracle.replayToSnapshot(Arrays.asList(later, unrelated, earlier)), + snapshot); + Assertions.assertFalse(snapshot.getGeneratingEventIds().containsKey( + id(later, interval(10, 20), null))); + Assertions.assertEquals(later.getEventId(), snapshot.getGeneratingEventIds().get( + id(later, interval(0, 20), null))); + Assertions.assertEquals(earlier.getEventId(), snapshot.getGeneratingEventIds().get( + id(earlier, interval(0, 15), null))); + Assertions.assertEquals(before.getState().getVersionsByFactKey().get(account), + snapshot.getState().getVersionsByFactKey().get(account)); + Assertions.assertEquals(2, before.getGeneratingEventIds().size()); + } + + @Test + public void testSameTimeOmittedVersionsLoseMappingButKeepTheirEvents() { + NormalizedMemoryEvent first = add("a", "Beijing", interval(0, 100), 10); + NormalizedMemoryEvent duplicate = add("b", "Beijing", interval(10, 90), 10); + String duplicateId = id(duplicate, interval(0, 100), null); + NormalizedMemoryEvent correction = correct("c", "Rome", interval(30, 70), 10, + duplicateId); + String correctedId = id(correction, interval(30, 70), duplicateId); + NormalizedMemoryEvent retraction = retract("d", interval(40, 50), 10, correctedId); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.apply(first); + integrator.applyBatch(Arrays.asList(correction, duplicate)); + integrator.apply(retraction); + + CanonicalSnapshot snapshot = integrator.canonicalSnapshot(); + Assertions.assertEquals(oracle.replayToSnapshot( + Arrays.asList(retraction, duplicate, first, correction)), snapshot); + Assertions.assertEquals(4, snapshot.getEvents().size()); + Assertions.assertEquals(5, snapshot.getGeneratingEventIds().size()); + Assertions.assertFalse(snapshot.getGeneratingEventIds().containsKey( + id(first, interval(0, 100), null))); + Assertions.assertFalse(snapshot.getGeneratingEventIds().containsKey(duplicateId)); + Assertions.assertFalse(snapshot.getGeneratingEventIds().containsKey(correctedId)); + Assertions.assertEquals(2, snapshot.getGeneratingEventIds().values().stream() + .filter(correction.getEventId()::equals).count()); + Assertions.assertEquals(3, snapshot.getGeneratingEventIds().values().stream() + .filter(retraction.getEventId()::equals).count()); + Assertions.assertTrue(snapshot.getState().getRelations().isEmpty()); + Assertions.assertEquals(snapshot.getGeneratingEventIds().keySet(), snapshot.getState() + .getVersions().stream().map(version -> version.getId()).collect(Collectors.toSet())); + } + + @Test + public void testRejectedBatchRollsBackGeneratingEventsAndAllowsRetry() { + NormalizedMemoryEvent first = add("a", "Beijing", interval(0, 100), 10); + NormalizedMemoryEvent duplicate = add("b", "Beijing", interval(10, 90), 10); + NormalizedMemoryEvent invalid = retract("invalid", interval(30, 40), 20, "missing"); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.apply(first); + CanonicalSnapshot before = integrator.canonicalSnapshot(); + + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, + Assertions.assertThrows(TemporalValidationException.class, + () -> integrator.applyBatch(Arrays.asList(duplicate, invalid))).getCode()); + Assertions.assertEquals(before, integrator.canonicalSnapshot()); + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.ACCEPTED, + EventLedgerDecision.DUPLICATE_NOOP), integrator.applyBatch( + Arrays.asList(duplicate, duplicate))); + Assertions.assertEquals(oracle.replayToSnapshot(Arrays.asList(first, duplicate)), + integrator.canonicalSnapshot()); + Assertions.assertEquals(before.getGeneratingEventIds(), + Collections.singletonMap(id(first, interval(0, 100), null), first.getEventId())); + + CanonicalSnapshot committed = integrator.canonicalSnapshot(); + NormalizedMemoryEvent reused = add("b", "Shanghai", interval(10, 90), 10); + Assertions.assertEquals(TemporalErrorCode.REJECT_EVENT_ID_REUSE, + Assertions.assertThrows(TemporalValidationException.class, + () -> integrator.applyBatch(Collections.singletonList(reused))).getCode()); + Assertions.assertEquals(committed, integrator.canonicalSnapshot()); + } + + @Test + public void testLateBatchReplaysCompleteTargetDependenciesAndProvenanceAtomically() { + NormalizedMemoryEvent first = add("a", "Beijing", interval(0, 10), 20); + NormalizedMemoryEvent correction = correct("c", "Rome", interval(2, 8), 30, + id(first, interval(0, 10), null)); + NormalizedMemoryEvent earlier = add("b", "Beijing", interval(5, 15), 10); + String earlierId = id(earlier, interval(5, 15), null); + NormalizedMemoryEvent invalid = retract("r", interval(15, 20), 15, earlierId); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.applyBatch(Arrays.asList(correction, first)); + CanonicalSnapshot before = integrator.canonicalSnapshot(); + + Assertions.assertThrows(TemporalValidationException.class, () -> integrator.apply(earlier)); + Assertions.assertEquals(before, integrator.canonicalSnapshot()); + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, + Assertions.assertThrows(TemporalValidationException.class, + () -> integrator.applyBatch(Arrays.asList(invalid, earlier))).getCode()); + Assertions.assertEquals(before, integrator.canonicalSnapshot()); + + NormalizedMemoryEvent retry = retract("r", interval(10, 15), 15, earlierId); + integrator.applyBatch(Arrays.asList(retry, earlier)); + CanonicalSnapshot snapshot = integrator.canonicalSnapshot(); + Assertions.assertEquals(oracle.replayToSnapshot( + Arrays.asList(correction, earlier, retry, first)), snapshot); + Assertions.assertEquals(first.getEventId(), snapshot.getGeneratingEventIds().get( + id(first, interval(0, 10), null))); + Assertions.assertEquals(earlier.getEventId(), snapshot.getGeneratingEventIds().get(earlierId)); + } + + private NormalizedMemoryEvent add(String id, String value, TimeInterval valid, long recorded) { + return normalizer.normalize(MemoryEvent.add(id, fact(value), valid, at(recorded), + evidence(id)), KEY); + } + + private NormalizedMemoryEvent correct( + String id, String value, TimeInterval valid, long recorded, String target) { + return normalizer.normalize(MemoryEvent.correct(id, fact(value), valid, at(recorded), + evidence(id)).targeting(target), KEY); + } + + private NormalizedMemoryEvent retract( + String id, TimeInterval valid, long recorded, String target) { + return normalizer.normalize(MemoryEvent.retract(id, "fact-city", valid, at(recorded), + evidence(id)).targeting(target), KEY); + } + + private static String id(NormalizedMemoryEvent event, TimeInterval valid, String target) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, valid, target); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-city", new MemoryEntity("alice", "person"), + "city", value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("registry", "Registry"), "Evidence for " + id)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/ConflictResolverTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/ConflictResolverTest.java new file mode 100644 index 000000000..03ec986b8 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/ConflictResolverTest.java @@ -0,0 +1,333 @@ +/* + * 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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ConflictResolverTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + + private final EventNormalizer normalizer = new EventNormalizer(); + private final ConflictResolver resolver = new ConflictResolver(); + + @Test + public void testDuplicateClosureIncludesConflictBeyondOriginalEventInterval() { + final MemoryFactVersion first = current("first", "Paris", interval(0, 50)); + final MemoryFactVersion bridge = current("bridge", "Paris", interval(40, 70)); + final MemoryFactVersion conflict = current("conflict", "Rome", interval(10, 20)); + final MemoryFactVersion historical = new MemoryFactVersion("historical", fact("Paris"), + interval(-100, 100), interval(1, 2), evidence("historical")); + final MemoryFactVersion tombstone = new MemoryFactVersion("tombstone", fact("Paris"), + MemoryFactVersionStatus.RETRACTED, interval(-100, 100), + TimeInterval.unboundedFrom(at(2)), evidence("tombstone")); + final List versions = new ArrayList<>( + Arrays.asList(first, bridge, conflict, historical, tombstone)); + final List before = new ArrayList<>(versions); + final NormalizedMemoryEvent event = add("merge", KEY, "Paris", interval(60, 80), 40); + + final ResolutionDecision decision = resolver.resolve(event, versions); + + Assertions.assertEquals(interval(0, 80), decision.getEffectiveValidTime()); + Assertions.assertEquals(Arrays.asList("bridge", "conflict", "first"), + decision.getMandatoryVersionIds()); + Assertions.assertEquals(decision.getMandatoryVersionIds(), decision.getReadVersionIds()); + Assertions.assertEquals(Arrays.asList("MERGE_EQUAL_VALUE", "COEXIST_CONFLICT"), + decision.getRules()); + Assertions.assertNull(decision.getTargetVersionId()); + Assertions.assertEquals(before, versions); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> decision.getMandatoryVersionIds().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> decision.getReadVersionIds().clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> decision.getRules().clear()); + Collections.reverse(versions); + final ResolutionDecision reversed = resolver.resolve(event, versions); + Assertions.assertEquals(decision.getEffectiveValidTime(), reversed.getEffectiveValidTime()); + Assertions.assertEquals(decision.getMandatoryVersionIds(), + reversed.getMandatoryVersionIds()); + Assertions.assertEquals(decision.getRules(), reversed.getRules()); + } + + @Test + public void testAdjacentSameValueIsNewAssertionWithoutAnOverlap() { + final NormalizedMemoryEvent event = add("adjacent", KEY, "Paris", interval(20, 30), 40); + final ResolutionDecision decision = resolver.resolve(event, + Collections.singletonList(current("existing", "Paris", interval(0, 20)))); + + Assertions.assertEquals(event.getValidTime(), decision.getEffectiveValidTime()); + Assertions.assertTrue(decision.getMandatoryVersionIds().isEmpty()); + Assertions.assertTrue(decision.getReadVersionIds().isEmpty()); + Assertions.assertEquals(Collections.singletonList("ASSERT_NEW"), decision.getRules()); + } + + @Test + public void testApplyUsesClosureAndPreservesEvidenceAndCanonicalRelations() { + final MemoryFactVersion first = current("first", "Paris", interval(0, 50)); + final MemoryFactVersion bridge = current("bridge", "Paris", interval(40, 70)); + final MemoryFactVersion conflict = current("conflict", "Rome", interval(10, 20)); + final List versions = + new ArrayList<>(Arrays.asList(first, bridge, conflict)); + final List relations = new ArrayList<>(); + final Map provenance = new HashMap<>(); + final NormalizedMemoryEvent event = add("merge", KEY, "Paris", interval(60, 80), 40); + + resolver.apply(event, versions, relations, provenance); + + final String mergedId = id(event, event.getEvent().getFact().get(), interval(0, 80), + MemoryFactVersionStatus.ACTIVE); + final MemoryFactVersion merged = versions.stream().filter(v -> v.getId().equals(mergedId)) + .findFirst().orElseThrow(() -> new AssertionError("Merged version missing")); + Assertions.assertEquals(3, merged.getEvidence().size()); + Assertions.assertTrue(merged.getEvidence().containsAll(event.getEvidence())); + Assertions.assertTrue(merged.getEvidence().containsAll(first.getEvidence())); + Assertions.assertTrue(merged.getEvidence().containsAll(bridge.getEvidence())); + Assertions.assertEquals("merge", provenance.get(mergedId)); + Assertions.assertEquals(3, relations.size()); + Assertions.assertTrue(relations.contains(new VersionRelation( + VersionRelationType.DUPLICATE_OF, mergedId, first.getId()))); + Assertions.assertTrue(relations.contains(new VersionRelation( + VersionRelationType.DUPLICATE_OF, mergedId, bridge.getId()))); + Assertions.assertTrue(relations.contains(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, mergedId, conflict.getId()))); + Assertions.assertEquals(2, versions.stream() + .filter(v -> !v.getTransactionTime().getEnd().isPresent()).count()); + } + + @Test + public void testCorrectionSelectsExactTargetAndKeepsBothResiduals() { + final MemoryFactVersion target = current("target", "Paris", interval(0, 100)); + final MemoryFactVersion conflict = current("conflict", "Rome", interval(10, 90)); + final List versions = new ArrayList<>(Arrays.asList(conflict, target)); + final NormalizedMemoryEvent event = + change("correct", false, interval(20, 80), 40, "target"); + final ResolutionDecision decision = resolver.resolve(event, versions); + Assertions.assertEquals("target", decision.getTargetVersionId()); + Assertions.assertEquals(Collections.singletonList("target"), decision.getReadVersionIds()); + Assertions.assertEquals(Arrays.asList("conflict", "target"), + decision.getMandatoryVersionIds()); + Assertions.assertEquals(Collections.singletonList("CORRECT_TARGET"), decision.getRules()); + final List relations = new ArrayList<>(); + + resolver.apply(event, versions, relations, new HashMap<>()); + + Assertions.assertTrue(versions.contains(conflict)); + Assertions.assertEquals(5, versions.size()); + Assertions.assertEquals(3, relations.stream() + .filter(r -> r.getType() == VersionRelationType.SUPERSEDES).count()); + for (TimeInterval remainder : Arrays.asList(interval(0, 20), interval(80, 100))) { + MemoryFactVersion residual = versions.stream() + .filter(v -> v.getId().equals(id(event, target.getFact(), remainder, + MemoryFactVersionStatus.ACTIVE))) + .findFirst().orElseThrow(() -> new AssertionError("Residual missing")); + Assertions.assertEquals(target.getEvidence(), residual.getEvidence()); + Assertions.assertTrue(relations.contains(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, residual.getId(), conflict.getId()))); + } + } + + @Test + public void testRetractionClipsTargetAndPreservesUncoveredTail() { + final MemoryFactVersion target = current("target", "Paris", interval(0, 100)); + final List versions = new ArrayList<>(Collections.singletonList(target)); + final NormalizedMemoryEvent event = + change("retract", true, interval(-20, 30), 40, "target"); + final ResolutionDecision decision = resolver.resolve(event, versions); + Assertions.assertEquals(interval(0, 30), decision.getEffectiveValidTime()); + Assertions.assertEquals(Collections.singletonList("RETRACT_TARGET"), decision.getRules()); + final List relations = new ArrayList<>(); + + resolver.apply(event, versions, relations, new HashMap<>()); + + Assertions.assertEquals(3, versions.size()); + final MemoryFactVersion tombstone = versions.stream() + .filter(v -> v.getStatus() == MemoryFactVersionStatus.RETRACTED) + .findFirst().orElseThrow(() -> new AssertionError("Tombstone missing")); + Assertions.assertEquals(interval(0, 30), tombstone.getValidTime()); + Assertions.assertEquals(event.getEvidence(), tombstone.getEvidence()); + Assertions.assertEquals(id(event, target.getFact(), interval(0, 30), + MemoryFactVersionStatus.RETRACTED), tombstone.getId()); + Assertions.assertTrue(versions.stream().anyMatch(v -> + v.getValidTime().equals(interval(30, 100)) + && v.getEvidence().equals(target.getEvidence()))); + Assertions.assertEquals(2, relations.size()); + } + + @Test + public void testMissingInactiveAndNonOverlappingTargetsNeverUseAnotherCandidate() { + final MemoryFactVersion active = current("active", "Paris", interval(0, 100)); + final MemoryFactVersion closed = new MemoryFactVersion("closed", fact("Paris"), + interval(0, 100), interval(1, 2), evidence("closed")); + final MemoryFactVersion tombstone = new MemoryFactVersion("tombstone", fact("Paris"), + MemoryFactVersionStatus.RETRACTED, interval(0, 100), + TimeInterval.unboundedFrom(at(2)), evidence("tombstone")); + final List versions = + new ArrayList<>(Arrays.asList(active, closed, tombstone)); + final List before = new ArrayList<>(versions); + assertRejected(change("missing", false, interval(20, 30), 40, "missing"), versions, + TemporalErrorCode.REJECT_TARGET_NOT_FOUND); + assertRejected(change("closed", false, interval(20, 30), 40, "closed"), versions, + TemporalErrorCode.REJECT_TARGET_INACTIVE); + assertRejected(change("tombstone", true, interval(20, 30), 40, "tombstone"), versions, + TemporalErrorCode.REJECT_TARGET_INACTIVE); + assertRejected(change("no-overlap", true, interval(100, 110), 40, "active"), versions, + TemporalErrorCode.REJECT_TARGET_NO_OVERLAP); + Assertions.assertEquals(before, versions); + } + + @Test + public void testWrongScopeTargetRejectedWithoutPublishingStateOrEvent() { + final FactKey otherScope = new FactKey(KEY.getSubjectId(), KEY.getPredicate(), "account"); + final NormalizedMemoryEvent source = + add("account", otherScope, "Paris", interval(0, 100), 10); + final IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.apply(source); + final TemporalState before = integrator.stateSnapshot(); + final String targetId = id(source, source.getEvent().getFact().get(), source.getValidTime(), + MemoryFactVersionStatus.ACTIVE); + final NormalizedMemoryEvent event = + change("wrong-scope", false, interval(20, 30), 40, targetId); + + final TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, () -> integrator.apply(event)); + + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, failure.getCode()); + Assertions.assertEquals(before, integrator.stateSnapshot()); + Assertions.assertEquals(Collections.singletonList(source), + integrator.normalizedEventSnapshot()); + } + + @Test + public void testSameTimestampAndLateArrivalMatchIndependentOracleWithoutDanglingRelations() { + final NormalizedMemoryEvent source = add("a-source", KEY, "Paris", interval(0, 100), 10); + final NormalizedMemoryEvent conflict = add("b-conflict", KEY, "Rome", interval(10, 90), 10); + final NormalizedMemoryEvent change = change("c-correct", false, interval(20, 80), 10, + id(source, source.getEvent().getFact().get(), source.getValidTime(), + MemoryFactVersionStatus.ACTIVE)); + final NormalizedMemoryEvent duplicate = add("d-merge", KEY, "London", interval(30, 70), 20); + final List events = + Arrays.asList(source, conflict, change, duplicate); + final IncrementalTemporalIntegrator sequential = new IncrementalTemporalIntegrator(); + sequential.apply(source); + sequential.apply(change); + sequential.apply(duplicate); + sequential.apply(conflict); + final IncrementalTemporalIntegrator batched = new IncrementalTemporalIntegrator(); + batched.applyBatch(Arrays.asList(duplicate, change, source, conflict)); + final TemporalState expected = new FullReplayOracle().replayNormalized(events); + + Assertions.assertEquals(expected, sequential.stateSnapshot()); + Assertions.assertEquals(expected, batched.stateSnapshot()); + final Set versionIds = expected.getVersions().stream().map(MemoryFactVersion::getId) + .collect(Collectors.toSet()); + Assertions.assertFalse(versionIds.contains(id(source, source.getEvent().getFact().get(), + source.getValidTime(), MemoryFactVersionStatus.ACTIVE))); + for (VersionRelation relation : expected.getRelations()) { + Assertions.assertTrue(versionIds.contains(relation.getFromVersionId())); + Assertions.assertTrue(versionIds.contains(relation.getToVersionId())); + } + } + + private void assertRejected(NormalizedMemoryEvent event, List versions, + TemporalErrorCode code) { + final List relations = new ArrayList<>(); + final Map provenance = new HashMap<>(); + final TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, () -> resolver.apply(event, versions, + relations, provenance)); + Assertions.assertEquals(code, failure.getCode()); + Assertions.assertTrue(relations.isEmpty()); + Assertions.assertTrue(provenance.isEmpty()); + } + + private NormalizedMemoryEvent add(String eventId, FactKey key, String value, + TimeInterval valid, long recordedAt) { + return normalizer.normalize(MemoryEvent.add(eventId, fact(value), valid, + at(recordedAt), evidence(eventId)), key); + } + + private NormalizedMemoryEvent change(String eventId, boolean retract, TimeInterval valid, + long recordedAt, String targetId) { + final MemoryEvent event = retract + ? MemoryEvent.retract(eventId, "fact-city", valid, at(recordedAt), evidence(eventId)) + : MemoryEvent.correct(eventId, fact("London"), valid, at(recordedAt), + evidence(eventId)); + return normalizer.normalize(event.targeting(targetId), KEY); + } + + private static MemoryFactVersion current(String versionId, String value, TimeInterval valid) { + return new MemoryFactVersion(versionId, fact(value), valid, + TimeInterval.unboundedFrom(at(5)), evidence(versionId)); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-city", new MemoryEntity(KEY.getSubjectId(), "person"), + KEY.getPredicate(), value); + } + + private static List evidence(String eventId) { + return Collections.singletonList(new Evidence("evidence-" + eventId, + new Source("source", "fixture"), "Evidence for " + eventId)); + } + + private static String id(NormalizedMemoryEvent event, MemoryFact fact, TimeInterval valid, + MemoryFactVersionStatus status) { + return VersionIdentity.create(event, fact, status, valid, + event.getTargetVersionId().orElse(null)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTargetTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTargetTest.java new file mode 100644 index 000000000..a199a62dc --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTargetTest.java @@ -0,0 +1,321 @@ +/* + * 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 java.util.Set; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class IncrementalTargetTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey PROFILE = new FactKey("person:alice", "city", "profile"); + private static final FactKey ACCOUNT = new FactKey("person:alice", "city", "account"); + private static final TimeInterval VALID = interval(0, 100); + + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + + @Test + public void testBatchSortsTargetDependencyAndClipsToExactTarget() { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent source = add("source", PROFILE, "Beijing", VALID, 10); + NormalizedMemoryEvent account = add("account", ACCOUNT, "Paris", VALID, 15); + NormalizedMemoryEvent change = correct( + "change", PROFILE, "Shanghai", interval(-10, 110), 20, addId(source)); + + List decisions = integrator.applyBatch( + Arrays.asList(change, account, source)); + + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.ACCEPTED, + EventLedgerDecision.ACCEPTED, EventLedgerDecision.ACCEPTED), decisions); + Assertions.assertEquals(Arrays.asList(source, account, change), + integrator.normalizedEventSnapshot()); + TemporalState state = integrator.stateSnapshot(); + Assertions.assertEquals(3, state.getVersions().size()); + MemoryFactVersion original = version(state, addId(source)); + Assertions.assertEquals(VALID, original.getValidTime()); + Assertions.assertEquals(interval(10, 20), original.getTransactionTime()); + MemoryFactVersion replacement = version(state, changeId(change, + change.getEvent().getFact().get(), VALID)); + Assertions.assertEquals(VALID, replacement.getValidTime()); + Assertions.assertEquals(TimeInterval.unboundedFrom(at(20)), + replacement.getTransactionTime()); + Assertions.assertEquals("Shanghai", replacement.getFact().getLiteralValue().get()); + Assertions.assertEquals(change.getEvidence(), replacement.getEvidence()); + Assertions.assertEquals(Collections.singletonList(new VersionRelation( + VersionRelationType.SUPERSEDES, replacement.getId(), original.getId())), + state.getRelations()); + Assertions.assertEquals(Collections.singletonList(version(state, addId(account))), + state.getVersionsByFactKey().get(ACCOUNT)); + Assertions.assertEquals(oracle.replayNormalized(Arrays.asList(source, account, change)), + state); + } + + @Test + public void testAtomicRollbackLeavesEveryEventIdAvailableForRetry() { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent source = add("source", PROFILE, "Beijing", VALID, 10); + NormalizedMemoryEvent staged = add("staged", ACCOUNT, "Paris", VALID, 15); + NormalizedMemoryEvent invalid = correct( + "retry", PROFILE, "Shanghai", interval(100, 200), 20, addId(source)); + integrator.apply(source); + TemporalState before = integrator.stateSnapshot(); + List eventsBefore = integrator.normalizedEventSnapshot(); + + TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, + () -> integrator.applyBatch(Arrays.asList(invalid, staged))); + + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, failure.getCode()); + Assertions.assertEquals(before, integrator.stateSnapshot()); + Assertions.assertEquals(eventsBefore, integrator.normalizedEventSnapshot()); + NormalizedMemoryEvent retry = correct( + "retry", PROFILE, "Shanghai", interval(20, 40), 20, addId(source)); + NormalizedMemoryEvent stagedRetry = add("staged", ACCOUNT, "Munich", VALID, 15); + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.ACCEPTED, + EventLedgerDecision.ACCEPTED), + integrator.applyBatch(Arrays.asList(retry, stagedRetry))); + TemporalState after = integrator.stateSnapshot(); + Assertions.assertEquals(5, after.getVersions().size()); + Assertions.assertEquals(Arrays.asList(source, stagedRetry, retry), + integrator.normalizedEventSnapshot()); + Assertions.assertEquals(interval(0, 20), version(after, changeId(retry, + source.getEvent().getFact().get(), interval(0, 20))).getValidTime()); + Assertions.assertEquals(interval(20, 40), version(after, changeId(retry, + retry.getEvent().getFact().get(), interval(20, 40))).getValidTime()); + Assertions.assertEquals(interval(40, 100), version(after, changeId(retry, + source.getEvent().getFact().get(), interval(40, 100))).getValidTime()); + Assertions.assertEquals("Munich", + version(after, addId(stagedRetry)).getFact().getLiteralValue().get()); + Assertions.assertEquals(oracle.replayNormalized(Arrays.asList(source, stagedRetry, retry)), + after); + } + + @Test + public void testBatchIdempotencyDecisionsAndEventIdReuseRollback() { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent source = add("source", PROFILE, "Beijing", VALID, 10); + List decisions = integrator.applyBatch(Arrays.asList(source, source)); + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.ACCEPTED, + EventLedgerDecision.DUPLICATE_NOOP), decisions); + Assertions.assertThrows(UnsupportedOperationException.class, () -> decisions.clear()); + TemporalState before = integrator.stateSnapshot(); + NormalizedMemoryEvent staged = add("staged", ACCOUNT, "Paris", VALID, 5); + NormalizedMemoryEvent reused = add("source", PROFILE, "Rome", VALID, 10); + + TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, + () -> integrator.applyBatch(Arrays.asList(reused, staged))); + + Assertions.assertEquals(TemporalErrorCode.REJECT_EVENT_ID_REUSE, failure.getCode()); + Assertions.assertEquals(before, integrator.stateSnapshot()); + Assertions.assertEquals(Collections.singletonList(source), + integrator.normalizedEventSnapshot()); + Assertions.assertEquals(Collections.singletonList(EventLedgerDecision.ACCEPTED), + integrator.applyBatch(Collections.singletonList(staged))); + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.DUPLICATE_NOOP, + EventLedgerDecision.DUPLICATE_NOOP), + integrator.applyBatch(Arrays.asList(source, staged))); + Assertions.assertEquals(oracle.replayNormalized(Arrays.asList(staged, source)), + integrator.stateSnapshot()); + } + + @Test + public void testSameTimestampCrossBatchReplayKeepsOnlyMaterializedEndpoints() { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent source = add("a-source", PROFILE, "Beijing", VALID, 10); + NormalizedMemoryEvent conflict = add("b-conflict", PROFILE, "Paris", VALID, 10); + NormalizedMemoryEvent change = correct( + "c-change", PROFILE, "Shanghai", VALID, 10, addId(source)); + integrator.applyBatch(Collections.singletonList(source)); + integrator.applyBatch(Collections.singletonList(change)); + Assertions.assertEquals(1, integrator.stateSnapshot().getVersions().size()); + Assertions.assertTrue(integrator.stateSnapshot().getRelations().isEmpty()); + + integrator.applyBatch(Collections.singletonList(conflict)); + + TemporalState state = integrator.stateSnapshot(); + String changedId = changeId(change, change.getEvent().getFact().get(), VALID); + Assertions.assertEquals(2, state.getVersions().size()); + Assertions.assertEquals(Collections.singletonList(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, addId(conflict), changedId)), state.getRelations()); + Set ids = state.getVersions().stream().map(MemoryFactVersion::getId) + .collect(Collectors.toSet()); + Assertions.assertFalse(ids.contains(addId(source))); + for (MemoryFactVersion version : state.getVersions()) { + Assertions.assertEquals(MemoryFactVersionStatus.ACTIVE, version.getStatus()); + Assertions.assertEquals(TimeInterval.unboundedFrom(at(10)), + version.getTransactionTime()); + } + for (VersionRelation relation : state.getRelations()) { + Assertions.assertTrue(ids.contains(relation.getFromVersionId())); + Assertions.assertTrue(ids.contains(relation.getToVersionId())); + } + Assertions.assertEquals(Arrays.asList(source, conflict, change), + integrator.normalizedEventSnapshot()); + Assertions.assertEquals(oracle.replayNormalized(Arrays.asList(change, source, conflict)), + state); + IncrementalTemporalIntegrator singleBatch = new IncrementalTemporalIntegrator(); + singleBatch.applyBatch(Arrays.asList(change, conflict, source)); + Assertions.assertEquals(state, singleBatch.stateSnapshot()); + } + + @Test + public void testLateAddCannotRetargetCorrectionOrRenumberItsResidual() { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent source = add("source", PROFILE, "Beijing", VALID, 10); + NormalizedMemoryEvent change = correct( + "change", PROFILE, "Shanghai", interval(30, 180), 20, addId(source)); + integrator.applyBatch(Arrays.asList(change, source)); + TemporalState before = integrator.stateSnapshot(); + String residualId = changeId(change, source.getEvent().getFact().get(), interval(0, 30)); + String replacementId = changeId(change, + change.getEvent().getFact().get(), interval(30, 100)); + Assertions.assertEquals(interval(0, 30), version(before, residualId).getValidTime()); + Assertions.assertEquals(interval(30, 100), version(before, replacementId).getValidTime()); + NormalizedMemoryEvent lateAdd = add( + "late-add", PROFILE, "Rome", interval(150, 200), 15); + NormalizedMemoryEvent account = add("account", ACCOUNT, "Tokyo", VALID, 14); + + integrator.applyBatch(Arrays.asList(lateAdd, account)); + + TemporalState after = integrator.stateSnapshot(); + Assertions.assertEquals(5, after.getVersions().size()); + for (MemoryFactVersion version : before.getVersions()) { + Assertions.assertEquals(version, version(after, version.getId())); + } + Assertions.assertEquals(interval(150, 200), version(after, addId(lateAdd)).getValidTime()); + Assertions.assertFalse(version(after, addId(lateAdd)) + .getTransactionTime().getEnd().isPresent()); + Assertions.assertEquals("Rome", + version(after, addId(lateAdd)).getFact().getLiteralValue().get()); + Assertions.assertEquals( + oracle.replayNormalized(Arrays.asList(source, account, lateAdd, change)), after); + } + + @Test + public void testAddMergesCompleteSameValueChainAfterTargetedCorrection() { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent source = add("source", PROFILE, "Beijing", VALID, 10); + NormalizedMemoryEvent conflict = add( + "conflict", PROFILE, "Shanghai", interval(30, 60), 20); + NormalizedMemoryEvent change = correct( + "change", PROFILE, "Shanghai", interval(40, 70), 30, addId(source)); + NormalizedMemoryEvent duplicate = add( + "duplicate", PROFILE, "Shanghai", interval(60, 80), 40); + integrator.applyBatch(Arrays.asList(change, conflict, source)); + integrator.applyBatch(Collections.singletonList(duplicate)); + + TemporalState state = integrator.stateSnapshot(); + String mergedId = VersionIdentity.create(duplicate, duplicate.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, interval(30, 80), null); + MemoryFactVersion merged = version(state, mergedId); + Assertions.assertEquals(interval(30, 80), merged.getValidTime()); + Assertions.assertEquals(Arrays.asList(evidence("change").get(0), + evidence("conflict").get(0), evidence("duplicate").get(0)), merged.getEvidence()); + Assertions.assertEquals(1, state.getVersions().stream() + .filter(version -> !version.getTransactionTime().getEnd().isPresent()) + .filter(version -> version.getFact().getLiteralValue().get().equals("Shanghai")) + .count()); + Assertions.assertTrue(state.getRelations().contains(new VersionRelation( + VersionRelationType.DUPLICATE_OF, mergedId, addId(conflict)))); + Assertions.assertTrue(state.getRelations().contains(new VersionRelation( + VersionRelationType.DUPLICATE_OF, mergedId, + changeId(change, change.getEvent().getFact().get(), interval(40, 70))))); + Assertions.assertEquals(oracle.replayNormalized( + Arrays.asList(source, conflict, change, duplicate)), state); + } + + private NormalizedMemoryEvent add( + String id, FactKey key, String value, TimeInterval valid, long recordedAt) { + return normalizer.normalize(MemoryEvent.add(id, fact(key, value), valid, at(recordedAt), + evidence(id)), key); + } + + private NormalizedMemoryEvent correct( + String id, + FactKey key, + String value, + TimeInterval valid, + long recordedAt, + String targetId) { + return normalizer.normalize(MemoryEvent.correct(id, fact(key, value), valid, at(recordedAt), + evidence(id)).targeting(targetId), key); + } + + private static MemoryFact fact(FactKey key, String value) { + return MemoryFact.attribute("fact-shared", new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("source", "fixture"), "Evidence for " + id)); + } + + private static String addId(NormalizedMemoryEvent event) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, event.getValidTime(), null); + } + + private static String changeId( + NormalizedMemoryEvent event, MemoryFact fact, TimeInterval valid) { + return VersionIdentity.create(event, fact, MemoryFactVersionStatus.ACTIVE, valid, + event.getTargetVersionId().get()); + } + + private static MemoryFactVersion version(TemporalState state, String id) { + return state.getVersions().stream().filter(version -> version.getId().equals(id)) + .findFirst().orElseThrow(() -> new AssertionError("Missing version: " + id)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} 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..9c1c87ce1 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalIntegratorTest.java @@ -0,0 +1,385 @@ +/* + * 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 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()); + Assertions.assertThrows( + NullPointerException.class, + () -> integrator.apply((MemoryEvent) 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); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalStateTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalStateTest.java new file mode 100644 index 000000000..c8dd37770 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/IncrementalTemporalStateTest.java @@ -0,0 +1,454 @@ +/* + * 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.Comparator; +import java.util.List; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class IncrementalTemporalStateTest { + + private static final FactKey ALICE_KEY = new FactKey( + "person:alice", + "city", + "profile"); + private static final FactKey BOB_KEY = new FactKey( + "person:bob", + "city", + "profile"); + + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + + @Test + public void testLateEventMatchesOracleAndKeepsOtherFactKey() { + IncrementalTemporalIntegrator integrator = + new IncrementalTemporalIntegrator(); + TimeInterval validTime = interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"); + NormalizedMemoryEvent aliceAdd = add( + "event-alice-add", + ALICE_KEY, + "Beijing", + validTime, + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent bobAdd = add( + "event-bob-add", + BOB_KEY, + "Paris", + validTime, + "2024-04-01T00:00:00Z"); + NormalizedMemoryEvent laterCorrection = correct( + "event-alice-later", + ALICE_KEY, + "Rome", + validTime, + "2024-08-01T00:00:00Z", + addedVersionId(aliceAdd)); + NormalizedMemoryEvent retraction = retract( + "event-alice-retract", + ALICE_KEY, + validTime, + "2024-10-01T00:00:00Z", + changedVersionId(laterCorrection, laterCorrection.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, validTime)); + NormalizedMemoryEvent lateCorrection = correct( + "event-alice-late", + ALICE_KEY, + "Shanghai", + validTime, + "2024-06-01T00:00:00Z", + addedVersionId(aliceAdd)); + NormalizedMemoryEvent lateAdd = add( + "event-alice-late-add", ALICE_KEY, "Shanghai", validTime, + "2024-06-01T00:00:00Z"); + List received = new ArrayList<>(); + + for (NormalizedMemoryEvent event : Arrays.asList( + aliceAdd, + bobAdd, + laterCorrection, + retraction)) { + integrator.apply(event); + received.add(event); + Assertions.assertEquals( + oracle.replayNormalized(received), + integrator.stateSnapshot()); + } + + List bobBefore = + integrator.stateSnapshot() + .getVersionsByFactKey().get(BOB_KEY); + TemporalState beforeLate = integrator.stateSnapshot(); + TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, () -> integrator.apply(lateCorrection)); + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_INACTIVE, failure.getCode()); + Assertions.assertEquals(beforeLate, integrator.stateSnapshot()); + Assertions.assertEquals(received, integrator.normalizedEventSnapshot()); + integrator.apply(lateAdd); + received.add(lateAdd); + Collections.sort(received, Comparator + .comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId)); + + TemporalState state = integrator.stateSnapshot(); + Assertions.assertEquals( + oracle.replayNormalized(received), + state); + Assertions.assertEquals( + bobBefore, + state.getVersionsByFactKey().get(BOB_KEY)); + Assertions.assertEquals( + MemoryFactVersionStatus.RETRACTED, + version(state, changedVersionId(retraction, + laterCorrection.getEvent().getFact().get(), + MemoryFactVersionStatus.RETRACTED, validTime)) + .getStatus()); + } + + @Test + public void testPartialChangesAddConflictsForResidualVersions() { + IncrementalTemporalIntegrator integrator = + new IncrementalTemporalIntegrator(); + NormalizedMemoryEvent first = add( + "event-first", + ALICE_KEY, + "Beijing", + interval( + "2024-01-01T00:00:00Z", + "2024-10-01T00:00:00Z"), + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent conflict = add( + "event-conflict", + ALICE_KEY, + "Shanghai", + interval( + "2024-05-01T00:00:00Z", + "2025-01-01T00:00:00Z"), + "2024-04-01T00:00:00Z"); + NormalizedMemoryEvent correction = correct( + "event-correct", + ALICE_KEY, + "Rome", + interval( + "2024-01-01T00:00:00Z", + "2024-05-01T00:00:00Z"), + "2024-06-01T00:00:00Z", + addedVersionId(first)); + NormalizedMemoryEvent retraction = retract( + "event-retract", + ALICE_KEY, + correction.getValidTime(), + "2024-06-01T00:00:00Z", + addedVersionId(first)); + + integrator.apply(first); + integrator.apply(conflict); + integrator.apply(correction); + + List relations = + integrator.stateSnapshot().getRelations(); + Assertions.assertTrue(relations.contains(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + addedVersionId(first)))); + Assertions.assertTrue(relations.contains(new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + changedVersionId(correction, first.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + interval("2024-05-01T00:00:00Z", "2024-10-01T00:00:00Z"))))); + + IncrementalTemporalIntegrator retractingIntegrator = + new IncrementalTemporalIntegrator(); + retractingIntegrator.apply(first); + retractingIntegrator.apply(conflict); + retractingIntegrator.apply(retraction); + Assertions.assertTrue( + retractingIntegrator.stateSnapshot().getRelations().contains( + new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + changedVersionId(retraction, first.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, + interval("2024-05-01T00:00:00Z", "2024-10-01T00:00:00Z"))))); + } + + @Test + public void testFailedApplyDoesNotCommitLedgerOrState() { + IncrementalTemporalIntegrator integrator = + new IncrementalTemporalIntegrator(); + TimeInterval validTime = interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"); + NormalizedMemoryEvent add = add( + "event-add", + ALICE_KEY, + "Beijing", + validTime, + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent invalid = correct( + "event-change", + ALICE_KEY, + "Shanghai", + interval( + "2023-12-01T00:00:00Z", + "2024-01-01T00:00:00Z"), + "2024-06-01T00:00:00Z", + addedVersionId(add)); + NormalizedMemoryEvent validRetry = correct( + "event-change", + ALICE_KEY, + "Shanghai", + validTime, + "2024-06-01T00:00:00Z", + addedVersionId(add)); + NormalizedMemoryEvent reused = correct( + "event-change", + ALICE_KEY, + "London", + validTime, + "2024-06-01T00:00:00Z", + addedVersionId(add)); + + integrator.apply(add); + TemporalState before = integrator.stateSnapshot(); + + TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, () -> integrator.apply(invalid)); + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, failure.getCode()); + Assertions.assertEquals(before, integrator.stateSnapshot()); + Assertions.assertEquals(Collections.singletonList(add), + integrator.normalizedEventSnapshot()); + + integrator.apply(validRetry); + TemporalState afterRetry = integrator.stateSnapshot(); + Assertions.assertEquals( + oracle.replayNormalized(Arrays.asList(add, validRetry)), + afterRetry); + + integrator.apply(validRetry); + Assertions.assertEquals(afterRetry, integrator.stateSnapshot()); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> integrator.apply(reused)); + Assertions.assertEquals(afterRetry, integrator.stateSnapshot()); + } + + @Test + public void testFactKeyScopeIsolationAndImmutableSnapshot() { + IncrementalTemporalIntegrator integrator = + new IncrementalTemporalIntegrator(); + FactKey accountKey = new FactKey( + "person:alice", + "city", + "account"); + TimeInterval validTime = interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"); + NormalizedMemoryEvent profile = add( + "event-profile", + ALICE_KEY, + "Beijing", + "fact-shared", + validTime, + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent account = add( + "event-account", + accountKey, + "Shanghai", + "fact-shared", + validTime, + "2024-04-01T00:00:00Z"); + + integrator.apply(profile); + integrator.apply(account); + TemporalState state = integrator.stateSnapshot(); + + Assertions.assertEquals(2, state.getVersions().size()); + Assertions.assertEquals( + Arrays.asList(accountKey, ALICE_KEY), + new ArrayList<>( + state.getVersionsByFactKey().keySet())); + Assertions.assertTrue(state.getRelations().isEmpty()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> state.getVersions().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> state.getRelations().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> state.getVersionsByFactKey().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> state.getVersionsByFactKey() + .get(ALICE_KEY).clear()); + } + + private NormalizedMemoryEvent add( + String eventId, + FactKey key, + String value, + TimeInterval validTime, + String recordedAt) { + return add( + eventId, + key, + value, + factId(key), + validTime, + recordedAt); + } + + private NormalizedMemoryEvent add( + String eventId, + FactKey key, + String value, + String factId, + TimeInterval validTime, + String recordedAt) { + return normalizer.normalize( + MemoryEvent.add( + eventId, + fact(factId, key, value), + validTime, + time(recordedAt), + evidence(eventId)), + key); + } + + private NormalizedMemoryEvent correct( + String eventId, + FactKey key, + String value, + TimeInterval validTime, + String recordedAt, + String targetVersionId) { + return normalizer.normalize( + MemoryEvent.correct( + eventId, + fact(factId(key), key, value), + validTime, + time(recordedAt), + evidence(eventId)).targeting(targetVersionId), + key); + } + + private NormalizedMemoryEvent retract( + String eventId, + FactKey key, + TimeInterval validTime, + String recordedAt, + String targetVersionId) { + return normalizer.normalize( + MemoryEvent.retract( + eventId, + factId(key), + validTime, + time(recordedAt), + evidence(eventId)).targeting(targetVersionId), + key); + } + + private static String addedVersionId(NormalizedMemoryEvent event) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, event.getValidTime(), null); + } + + private static String changedVersionId( + NormalizedMemoryEvent event, + MemoryFact fact, + MemoryFactVersionStatus status, + TimeInterval validTime) { + return VersionIdentity.create(event, fact, status, validTime, + event.getTargetVersionId().get()); + } + + private static MemoryFact fact( + String factId, + FactKey key, + String value) { + return MemoryFact.attribute( + factId, + new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), + value); + } + + private static String factId(FactKey key) { + return "fact-" + + key.getSubjectId() + + "-" + + key.getScope(); + } + + private static List evidence(String eventId) { + return Collections.singletonList(new Evidence( + "evidence-" + eventId, + new Source("source-1", "registry"), + "Evidence for " + eventId)); + } + + private static MemoryFactVersion version( + TemporalState state, + String versionId) { + for (MemoryFactVersion version : state.getVersions()) { + if (version.getId().equals(versionId)) { + return version; + } + } + throw new AssertionError("Missing version: " + versionId); + } + + 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); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/LateBatchAtomicityTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/LateBatchAtomicityTest.java new file mode 100644 index 000000000..8c528d667 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/LateBatchAtomicityTest.java @@ -0,0 +1,227 @@ +/* + * 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.FactKey; +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.MemoryFactVersionStatus; +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.semantics.EventLedgerDecision; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LateBatchAtomicityTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + + @Test + public void testLateBatchValidatesCompleteTimelineBeforeExistingFutureTargets() { + NormalizedMemoryEvent assertion = add("a", interval(0, 10), 20); + NormalizedMemoryEvent correction = correct("c", interval(2, 8), 30, + addId(assertion)); + IncrementalTemporalIntegrator integrator = initialized(assertion, correction); + TemporalState before = integrator.stateSnapshot(); + NormalizedMemoryEvent earlier = add("b", interval(5, 15), 10); + NormalizedMemoryEvent retract = retract("r", interval(10, 15), 15, addId(earlier)); + + // The incomplete prefix expands a's interval and temporarily changes its identity. + TemporalValidationException prefixFailure = Assertions.assertThrows( + TemporalValidationException.class, () -> integrator.apply(earlier)); + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, prefixFailure.getCode()); + Assertions.assertEquals(before, integrator.stateSnapshot()); + + List decisions = integrator.applyBatch( + Arrays.asList(retract, earlier)); + + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.ACCEPTED, + EventLedgerDecision.ACCEPTED), decisions); + assertFinalTimeline(integrator, before, assertion, correction, earlier, retract); + } + + @Test + public void testRejectedLateBatchRollsBackTimelineAndAllowsChangedPayloadRetry() { + NormalizedMemoryEvent assertion = add("a", interval(0, 10), 20); + NormalizedMemoryEvent correction = correct("c", interval(2, 8), 30, + addId(assertion)); + IncrementalTemporalIntegrator integrator = initialized(assertion, correction); + TemporalState before = integrator.stateSnapshot(); + List eventsBefore = integrator.normalizedEventSnapshot(); + NormalizedMemoryEvent earlier = add("b", interval(5, 15), 10); + NormalizedMemoryEvent invalid = retract("r", interval(15, 20), 15, addId(earlier)); + + TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, + () -> integrator.applyBatch(Arrays.asList(invalid, earlier))); + + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, failure.getCode()); + Assertions.assertEquals(before, integrator.stateSnapshot()); + Assertions.assertEquals(eventsBefore, integrator.normalizedEventSnapshot()); + NormalizedMemoryEvent retry = retract("r", interval(10, 15), 15, addId(earlier)); + Assertions.assertEquals(Arrays.asList(EventLedgerDecision.ACCEPTED, + EventLedgerDecision.ACCEPTED), integrator.applyBatch(Arrays.asList(retry, earlier))); + assertFinalTimeline(integrator, before, assertion, correction, earlier, retry); + } + + @Test + public void testCrossKeyFailuresUseGlobalEventOrder() { + FactKey account = new FactKey("alice", "city", "account"); + NormalizedMemoryEvent source = normalizer.normalize( + MemoryEvent.add("account-source", fact("Beijing"), interval(0, 10), at(0), + evidence("account-source")), account); + NormalizedMemoryEvent earlierProfileError = correct( + "profile-invalid", interval(0, 10), 10, "unknown-profile-version"); + NormalizedMemoryEvent laterAccountError = normalizer.normalize( + MemoryEvent.correct("account-invalid", fact("Shanghai"), interval(10, 20), at(20), + evidence("account-invalid")).targeting(addId(source)), account); + List events = Arrays.asList( + laterAccountError, earlierProfileError, source); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + + TemporalValidationException oracleFailure = Assertions.assertThrows( + TemporalValidationException.class, () -> oracle.replayNormalized(events)); + TemporalValidationException incrementalFailure = Assertions.assertThrows( + TemporalValidationException.class, () -> integrator.applyBatch(events)); + + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, oracleFailure.getCode()); + Assertions.assertEquals(oracleFailure.getCode(), incrementalFailure.getCode()); + Assertions.assertTrue(integrator.normalizedEventSnapshot().isEmpty()); + Assertions.assertTrue(integrator.stateSnapshot().getVersions().isEmpty()); + Assertions.assertEquals(Collections.singletonList(EventLedgerDecision.ACCEPTED), + integrator.applyBatch(Collections.singletonList(source))); + } + + @Test + public void testEventIdReusePrecheckPrecedesEarlierInvalidTarget() { + NormalizedMemoryEvent invalidTarget = correct( + "invalid-target", interval(0, 10), 10, "unknown-version"); + NormalizedMemoryEvent original = add("reused", interval(0, 10), 20); + NormalizedMemoryEvent reused = normalizer.normalize( + MemoryEvent.add("reused", fact("Shanghai"), interval(0, 10), at(30), + evidence("reused")), KEY); + List events = Arrays.asList(reused, invalidTarget, original); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + + TemporalValidationException oracleFailure = Assertions.assertThrows( + TemporalValidationException.class, () -> oracle.replayNormalized(events)); + TemporalValidationException incrementalFailure = Assertions.assertThrows( + TemporalValidationException.class, () -> integrator.applyBatch(events)); + + Assertions.assertEquals(TemporalErrorCode.REJECT_EVENT_ID_REUSE, oracleFailure.getCode()); + Assertions.assertEquals(oracleFailure.getCode(), incrementalFailure.getCode()); + Assertions.assertTrue(integrator.normalizedEventSnapshot().isEmpty()); + Assertions.assertTrue(integrator.stateSnapshot().getVersions().isEmpty()); + } + + private IncrementalTemporalIntegrator initialized( + NormalizedMemoryEvent assertion, NormalizedMemoryEvent correction) { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.applyBatch(Arrays.asList(correction, assertion)); + return integrator; + } + + private void assertFinalTimeline( + IncrementalTemporalIntegrator integrator, + TemporalState before, + NormalizedMemoryEvent assertion, + NormalizedMemoryEvent correction, + NormalizedMemoryEvent earlier, + NormalizedMemoryEvent retract) { + TemporalState actual = integrator.stateSnapshot(); + List ordered = Arrays.asList( + earlier, retract, assertion, correction); + Assertions.assertEquals(ordered, integrator.normalizedEventSnapshot()); + Assertions.assertEquals(oracle.replayNormalized(ordered), actual); + String originalId = addId(assertion); + Assertions.assertEquals(originalId, correction.getTargetVersionId().get()); + Assertions.assertEquals(interval(0, 10), version(actual, originalId).getValidTime()); + Assertions.assertEquals(interval(20, 30), + version(actual, originalId).getTransactionTime()); + String replacementId = VersionIdentity.create(correction, + correction.getEvent().getFact().get(), MemoryFactVersionStatus.ACTIVE, + correction.getValidTime(), originalId); + Assertions.assertEquals(version(before, replacementId), version(actual, replacementId)); + Assertions.assertEquals("Shanghai", + version(actual, replacementId).getFact().getLiteralValue().get()); + } + + private NormalizedMemoryEvent add(String id, TimeInterval valid, long recordedAt) { + return normalizer.normalize(MemoryEvent.add(id, fact("Beijing"), valid, at(recordedAt), + evidence(id)), KEY); + } + + private NormalizedMemoryEvent correct( + String id, TimeInterval valid, long recordedAt, String targetId) { + return normalizer.normalize(MemoryEvent.correct(id, fact("Shanghai"), valid, at(recordedAt), + evidence(id)).targeting(targetId), KEY); + } + + private NormalizedMemoryEvent retract( + String id, TimeInterval valid, long recordedAt, String targetId) { + return normalizer.normalize(MemoryEvent.retract(id, "fact-city", valid, at(recordedAt), + evidence(id)).targeting(targetId), KEY); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-city", new MemoryEntity("alice", "person"), + "city", value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("registry", "Registry"), "Evidence for " + id)); + } + + private static String addId(NormalizedMemoryEvent event) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, event.getValidTime(), null); + } + + private static MemoryFactVersion version(TemporalState state, String id) { + return state.getVersions().stream().filter(version -> version.getId().equals(id)) + .findFirst().orElseThrow(() -> new AssertionError("Missing version: " + id)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalDifferentialTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalDifferentialTest.java new file mode 100644 index 000000000..f39ab3118 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalDifferentialTest.java @@ -0,0 +1,355 @@ +/* + * 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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.SplittableRandom; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.oracle.FullReplayOracle; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Small legal event streams generated without consulting either replay implementation. */ +public class TemporalDifferentialTest { + + private static final long[] SEEDS = {261450009L, 261450010L, 261450011L, 261450012L}; + private static final int KEY_COUNT = 6; + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final Comparator EVENT_ORDER = + Comparator.comparing(NormalizedMemoryEvent::getRecordedAt) + .thenComparing(NormalizedMemoryEvent::getEventId); + + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + + @Test + public void testFixedSeedsAcrossBatchSizesAndLegalArrivalOrders() { + for (long seed : SEEDS) { + List> timelines = timelines(seed); + List sorted = new ArrayList<>(); + for (List timeline : timelines) { + sorted.addAll(timeline); + } + Collections.sort(sorted, EVENT_ORDER); + + List shuffled = new ArrayList<>(sorted); + Collections.shuffle(shuffled, new Random(seed)); + IncrementalTemporalIntegrator allAtOnce = new IncrementalTemporalIntegrator(); + String fullContext = context(seed, "shuffled-full", 0, shuffled); + verify(seed, "shuffled-full", 0, shuffled, allAtOnce, () -> { + allAtOnce.applyBatch(shuffled); + Assertions.assertEquals(sorted, allAtOnce.normalizedEventSnapshot(), fullContext); + assertTemplateOutcome(allAtOnce.stateSnapshot(), fullContext); + }); + + for (int batchSize : new int[] {2, 7, 13}) { + verifySortedBatches(seed, sorted, batchSize, allAtOnce.stateSnapshot()); + } + verifyCrossKeyArrival(seed, timelines, allAtOnce.stateSnapshot()); + } + } + + private void verifySortedBatches( + long seed, + List sorted, + int batchSize, + TemporalState expectedFinal) { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + List prefix = new ArrayList<>(); + int batch = 0; + for (int start = 0; start < sorted.size(); start += batchSize) { + List next = sorted.subList( + start, Math.min(start + batchSize, sorted.size())); + prefix.addAll(next); + verify(seed, "sorted-size-" + batchSize, batch++, prefix, integrator, + () -> integrator.applyBatch(next)); + } + verify(seed, "sorted-final-" + batchSize, batch, prefix, integrator, + () -> Assertions.assertEquals(expectedFinal, integrator.stateSnapshot())); + } + + private void verifyCrossKeyArrival( + long seed, + List> timelines, + TemporalState expectedFinal) { + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + SplittableRandom random = new SplittableRandom(seed ^ 0x5deece66dL); + int[] positions = new int[KEY_COUNT]; + List activeKeys = new ArrayList<>(); + for (int key = 0; key < KEY_COUNT; key++) { + activeKeys.add(key); + } + List prefix = new ArrayList<>(); + Instant latest = Instant.MIN; + boolean sawLateArrival = false; + while (!activeKeys.isEmpty()) { + int activeIndex = random.nextInt(activeKeys.size()); + int keyIndex = activeKeys.get(activeIndex); + List timeline = timelines.get(keyIndex); + NormalizedMemoryEvent next = timeline.get(positions[keyIndex]++); + prefix.add(next); + if (next.getRecordedAt().isBefore(latest)) { + sawLateArrival = true; + } else { + latest = next.getRecordedAt(); + } + verify(seed, "cross-key-arrival", prefix.size() - 1, prefix, integrator, + () -> integrator.apply(next)); + if (positions[keyIndex] == timeline.size()) { + activeKeys.remove(activeIndex); + } + } + boolean lateCoverage = sawLateArrival; + verify(seed, "cross-key-final", prefix.size(), prefix, integrator, () -> { + Assertions.assertTrue(lateCoverage, + "Cross-key arrival must exercise recordedAt lateness"); + Assertions.assertEquals(expectedFinal, integrator.stateSnapshot()); + }); + } + + private void verify( + long seed, + String route, + int batch, + List prefix, + IncrementalTemporalIntegrator integrator, + Runnable action) { + TemporalState expected = null; + try { + action.run(); + expected = oracle.replayNormalized(prefix); + Assertions.assertEquals(expected, integrator.stateSnapshot(), + context(seed, route, batch, prefix)); + } catch (AssertionError | RuntimeException failure) { + if (expected == null) { + try { + expected = oracle.replayNormalized(prefix); + } catch (RuntimeException oracleFailure) { + failure.addSuppressed(oracleFailure); + } + } + saveFailure(seed, route, batch, prefix, expected, integrator.stateSnapshot(), failure); + throw new AssertionError(context(seed, route, batch, prefix), failure); + } + } + + private static void saveFailure( + long seed, + String route, + int batch, + List prefix, + TemporalState expected, + TemporalState actual, + Throwable failure) { + StringBuilder report = new StringBuilder(context(seed, route, batch, prefix)) + .append('\n').append("failure=").append(failure).append('\n'); + for (NormalizedMemoryEvent event : prefix) { + FactKey key = event.getFactKey(); + report.append("event=").append(event.getEventId()) + .append(" operation=").append(event.getOperation()) + .append(" factKey=").append(key.getSubjectId()).append('/') + .append(key.getPredicate()).append('/').append(key.getScope()) + .append(" factId=").append(event.getFactId()) + .append(" value=").append(event.getFactValue() + .map(value -> value.getKind() + ":" + value.getValue()).orElse("")) + .append(" valid=").append(event.getValidTime()) + .append(" recordedAt=").append(event.getRecordedAt()) + .append(" target=").append(event.getTargetVersionId().orElse("")) + .append(" payloadHash=").append(event.getPayloadHash()) + .append(" evidence=").append(evidenceSummary(event.getEvidence())).append('\n'); + } + appendState(report, "expected", expected); + appendState(report, "actual", actual); + try { + Path module = Paths.get(System.getProperty("basedir", ".")); + if (Files.isDirectory(module.resolve("geaflow-ai/src/main/java"))) { + module = module.resolve("geaflow-ai"); + } + Path directory = module.resolve("target/temporal-failures"); + Files.createDirectories(directory); + Files.write(directory.resolve(seed + "-" + route + "-" + batch + ".txt"), + report.toString().getBytes(StandardCharsets.UTF_8)); + } catch (IOException writeFailure) { + failure.addSuppressed(writeFailure); + } + } + + private static void appendState(StringBuilder report, String label, TemporalState state) { + report.append(label).append(':').append('\n'); + if (state == null) { + report.append("").append('\n'); + return; + } + for (Map.Entry> entry + : state.getVersionsByFactKey().entrySet()) { + FactKey key = entry.getKey(); + for (MemoryFactVersion version : entry.getValue()) { + report.append("version=").append(version.getId()) + .append(" factKey=").append(key.getSubjectId()).append('/') + .append(key.getPredicate()).append('/').append(key.getScope()) + .append(" factId=").append(version.getFact().getId()) + .append(" status=").append(version.getStatus()) + .append(" value=").append( + version.getFact().getLiteralValue().orElse("")) + .append(" valid=").append(version.getValidTime()) + .append(" system=").append(version.getTransactionTime()) + .append(" evidence=").append(evidenceSummary(version.getEvidence())) + .append('\n'); + } + } + for (VersionRelation relation : state.getRelations()) { + report.append("relation=").append(relation.getType()).append(' ') + .append(relation.getFromVersionId()).append(" -> ") + .append(relation.getToVersionId()).append('\n'); + } + } + + private static String evidenceSummary(List evidence) { + return evidence.stream().map(item -> item.getId() + "/" + item.getSource().getId() + + "/" + item.getSource().getName() + "/" + item.getContent()) + .collect(Collectors.joining(";")); + } + + private List> timelines(long seed) { + SplittableRandom random = new SplittableRandom(seed); + List> timelines = new ArrayList<>(); + for (int index = 0; index < KEY_COUNT; index++) { + FactKey key = new FactKey("person:" + (index / 2), "city", + index % 2 == 0 ? "profile" : "account"); + String prefix = seed + "-key-" + index + "-"; + String valueA = "city-a-" + random.nextInt(3); + String valueB = "city-b-" + random.nextInt(3); + String valueC = "city-c-" + random.nextInt(3); + long changeStart = 100 + random.nextInt(200); + long changeEnd = 700 + random.nextInt(200); + long withdrawStart = changeStart + 100; + long withdrawEnd = changeEnd - 100; + TimeInterval changedInterval = interval(changeStart, changeEnd); + NormalizedMemoryEvent source = add( + prefix + "1-source", key, valueA, interval(0, 1000), 10); + String sourceId = VersionIdentity.create(source, source.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, source.getValidTime(), null); + NormalizedMemoryEvent change = normalizer.normalize(MemoryEvent.correct( + prefix + "2-change", fact(key, valueB), changedInterval, at(20), + evidence(prefix + "2-change")).targeting(sourceId), key); + String changedId = VersionIdentity.create(change, change.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, changedInterval, sourceId); + NormalizedMemoryEvent withdraw = normalizer.normalize(MemoryEvent.retract( + prefix + "3-withdraw", factId(key), interval(withdrawStart, withdrawEnd), at(30), + evidence(prefix + "3-withdraw")).targeting(changedId), key); + NormalizedMemoryEvent conflict = add(prefix + "4-conflict", key, valueC, + interval(changeStart + 20, changeEnd - 20), 40); + NormalizedMemoryEvent duplicateB = add(prefix + "5-duplicate-b", key, valueB, + interval(changeStart + 10, withdrawStart - 10), 50); + NormalizedMemoryEvent duplicateC = add(prefix + "6-duplicate-c", key, valueC, + interval(changeStart - 50, changeEnd + 50), 60); + TimeInterval duplicateAInterval = random.nextBoolean() + ? interval(10, changeStart - 10) : interval(changeEnd + 10, 990); + NormalizedMemoryEvent duplicateA = add( + prefix + "7-duplicate-a", key, valueA, duplicateAInterval, 70); + NormalizedMemoryEvent separate = add(prefix + "8-separate", key, "city-d", + interval(1100 + random.nextInt(100), 1300 + random.nextInt(100)), 80); + timelines.add(Arrays.asList(source, change, withdraw, conflict, + duplicateB, duplicateC, duplicateA, separate)); + } + return timelines; + } + + private static void assertTemplateOutcome(TemporalState state, String context) { + Assertions.assertEquals(KEY_COUNT, state.getVersionsByFactKey().size(), context); + for (Map.Entry> entry + : state.getVersionsByFactKey().entrySet()) { + List versions = entry.getValue(); + Assertions.assertEquals(12, versions.size(), context); + Assertions.assertEquals(1, versions.stream() + .filter(version -> version.getStatus() == MemoryFactVersionStatus.RETRACTED) + .count(), context); + Assertions.assertEquals(6, versions.stream() + .filter(version -> version.getStatus() == MemoryFactVersionStatus.ACTIVE) + .filter(version -> !version.getTransactionTime().getEnd().isPresent()) + .count(), context); + Assertions.assertEquals(5, versions.stream() + .filter(version -> version.getTransactionTime().getEnd().isPresent()) + .count(), context); + } + } + + private NormalizedMemoryEvent add( + String id, FactKey key, String value, TimeInterval valid, long recordedAt) { + return normalizer.normalize(MemoryEvent.add(id, fact(key, value), valid, at(recordedAt), + evidence(id)), key); + } + + private static MemoryFact fact(FactKey key, String value) { + return MemoryFact.attribute(factId(key), new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), value); + } + + private static String factId(FactKey key) { + return "fact-" + key.getSubjectId() + "-" + key.getPredicate(); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("source", "generated-fixture"), "Evidence for " + id)); + } + + private static String context( + long seed, String path, int batch, List prefix) { + return "seed=" + seed + ", path=" + path + ", batch=" + batch + ", input=" + + prefix.stream().map(event -> event.getEventId() + "/" + event.getOperation() + + "@" + event.getRecordedAt() + "[" + event.getFactKey().getScope() + "]") + .collect(Collectors.joining(",")); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } +} 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..09b36da35 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/integration/TemporalEventPipelineTest.java @@ -0,0 +1,389 @@ +/* + * 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 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; +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.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 { + + private static final int WINDOW_SIZE = 2; + + @TempDir + Path tempDirectory; + + @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(outputFileName); + Environment environment = null; + + try { + environment = EnvironmentFactory.onLocalEnvironment(); + environment.getEnvironmentContext().withConfig(config); + 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 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", + "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); + } +} 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)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/FactKeyTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/FactKeyTest.java new file mode 100644 index 000000000..b90015ca0 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/FactKeyTest.java @@ -0,0 +1,98 @@ +/* + * 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.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class FactKeyTest { + + @Test + public void testValueSemanticsAndDeterministicOrder() { + FactKey key = new FactKey( + "person:alice", + "city", + "profile"); + FactKey same = new FactKey( + "person:alice", + "city", + "profile"); + + Assertions.assertEquals("person:alice", key.getSubjectId()); + Assertions.assertEquals("city", key.getPredicate()); + Assertions.assertEquals("profile", key.getScope()); + Assertions.assertEquals(key, same); + Assertions.assertEquals(key.hashCode(), same.hashCode()); + + FactKey earlierPredicate = new FactKey( + "person:alice", + "age", + "profile"); + FactKey earlierScope = new FactKey( + "person:alice", + "city", + "account"); + FactKey laterSubject = new FactKey( + "person:bob", + "age", + "profile"); + List keys = new ArrayList<>(Arrays.asList( + laterSubject, + key, + earlierScope, + earlierPredicate)); + + Collections.sort(keys); + + Assertions.assertEquals( + Arrays.asList( + earlierPredicate, + earlierScope, + key, + laterSubject), + keys); + } + + @Test + public void testRejectInvalidKey() { + Assertions.assertThrows( + NullPointerException.class, + () -> new FactKey(null, "city", "profile")); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new FactKey(" ", "city", "profile")); + Assertions.assertThrows( + NullPointerException.class, + () -> new FactKey("person:alice", null, "profile")); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new FactKey("person:alice", " ", "profile")); + Assertions.assertThrows( + NullPointerException.class, + () -> new FactKey("person:alice", "city", null)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new FactKey("person:alice", "city", " ")); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/FactValueTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/FactValueTest.java new file mode 100644 index 000000000..25d215a80 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/FactValueTest.java @@ -0,0 +1,101 @@ +/* + * 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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class FactValueTest { + + @Test + public void testLiteralAndEntityReference() { + FactValue literal = FactValue.literal("Beijing"); + FactValue entityReference = + FactValue.entityReference("city:beijing"); + + Assertions.assertEquals( + FactValue.Kind.LITERAL, + literal.getKind()); + Assertions.assertEquals("Beijing", literal.getValue()); + Assertions.assertEquals( + Optional.of("Beijing"), + literal.getLiteralValue()); + Assertions.assertEquals( + Optional.empty(), + literal.getEntityId()); + + Assertions.assertEquals( + FactValue.Kind.ENTITY_REF, + entityReference.getKind()); + Assertions.assertEquals( + "city:beijing", + entityReference.getValue()); + Assertions.assertEquals( + Optional.empty(), + entityReference.getLiteralValue()); + Assertions.assertEquals( + Optional.of("city:beijing"), + entityReference.getEntityId()); + } + + @Test + public void testValueSemanticsAndDeterministicOrder() { + FactValue beijing = FactValue.literal("Beijing"); + FactValue same = FactValue.literal("Beijing"); + FactValue shanghai = FactValue.literal("Shanghai"); + FactValue reference = + FactValue.entityReference("city:beijing"); + + Assertions.assertEquals(beijing, same); + Assertions.assertEquals(beijing.hashCode(), same.hashCode()); + Assertions.assertNotEquals(beijing, reference); + + List values = new ArrayList<>(Arrays.asList( + reference, + shanghai, + beijing)); + Collections.sort(values); + + Assertions.assertEquals( + Arrays.asList(beijing, shanghai, reference), + values); + } + + @Test + public void testRejectInvalidValue() { + Assertions.assertThrows( + NullPointerException.class, + () -> FactValue.literal(null)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> FactValue.literal(" ")); + Assertions.assertThrows( + NullPointerException.class, + () -> FactValue.entityReference(null)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> FactValue.entityReference(" ")); + } +} 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", " ")); + } +} 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); + } +} 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)); + } +} 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..2f03faa86 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/MemoryFactVersionTest.java @@ -0,0 +1,297 @@ +/* + * 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 testStatusDefaultsToActiveAndAffectsValueSemantics() { + 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")); + MemoryFactVersion active = new MemoryFactVersion( + "version-1", + fact, + validTime, + transactionTime, + evidence); + MemoryFactVersion explicitActive = new MemoryFactVersion( + "version-1", + fact, + MemoryFactVersionStatus.ACTIVE, + validTime, + transactionTime, + evidence); + MemoryFactVersion retracted = new MemoryFactVersion( + "version-1", + fact, + MemoryFactVersionStatus.RETRACTED, + validTime, + transactionTime, + evidence); + + Assertions.assertEquals( + MemoryFactVersionStatus.ACTIVE, + active.getStatus()); + Assertions.assertEquals(active, explicitActive); + Assertions.assertNotEquals(active, retracted); + Assertions.assertThrows( + NullPointerException.class, + () -> new MemoryFactVersion( + "version-1", + fact, + null, + validTime, + transactionTime, + evidence)); + } + + @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); + } +} 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, " ")); + } +} 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); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/VersionRelationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/VersionRelationTest.java new file mode 100644 index 000000000..0a0f9c9f4 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/model/VersionRelationTest.java @@ -0,0 +1,143 @@ +/* + * 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.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class VersionRelationTest { + + @Test + public void testDirectionalRelationValueSemantics() { + VersionRelation relation = new VersionRelation( + VersionRelationType.SUPERSEDES, + "version-2", + "version-1"); + VersionRelation same = new VersionRelation( + VersionRelationType.SUPERSEDES, + "version-2", + "version-1"); + + Assertions.assertEquals( + VersionRelationType.SUPERSEDES, + relation.getType()); + Assertions.assertEquals( + "version-2", + relation.getFromVersionId()); + Assertions.assertEquals( + "version-1", + relation.getToVersionId()); + Assertions.assertEquals(relation, same); + Assertions.assertEquals( + relation.hashCode(), + same.hashCode()); + } + + @Test + public void testConflictUsesCanonicalDirection() { + VersionRelation relation = new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + "version-b", + "version-a"); + VersionRelation canonical = new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + "version-a", + "version-b"); + + Assertions.assertEquals( + "version-a", + relation.getFromVersionId()); + Assertions.assertEquals( + "version-b", + relation.getToVersionId()); + Assertions.assertEquals(canonical, relation); + } + + @Test + public void testDeterministicOrder() { + VersionRelation supersedes = new VersionRelation( + VersionRelationType.SUPERSEDES, + "version-2", + "version-1"); + VersionRelation duplicate = new VersionRelation( + VersionRelationType.DUPLICATE_OF, + "version-3", + "version-1"); + VersionRelation conflict = new VersionRelation( + VersionRelationType.CONFLICTS_WITH, + "version-2", + "version-3"); + List relations = + new ArrayList<>(Arrays.asList( + conflict, + duplicate, + supersedes)); + + Collections.sort(relations); + + Assertions.assertEquals( + Arrays.asList(supersedes, duplicate, conflict), + relations); + } + + @Test + public void testRejectInvalidRelation() { + Assertions.assertThrows( + NullPointerException.class, + () -> new VersionRelation( + null, + "version-2", + "version-1")); + Assertions.assertThrows( + NullPointerException.class, + () -> new VersionRelation( + VersionRelationType.SUPERSEDES, + null, + "version-1")); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new VersionRelation( + VersionRelationType.SUPERSEDES, + " ", + "version-1")); + Assertions.assertThrows( + NullPointerException.class, + () -> new VersionRelation( + VersionRelationType.SUPERSEDES, + "version-2", + null)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new VersionRelation( + VersionRelationType.SUPERSEDES, + "version-2", + " ")); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new VersionRelation( + VersionRelationType.SUPERSEDES, + "version-1", + "version-1")); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTemporalStateTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTemporalStateTest.java new file mode 100644 index 000000000..4718e706e --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTemporalStateTest.java @@ -0,0 +1,526 @@ +/* + * 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.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.query.BitemporalQuery; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class FullReplayOracleTemporalStateTest { + + private static final FactKey KEY = new FactKey( + "person:alice", + "city", + "profile"); + + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + private final BitemporalQuery query = new BitemporalQuery(); + + @Test + public void testPartialRetractionCreatesTombstoneAndResidues() { + NormalizedMemoryEvent add = add( + "event-add", + KEY, + "Beijing", + TimeInterval.unboundedFrom( + time("2024-01-01T00:00:00Z")), + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent retract = retract( + "event-retract", + KEY, + interval( + "2024-04-01T00:00:00Z", + "2024-09-01T00:00:00Z"), + "2024-06-01T00:00:00Z", + addedVersionId(add)); + + TemporalState state = oracle.replayNormalized( + Arrays.asList(retract, add)); + + Assertions.assertEquals(4, state.getVersions().size()); + Assertions.assertEquals( + state.getVersions(), + state.getVersionsByFactKey().get(KEY)); + + MemoryFactVersion old = version( + state, + addedVersionId(add)); + Assertions.assertEquals( + MemoryFactVersionStatus.ACTIVE, + old.getStatus()); + Assertions.assertEquals( + interval( + "2024-03-01T00:00:00Z", + "2024-06-01T00:00:00Z"), + old.getTransactionTime()); + + MemoryFactVersion tombstone = generatedVersion( + state, retract, MemoryFactVersionStatus.RETRACTED, + retract.getValidTime()); + Assertions.assertEquals( + MemoryFactVersionStatus.RETRACTED, + tombstone.getStatus()); + Assertions.assertEquals( + retract.getValidTime(), + tombstone.getValidTime()); + Assertions.assertEquals( + TimeInterval.unboundedFrom(retract.getRecordedAt()), + tombstone.getTransactionTime()); + + MemoryFactVersion left = generatedVersion( + state, retract, MemoryFactVersionStatus.ACTIVE, + interval("2024-01-01T00:00:00Z", "2024-04-01T00:00:00Z")); + MemoryFactVersion right = generatedVersion( + state, retract, MemoryFactVersionStatus.ACTIVE, + TimeInterval.unboundedFrom(time("2024-09-01T00:00:00Z"))); + Assertions.assertEquals( + MemoryFactVersionStatus.ACTIVE, + left.getStatus()); + Assertions.assertEquals( + interval( + "2024-01-01T00:00:00Z", + "2024-04-01T00:00:00Z"), + left.getValidTime()); + Assertions.assertEquals( + MemoryFactVersionStatus.ACTIVE, + right.getStatus()); + Assertions.assertEquals( + TimeInterval.unboundedFrom( + time("2024-09-01T00:00:00Z")), + right.getValidTime()); + + Assertions.assertEquals( + sortedRelations( + relation( + VersionRelationType.SUPERSEDES, + tombstone.getId(), + old.getId()), + relation( + VersionRelationType.SUPERSEDES, + left.getId(), + old.getId()), + relation( + VersionRelationType.SUPERSEDES, + right.getId(), + old.getId())), + state.getRelations()); + Assertions.assertTrue(query.query( + state.getVersions(), + time("2024-05-01T00:00:00Z"), + time("2024-07-01T00:00:00Z")).isEmpty()); + Assertions.assertEquals(1, query.query( + state.getVersions(), + time("2024-02-01T00:00:00Z"), + time("2024-07-01T00:00:00Z")).size()); + } + + @Test + public void testExplicitCorrectionSupersedesSameValue() { + TimeInterval validTime = interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"); + NormalizedMemoryEvent add = add( + "event-add", + KEY, + "Beijing", + validTime, + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent correction = correct( + "event-correct", + KEY, + "Beijing", + validTime, + "2024-06-01T00:00:00Z", + addedVersionId(add)); + + TemporalState state = oracle.replayNormalized( + Arrays.asList(correction, add)); + + Assertions.assertEquals(2, state.getVersions().size()); + Assertions.assertEquals( + Collections.singletonList(relation( + VersionRelationType.SUPERSEDES, + generatedVersion(state, correction, + MemoryFactVersionStatus.ACTIVE, validTime).getId(), + addedVersionId(add))), + state.getRelations()); + } + + @Test + public void testSameValueOverlapCreatesDuplicateVersion() { + TimeInterval validTime = interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"); + NormalizedMemoryEvent first = add( + "event-first", + KEY, + "Beijing", + validTime, + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent duplicate = add( + "event-duplicate", + KEY, + "Beijing", + validTime, + "2024-04-01T00:00:00Z"); + + TemporalState state = oracle.replayNormalized( + Arrays.asList(duplicate, first)); + + MemoryFactVersion old = version( + state, + addedVersionId(first)); + MemoryFactVersion current = version( + state, + addedVersionId(duplicate)); + Assertions.assertEquals( + time("2024-04-01T00:00:00Z"), + old.getTransactionTime().getEnd().get()); + Assertions.assertFalse( + current.getTransactionTime().getEnd().isPresent()); + Assertions.assertEquals( + Arrays.asList( + "evidence-event-duplicate", + "evidence-event-first"), + Arrays.asList( + current.getEvidence().get(0).getId(), + current.getEvidence().get(1).getId())); + Assertions.assertEquals( + Collections.singletonList(relation( + VersionRelationType.DUPLICATE_OF, + current.getId(), + old.getId())), + state.getRelations()); + } + + @Test + public void testDifferentValueOverlapConflictsButBoundaryDoesNot() { + NormalizedMemoryEvent first = add( + "event-first", + KEY, + "Beijing", + interval( + "2024-01-01T00:00:00Z", + "2024-06-01T00:00:00Z"), + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent conflict = add( + "event-conflict", + KEY, + "Shanghai", + interval( + "2024-04-01T00:00:00Z", + "2024-09-01T00:00:00Z"), + "2024-04-01T00:00:00Z"); + NormalizedMemoryEvent boundary = add( + "event-boundary", + KEY, + "Rome", + interval( + "2024-09-01T00:00:00Z", + "2024-12-01T00:00:00Z"), + "2024-05-01T00:00:00Z"); + + TemporalState shuffled = oracle.replayNormalized( + Arrays.asList(boundary, conflict, first)); + TemporalState ordered = oracle.replayNormalized( + Arrays.asList(first, conflict, boundary)); + + Assertions.assertEquals(ordered, shuffled); + Assertions.assertEquals(3, shuffled.getVersions().size()); + for (MemoryFactVersion version : shuffled.getVersions()) { + Assertions.assertEquals( + MemoryFactVersionStatus.ACTIVE, + version.getStatus()); + Assertions.assertFalse( + version.getTransactionTime().getEnd().isPresent()); + } + Assertions.assertEquals( + Collections.singletonList(relation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + addedVersionId(first))), + shuffled.getRelations()); + } + + @Test + public void testPartialChangesAddConflictsForResidualVersions() { + NormalizedMemoryEvent first = add( + "event-first", + KEY, + "Beijing", + interval( + "2024-01-01T00:00:00Z", + "2024-10-01T00:00:00Z"), + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent conflict = add( + "event-conflict", + KEY, + "Shanghai", + interval( + "2024-05-01T00:00:00Z", + "2025-01-01T00:00:00Z"), + "2024-04-01T00:00:00Z"); + NormalizedMemoryEvent retraction = retract( + "event-retract", + KEY, + interval( + "2024-01-01T00:00:00Z", + "2024-05-01T00:00:00Z"), + "2024-06-01T00:00:00Z", + addedVersionId(first)); + NormalizedMemoryEvent correction = correct( + "event-correct", + KEY, + "Rome", + retraction.getValidTime(), + "2024-06-01T00:00:00Z", + addedVersionId(first)); + + TemporalState state = oracle.replayNormalized( + Arrays.asList(retraction, conflict, first)); + + Assertions.assertTrue(state.getRelations().contains(relation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + addedVersionId(first)))); + Assertions.assertTrue(state.getRelations().contains(relation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + generatedVersion(state, retraction, + MemoryFactVersionStatus.ACTIVE, + interval("2024-05-01T00:00:00Z", + "2024-10-01T00:00:00Z")).getId()))); + + TemporalState correctedState = oracle.replayNormalized( + Arrays.asList(correction, conflict, first)); + Assertions.assertTrue(correctedState.getRelations().contains( + relation( + VersionRelationType.CONFLICTS_WITH, + addedVersionId(conflict), + generatedVersion(correctedState, correction, + MemoryFactVersionStatus.ACTIVE, + interval("2024-05-01T00:00:00Z", + "2024-10-01T00:00:00Z")).getId()))); + } + + @Test + public void testLedgerNoopAndEventIdReuse() { + NormalizedMemoryEvent original = add( + "event-1", + KEY, + "Beijing", + interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"), + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent reused = add( + "event-1", + KEY, + "Shanghai", + interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"), + "2024-03-01T00:00:00Z"); + + Assertions.assertEquals( + oracle.replayNormalized( + Collections.singletonList(original)), + oracle.replayNormalized( + Arrays.asList(original, original))); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> oracle.replayNormalized( + Arrays.asList(original, reused))); + } + + @Test + public void testSameRecordedAtDoesNotLeaveZeroLengthVersion() { + TimeInterval validTime = interval( + "2024-01-01T00:00:00Z", + "2025-01-01T00:00:00Z"); + NormalizedMemoryEvent add = add( + "event-a-add", + KEY, + "Beijing", + validTime, + "2024-03-01T00:00:00Z"); + NormalizedMemoryEvent correction = correct( + "event-b-correct", + KEY, + "Shanghai", + validTime, + "2024-03-01T00:00:00Z", + addedVersionId(add)); + + TemporalState state = oracle.replayNormalized( + Arrays.asList(correction, add)); + + Assertions.assertEquals(1, state.getVersions().size()); + Assertions.assertEquals("Shanghai", state.getVersions().get(0) + .getFact().getLiteralValue().get()); + Assertions.assertNotEquals( + addedVersionId(add), state.getVersions().get(0).getId()); + Assertions.assertTrue(state.getRelations().isEmpty()); + } + + private NormalizedMemoryEvent add( + String eventId, + FactKey key, + String value, + TimeInterval validTime, + String recordedAt) { + return normalizer.normalize( + MemoryEvent.add( + eventId, + fact(key, value), + validTime, + time(recordedAt), + evidence(eventId)), + key); + } + + private NormalizedMemoryEvent correct( + String eventId, + FactKey key, + String value, + TimeInterval validTime, + String recordedAt, + String targetVersionId) { + return normalizer.normalize( + MemoryEvent.correct( + eventId, + fact(key, value), + validTime, + time(recordedAt), + evidence(eventId)).targeting(targetVersionId), + key); + } + + private NormalizedMemoryEvent retract( + String eventId, + FactKey key, + TimeInterval validTime, + String recordedAt, + String targetVersionId) { + return normalizer.normalize( + MemoryEvent.retract( + eventId, + factId(key), + validTime, + time(recordedAt), + evidence(eventId)).targeting(targetVersionId), + key); + } + + private static MemoryFact fact( + FactKey key, + String value) { + return MemoryFact.attribute( + factId(key), + new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), + value); + } + + private static String factId(FactKey key) { + return "fact-city-" + key.getScope(); + } + + private static List evidence(String eventId) { + return Collections.singletonList(new Evidence( + "evidence-" + eventId, + new Source("source-1", "registry"), + "Evidence for " + eventId)); + } + + private static VersionRelation relation( + VersionRelationType type, + String from, + String to) { + return new VersionRelation(type, from, to); + } + + private static List sortedRelations( + VersionRelation... relations) { + List ordered = Arrays.asList(relations); + Collections.sort(ordered); + return ordered; + } + + private String addedVersionId(NormalizedMemoryEvent event) { + return oracle.replayNormalized(Collections.singletonList(event)) + .getVersions().get(0).getId(); + } + + private static MemoryFactVersion generatedVersion( + TemporalState state, + NormalizedMemoryEvent event, + MemoryFactVersionStatus status, + TimeInterval validTime) { + return state.getVersions().stream() + .filter(version -> version.getTransactionTime().getStart() + .equals(event.getRecordedAt())) + .filter(version -> version.getStatus() == status) + .filter(version -> version.getValidTime().equals(validTime)) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Missing generated version for " + event.getEventId())); + } + + private static MemoryFactVersion version( + TemporalState state, + String versionId) { + for (MemoryFactVersion version : state.getVersions()) { + if (version.getId().equals(versionId)) { + return version; + } + } + throw new AssertionError("Missing version: " + versionId); + } + + 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); + } +} 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..988f8b3eb --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/FullReplayOracleTest.java @@ -0,0 +1,627 @@ +/* + * 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 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("2024-01-01T00:00:00Z")), + time("2024-06-01T00:00:00Z"), + evidence("event-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 + 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 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( + "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( + IllegalArgumentException.class, + () -> oracle.replay( + 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( + "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 + 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 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 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, + 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, + 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/oracle/TargetedReplayOracleTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/TargetedReplayOracleTest.java new file mode 100644 index 000000000..faba0bbf6 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/oracle/TargetedReplayOracleTest.java @@ -0,0 +1,315 @@ +/* + * 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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.apache.geaflow.ai.temporal.query.BitemporalQuery; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalErrorCode; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.TemporalValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +public class TargetedReplayOracleTest { + + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private static final TimeInterval YEAR = interval("2024-01-01", "2025-01-01"); + private final EventNormalizer normalizer = new EventNormalizer(); + private final FullReplayOracle oracle = new FullReplayOracle(); + + @Test + public void testCorrectionClipsToTargetAndKeepsConflictingValue() { + NormalizedMemoryEvent first = add("first", "Beijing", + interval("2024-01-01", "2024-07-01"), "2024-02-01"); + NormalizedMemoryEvent conflict = add("conflict", "Shanghai", + interval("2024-04-01", "2024-12-01"), "2024-03-01"); + MemoryFactVersion target = addedVersion(first); + MemoryFactVersion untouched = addedVersion(conflict); + NormalizedMemoryEvent change = correct("change", "Rome", + interval("2024-05-01", "2024-09-01"), "2024-06-01", target.getId()); + + TemporalState state = replay(first, conflict, change); + + Assertions.assertTrue(state.getVersions().contains(untouched)); + assertValues(state, "2024-06-15", "Rome", "Shanghai"); + assertValues(state, "2024-08-01", "Shanghai"); + MemoryFactVersion changed = currentValue(state, "Rome"); + Assertions.assertEquals(interval("2024-05-01", "2024-07-01"), + changed.getValidTime()); + Assertions.assertEquals(interval("2024-01-01", "2024-05-01"), + currentValue(state, "Beijing").getValidTime()); + Assertions.assertEquals(2, state.getRelations().stream() + .filter(relation -> relation.getType() == VersionRelationType.SUPERSEDES) + .count()); + Assertions.assertTrue(state.getRelations().stream() + .filter(relation -> relation.getType() == VersionRelationType.SUPERSEDES) + .allMatch(relation -> relation.getToVersionId().equals(target.getId()))); + } + + @Test + public void testRetractionOnlyWithdrawsItsSelectedConflictingVersion() { + NormalizedMemoryEvent first = add("first", "Beijing", YEAR, "2024-02-01"); + NormalizedMemoryEvent conflict = add("conflict", "Shanghai", YEAR, "2024-03-01"); + NormalizedMemoryEvent retract = retract("withdraw", + interval("2024-04-01", "2024-07-01"), "2024-06-01", + addedVersion(conflict).getId()); + + TemporalState state = replay(first, conflict, retract); + + Assertions.assertTrue(state.getVersions().contains(addedVersion(first))); + assertValues(state, "2024-06-15", "Beijing"); + assertValues(state, "2024-08-01", "Beijing", "Shanghai"); + MemoryFactVersion tombstone = state.getVersions().stream() + .filter(version -> version.getStatus() == MemoryFactVersionStatus.RETRACTED) + .findFirst().get(); + Assertions.assertEquals("Shanghai", tombstone.getFact().getLiteralValue().get()); + Assertions.assertEquals(retract.getValidTime(), tombstone.getValidTime()); + } + + @Test + public void testTargetValidationUsesStableCodesAndFactKeyIsolation() { + NormalizedMemoryEvent first = add("first", "Beijing", YEAR, "2024-02-01"); + String targetId = addedVersion(first).getId(); + assertCode(TemporalErrorCode.REJECT_TARGET_REQUIRED, + () -> normalizer.normalize(MemoryEvent.retract("missing-target", "fact-city", + YEAR, time("2024-03-01"), evidence("missing-target")), KEY)); + assertCode(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, + () -> replay(first, retract("unknown", YEAR, "2024-03-01", "unknown-version"))); + NormalizedMemoryEvent otherScope = normalizer.normalize( + MemoryEvent.retract("scope", "fact-city", YEAR, time("2024-03-01"), + evidence("scope")).targeting(targetId), + new FactKey("alice", "city", "account")); + assertCode(TemporalErrorCode.REJECT_TARGET_NOT_FOUND, () -> replay(first, otherScope)); + assertCode(TemporalErrorCode.REJECT_TARGET_NO_OVERLAP, + () -> replay(first, retract("adjacent", interval("2025-01-01", "2026-01-01"), + "2024-03-01", targetId))); + + NormalizedMemoryEvent correction = correct("change", "Rome", YEAR, + "2024-03-01", targetId); + assertCode(TemporalErrorCode.REJECT_TARGET_INACTIVE, + () -> replay(first, correction, retract("closed", YEAR, "2024-04-01", targetId))); + NormalizedMemoryEvent withdrawn = retract("withdraw", YEAR, "2024-03-01", targetId); + String tombstoneId = replay(first, withdrawn).getVersions().stream() + .filter(version -> version.getStatus() == MemoryFactVersionStatus.RETRACTED) + .findFirst().get().getId(); + assertCode(TemporalErrorCode.REJECT_TARGET_INACTIVE, + () -> replay(first, withdrawn, + retract("tombstone", YEAR, "2024-04-01", tombstoneId))); + assertCode(TemporalErrorCode.REJECT_EVENT_ID_REUSE, + () -> replay(first, add("first", "Rome", YEAR, "2024-02-01"))); + } + + @Test + public void testSameTimestampChainDoesNotLeaveDanglingRelations() { + NormalizedMemoryEvent first = add("event-a", "Beijing", YEAR, "2024-02-01"); + NormalizedMemoryEvent conflict = add("event-b", "Shanghai", YEAR, "2024-02-01"); + String firstId = addedVersion(first).getId(); + NormalizedMemoryEvent correction = correct("event-c", "Rome", + interval("2024-04-01", "2024-10-01"), "2024-02-01", firstId); + String changedId = currentValue(replay(first, conflict, correction), "Rome").getId(); + NormalizedMemoryEvent withdrawn = retract("event-d", + interval("2024-05-01", "2024-06-01"), "2024-02-01", changedId); + + TemporalState state = replay(withdrawn, correction, conflict, first); + + Assertions.assertEquals(6, state.getVersions().size()); + Set ids = state.getVersions().stream() + .map(MemoryFactVersion::getId).collect(Collectors.toSet()); + Assertions.assertFalse(ids.contains(firstId)); + Assertions.assertFalse(ids.contains(changedId)); + Assertions.assertTrue(state.getVersions().stream() + .noneMatch(version -> version.getTransactionTime().getEnd().isPresent())); + Assertions.assertTrue(state.getRelations().stream().allMatch(relation -> + ids.contains(relation.getFromVersionId()) && ids.contains(relation.getToVersionId()))); + assertValues(state, "2024-05-15", "Shanghai"); + assertValues(state, "2024-07-01", "Rome", "Shanghai"); + } + + @Test + public void testResidualTargetSurvivesUnrelatedEarlierConflictAndReplayOrder() { + NormalizedMemoryEvent first = add("first", "Beijing", YEAR, "2024-02-01"); + NormalizedMemoryEvent correction = correct("change", "Rome", + interval("2024-06-01", "2024-07-01"), "2024-06-01", + addedVersion(first).getId()); + TemporalState beforeLate = replay(first, correction); + MemoryFactVersion residual = beforeLate.getVersions().stream() + .filter(version -> !version.getTransactionTime().getEnd().isPresent()) + .filter(version -> version.getValidTime().equals( + interval("2024-01-01", "2024-06-01"))) + .findFirst().get(); + NormalizedMemoryEvent earlierConflict = add("late", "Shanghai", + interval("2024-01-01", "2024-03-01"), "2024-04-01"); + NormalizedMemoryEvent retract = retract("withdraw", + interval("2024-02-01", "2024-03-01"), "2024-08-01", residual.getId()); + + TemporalState state = replay(retract, correction, earlierConflict, first); + + Assertions.assertEquals(replay(first, earlierConflict, correction, retract), state); + Assertions.assertTrue(state.getVersions().stream() + .anyMatch(version -> version.getId().equals(residual.getId()))); + assertValues(state, "2024-02-15", "Shanghai"); + assertValues(state, "2024-05-01", "Beijing"); + assertValues(state, "2024-06-15", "Rome"); + } + + @Test + public void testMergedAddIdentityUsesItsExpandedInterval() { + NormalizedMemoryEvent first = add("first", "Beijing", + interval("2024-01-01", "2024-04-01"), "2024-02-01"); + NormalizedMemoryEvent duplicate = add("duplicate", "Beijing", + interval("2024-03-01", "2024-09-01"), "2024-03-01"); + + TemporalState state = replay(duplicate, first); + MemoryFactVersion merged = currentValue(state, "Beijing"); + + Assertions.assertEquals(replay(first, duplicate), state); + Assertions.assertEquals(interval("2024-01-01", "2024-09-01"), + merged.getValidTime()); + Assertions.assertNotEquals(addedVersion(duplicate).getId(), merged.getId()); + NormalizedMemoryEvent retract = retract("withdraw", + interval("2023-01-01", "2026-01-01"), "2024-05-01", merged.getId()); + TemporalState withdrawn = replay(first, duplicate, retract); + MemoryFactVersion tombstone = withdrawn.getVersions().stream() + .filter(version -> version.getStatus() == MemoryFactVersionStatus.RETRACTED) + .findFirst().get(); + Assertions.assertEquals(merged.getValidTime(), tombstone.getValidTime()); + assertValues(withdrawn, "2024-06-15"); + } + + @Test + public void testAddMergesTransitiveSameValueOverlapAfterExplicitCorrection() { + NormalizedMemoryEvent first = add("first", "Beijing", YEAR, "2024-02-01"); + NormalizedMemoryEvent conflict = add("conflict", "Shanghai", + interval("2024-04-01", "2024-07-01"), "2024-03-01"); + NormalizedMemoryEvent correction = correct("change", "Shanghai", + interval("2024-05-01", "2024-08-01"), "2024-04-01", + addedVersion(first).getId()); + NormalizedMemoryEvent duplicate = add("duplicate", "Shanghai", + interval("2024-07-01", "2024-09-01"), "2024-05-01"); + + TemporalState state = replay(first, conflict, correction, duplicate); + + MemoryFactVersion merged = currentValue(state, "Shanghai"); + Assertions.assertEquals(interval("2024-04-01", "2024-09-01"), + merged.getValidTime()); + Assertions.assertEquals(3, merged.getEvidence().size()); + Assertions.assertEquals(1, state.getVersions().stream() + .filter(version -> !version.getTransactionTime().getEnd().isPresent()) + .filter(version -> version.getFact().getLiteralValue().get().equals("Shanghai")) + .count()); + Assertions.assertEquals(2, state.getRelations().stream() + .filter(relation -> relation.getType() == VersionRelationType.DUPLICATE_OF) + .filter(relation -> relation.getFromVersionId().equals(merged.getId())) + .count()); + } + + private TemporalState replay(NormalizedMemoryEvent... events) { + return oracle.replayNormalized(Arrays.asList(events)); + } + + private MemoryFactVersion addedVersion(NormalizedMemoryEvent event) { + return replay(event).getVersions().get(0); + } + + private NormalizedMemoryEvent add( + String id, + String value, + TimeInterval validTime, + String recordedAt) { + return normalizer.normalize(MemoryEvent.add(id, fact(value), validTime, + time(recordedAt), evidence(id)), KEY); + } + + private NormalizedMemoryEvent correct( + String id, + String value, + TimeInterval validTime, + String recordedAt, + String targetId) { + return normalizer.normalize(MemoryEvent.correct(id, fact(value), validTime, + time(recordedAt), evidence(id)).targeting(targetId), KEY); + } + + private NormalizedMemoryEvent retract( + String id, + TimeInterval validTime, + String recordedAt, + String targetId) { + return normalizer.normalize(MemoryEvent.retract(id, "fact-city", validTime, + time(recordedAt), evidence(id)).targeting(targetId), KEY); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-city", new MemoryEntity("alice", "person"), + "city", value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("evidence-" + id, + new Source("registry", "registry"), "Evidence for " + id)); + } + + private static MemoryFactVersion currentValue(TemporalState state, String value) { + return state.getVersions().stream() + .filter(version -> version.getStatus() == MemoryFactVersionStatus.ACTIVE) + .filter(version -> !version.getTransactionTime().getEnd().isPresent()) + .filter(version -> version.getFact().getLiteralValue().get().equals(value)) + .findFirst().get(); + } + + private static void assertValues(TemporalState state, String validAt, String... expected) { + List values = new BitemporalQuery().query(state.getVersions(), + time(validAt), time("2025-01-01")).stream() + .map(version -> version.getFact().getLiteralValue().get()) + .sorted().collect(Collectors.toList()); + Assertions.assertEquals(Arrays.asList(expected), values); + } + + private static void assertCode(TemporalErrorCode code, Executable action) { + Assertions.assertEquals(code, Assertions.assertThrows( + TemporalValidationException.class, action).getCode()); + } + + private static TimeInterval interval(String start, String end) { + return new TimeInterval(time(start), time(end)); + } + + private static Instant time(String date) { + return Instant.parse(date + "T00:00:00Z"); + } +} 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); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/TemporalQueryServiceTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/TemporalQueryServiceTest.java new file mode 100644 index 000000000..c7da0efd2 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/query/TemporalQueryServiceTest.java @@ -0,0 +1,273 @@ +/* + * 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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.MemoryEntity; +import org.apache.geaflow.ai.temporal.model.MemoryFact; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersion; +import org.apache.geaflow.ai.temporal.model.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TemporalQueryServiceTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("person:alice", "city", "profile"); + + private final TemporalQueryService query = new TemporalQueryService(); + + @Test + public void testTwoDimensionalHalfOpenBoundaryMatrixAtNanosecondPrecision() { + MemoryFactVersion version = active("bounded", interval(10, 20), interval(30, 40)); + TemporalState state = state(KEY, version); + long[] validPoints = {9, 10, 11, 19, 20, 21}; + long[] systemPoints = {29, 30, 31, 39, 40, 41}; + + for (long validPoint : validPoints) { + for (long systemPoint : systemPoints) { + boolean visible = validPoint >= 10 && validPoint < 20 + && systemPoint >= 30 && systemPoint < 40; + List expected = visible + ? Collections.singletonList("bounded") : Collections.emptyList(); + List actual = query.asOf( + state, KEY, at(validPoint), at(systemPoint)); + Assertions.assertEquals(expected, ids(actual), + "valid=" + validPoint + ", system=" + systemPoint); + Assertions.assertEquals(actual, + query.asOf(state, at(validPoint), at(systemPoint))); + } + } + } + + @Test + public void testCurrentUsesOpenSystemEndAndExplicitValidTime() { + MemoryFactVersion current = active("current", interval(10, 20), open(30)); + MemoryFactVersion closed = active("closed", interval(10, 20), interval(0, 30)); + MemoryFactVersion tombstone = version( + "tombstone", MemoryFactVersionStatus.RETRACTED, interval(10, 20), open(30)); + TemporalState state = state(KEY, tombstone, current, closed); + + Assertions.assertTrue(query.current(state, KEY, at(9)).isEmpty()); + Assertions.assertEquals(Collections.singletonList("current"), + ids(query.current(state, KEY, at(10)))); + Assertions.assertEquals(Collections.singletonList("current"), + ids(query.current(state, KEY, at(19)))); + Assertions.assertTrue(query.current(state, KEY, at(20)).isEmpty()); + Assertions.assertEquals(query.current(state, KEY, at(10)), + query.current(state, at(10))); + } + + @Test + public void testHistoricalKeepsClosedAndTombstoneWithoutHidingConflict() { + MemoryFactVersion closed = active("closed", open(0), interval(0, 10)); + MemoryFactVersion conflict = active("conflict", open(0), open(0)); + MemoryFactVersion tombstone = version( + "tombstone", MemoryFactVersionStatus.RETRACTED, open(0), open(10)); + TemporalState state = state(KEY, tombstone, conflict, closed); + + Assertions.assertEquals(Arrays.asList("closed", "conflict", "tombstone"), + ids(query.historical(state, KEY, at(5)))); + Assertions.assertEquals(query.historical(state, KEY, at(5)), + query.historical(state, at(5))); + Assertions.assertTrue(query.historical(state, KEY, at(-1)).isEmpty()); + Assertions.assertEquals(Arrays.asList("closed", "conflict"), + ids(query.asOf(state, KEY, at(5), at(9)))); + Assertions.assertEquals(Collections.singletonList("conflict"), + ids(query.asOf(state, KEY, at(5), at(10)))); + Assertions.assertEquals(Collections.singletonList("conflict"), + ids(query.current(state, KEY, at(5)))); + } + + @Test + public void testOpenIntervalsIncludeInstantMax() { + MemoryFactVersion open = active("open", open(0), open(0)); + MemoryFactVersion finite = active("finite", + new TimeInterval(BASE, Instant.MAX), new TimeInterval(BASE, Instant.MAX)); + TemporalState state = state(KEY, finite, open); + + Assertions.assertEquals(Collections.singletonList("open"), + ids(query.asOf(state, KEY, Instant.MAX, Instant.MAX))); + Assertions.assertEquals(Collections.singletonList("open"), + ids(query.current(state, KEY, Instant.MAX))); + Assertions.assertEquals(Collections.singletonList("open"), + ids(query.historical(state, KEY, Instant.MAX))); + } + + @Test + public void testExactFactKeyFilteringIncludesScope() { + FactKey otherScope = new FactKey(KEY.getSubjectId(), KEY.getPredicate(), "work"); + FactKey otherPredicate = new FactKey(KEY.getSubjectId(), "status", KEY.getScope()); + FactKey otherSubject = new FactKey("person:bob", KEY.getPredicate(), KEY.getScope()); + Map> groups = new LinkedHashMap<>(); + groups.put(otherScope, Collections.singletonList(active("work", otherScope))); + groups.put(otherSubject, Collections.singletonList(active("bob", otherSubject))); + groups.put(KEY, Collections.singletonList(active("profile", open(0), open(0)))); + groups.put(otherPredicate, + Collections.singletonList(active("status", otherPredicate))); + TemporalState state = new TemporalState(groups, Collections.emptyList()); + + Assertions.assertEquals(Collections.singletonList("profile"), + ids(query.current(state, new FactKey("person:alice", "city", "profile"), BASE))); + Assertions.assertEquals(Collections.singletonList("profile"), + ids(query.historical(state, KEY, BASE))); + Assertions.assertEquals(Collections.singletonList("work"), + ids(query.asOf(state, otherScope, BASE, BASE))); + Assertions.assertEquals(Arrays.asList("profile", "work", "status", "bob"), + ids(query.current(state, BASE))); + FactKey absent = new FactKey("person:alice", "city", "absent"); + Assertions.assertTrue(query.current(state, absent, BASE).isEmpty()); + Assertions.assertTrue(query.historical(state, absent, BASE).isEmpty()); + Assertions.assertTrue(query.asOf(state, absent, BASE, BASE).isEmpty()); + } + + @Test + public void testStableOrderUsesBothIntervalEndsBeforeVersionId() { + List versions = Arrays.asList( + active("tie-z", open(0), open(0)), + active("a-valid-later", open(1), open(0)), + active("m-system-open", interval(0, 30), open(0)), + active("z-system-long", interval(0, 30), interval(0, 20)), + active("tie-a", open(0), open(0)), + active("n-system-later", interval(0, 30), open(1)), + active("a-system-short", interval(0, 30), interval(0, 10)), + active("z-valid-end", interval(0, 20), open(3))); + TemporalState state = new TemporalState( + Collections.singletonMap(KEY, versions), Collections.emptyList()); + List reversed = new ArrayList<>(versions); + Collections.reverse(reversed); + TemporalState reordered = new TemporalState( + Collections.singletonMap(KEY, reversed), Collections.emptyList()); + + Assertions.assertEquals(Arrays.asList("z-valid-end", "a-system-short", "z-system-long", + "m-system-open", "n-system-later", "tie-a", "tie-z", "a-valid-later"), + ids(query.historical(state, at(15)))); + Assertions.assertEquals(Arrays.asList("z-valid-end", "m-system-open", "n-system-later", + "tie-a", "tie-z", "a-valid-later"), ids(query.current(state, at(15)))); + Assertions.assertEquals(Arrays.asList("z-valid-end", "z-system-long", "m-system-open", + "n-system-later", "tie-a", "tie-z", "a-valid-later"), + ids(query.asOf(state, at(15), at(15)))); + Assertions.assertEquals(query.historical(state, at(15)), + query.historical(reordered, at(15))); + Assertions.assertEquals(query.current(state, at(15)), + query.current(reordered, at(15))); + Assertions.assertEquals(query.asOf(state, at(15), at(15)), + query.asOf(reordered, at(15), at(15))); + } + + @Test + public void testResultsAreImmutableAndInputsUnchanged() { + MemoryFactVersion version = active("active", open(0), open(0)); + TemporalState state = state(KEY, version); + + Assertions.assertThrows(UnsupportedOperationException.class, + () -> query.current(state, KEY, BASE).clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> query.historical(state, KEY, BASE).clear()); + Assertions.assertThrows(UnsupportedOperationException.class, + () -> query.asOf(state, KEY, BASE, BASE).clear()); + Assertions.assertEquals(Collections.singletonList(version), state.getVersions()); + } + + @Test + public void testEmptyStateAndNullArguments() { + TemporalState empty = new TemporalState(Collections.emptyMap(), Collections.emptyList()); + + Assertions.assertTrue(query.current(empty, BASE).isEmpty()); + Assertions.assertTrue(query.historical(empty, BASE).isEmpty()); + Assertions.assertTrue(query.asOf(empty, BASE, BASE).isEmpty()); + Assertions.assertThrows(NullPointerException.class, () -> query.current(null, BASE)); + Assertions.assertThrows(NullPointerException.class, () -> query.historical(null, BASE)); + Assertions.assertThrows(NullPointerException.class, () -> query.asOf(null, BASE, BASE)); + Assertions.assertThrows(NullPointerException.class, () -> query.current(empty, null)); + Assertions.assertThrows(NullPointerException.class, () -> query.historical(empty, null)); + Assertions.assertThrows(NullPointerException.class, () -> query.asOf(empty, null, BASE)); + Assertions.assertThrows(NullPointerException.class, () -> query.asOf(empty, BASE, null)); + Assertions.assertThrows(NullPointerException.class, () -> query.current(empty, null, BASE)); + Assertions.assertThrows(NullPointerException.class, + () -> query.historical(empty, null, BASE)); + Assertions.assertThrows(NullPointerException.class, + () -> query.asOf(empty, null, BASE, BASE)); + } + + private static TemporalState state(FactKey key, MemoryFactVersion... versions) { + return new TemporalState(Collections.singletonMap(key, Arrays.asList(versions)), + Collections.emptyList()); + } + + private static MemoryFactVersion active( + String id, TimeInterval valid, TimeInterval system) { + return version(id, MemoryFactVersionStatus.ACTIVE, valid, system); + } + + private static MemoryFactVersion active(String id, FactKey key) { + return version(id, key, MemoryFactVersionStatus.ACTIVE, open(0), open(0)); + } + + private static MemoryFactVersion version( + String id, + MemoryFactVersionStatus status, + TimeInterval valid, + TimeInterval system) { + return version(id, KEY, status, valid, system); + } + + private static MemoryFactVersion version( + String id, + FactKey key, + MemoryFactVersionStatus status, + TimeInterval valid, + TimeInterval system) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity(key.getSubjectId(), "person"), key.getPredicate(), "value-" + id); + Evidence evidence = new Evidence("evidence-" + id, + new Source("source", "fixture"), "Evidence for " + id); + return new MemoryFactVersion(id, fact, status, valid, system, + Collections.singletonList(evidence)); + } + + private static TimeInterval interval(long start, long end) { + return new TimeInterval(at(start), at(end)); + } + + private static TimeInterval open(long start) { + return TimeInterval.unboundedFrom(at(start)); + } + + private static Instant at(long nanos) { + return BASE.plusNanos(nanos); + } + + private static List ids(List versions) { + return versions.stream().map(MemoryFactVersion::getId).collect(Collectors.toList()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/CanonicalSnapshotTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/CanonicalSnapshotTest.java new file mode 100644 index 000000000..4d9cd873c --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/CanonicalSnapshotTest.java @@ -0,0 +1,351 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Preserves normalized payloads and explicit version-generation references. + * Events use recordedAt/ID order; state keeps FactKey/system-time/valid-time/ID order. + * Version evidence is sorted without dropping repeated entries. + */ +public class CanonicalSnapshotTest { + + private static final FactKey KEY = key("profile"); + private static final Instant FIRST = Instant.parse("2024-03-01T00:00:00Z"); + private static final Instant SECOND = Instant.parse("2024-06-01T00:00:00Z"); + private static final TimeInterval VALID = TimeInterval.unboundedFrom( + Instant.parse("2024-01-01T00:00:00Z")); + + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testCanonicalOrderDoesNotDependOnInputContainers() { + FactKey account = key("account"); + Evidence first = evidence("evidence-a"); + Evidence second = evidence("evidence-b"); + NormalizedMemoryEvent eventA = add("event-a", KEY, FIRST); + NormalizedMemoryEvent eventB = add("event-b", account, FIRST); + NormalizedMemoryEvent eventC = add("event-0-later", KEY, SECOND); + MemoryFactVersion versionA = version( + "opaque-a", FIRST, Arrays.asList(first, first, second)); + MemoryFactVersion versionB = version( + "opaque-b", FIRST, Collections.singletonList(first)); + MemoryFactVersion versionC = version( + "opaque-c", SECOND, Collections.singletonList(second)); + VersionRelation supersedes = new VersionRelation( + VersionRelationType.SUPERSEDES, "opaque-c", "opaque-a"); + VersionRelation duplicate = new VersionRelation( + VersionRelationType.DUPLICATE_OF, "opaque-c", "opaque-a"); + Map> shuffled = new LinkedHashMap<>(); + shuffled.put(KEY, Arrays.asList( + versionC, + version("opaque-a", FIRST, Arrays.asList(second, first, first)))); + shuffled.put(account, Collections.singletonList(versionB)); + Map generating = new LinkedHashMap<>(); + generating.put("opaque-c", "event-0-later"); + generating.put("opaque-b", "event-b"); + generating.put("opaque-a", "event-a"); + CanonicalSnapshot actual = new CanonicalSnapshot( + new TemporalState(shuffled, Arrays.asList(duplicate, supersedes)), + Arrays.asList(eventC, eventB, eventA), + generating); + + Map> ordered = new LinkedHashMap<>(); + ordered.put(account, Collections.singletonList(versionB)); + ordered.put(KEY, Arrays.asList(versionA, versionC)); + CanonicalSnapshot expected = new CanonicalSnapshot( + new TemporalState(ordered, Arrays.asList(supersedes, duplicate)), + Arrays.asList(eventA, eventB, eventC), + generating); + + Assertions.assertEquals(expected, actual); + Assertions.assertEquals(expected.hashCode(), actual.hashCode()); + Assertions.assertEquals( + Arrays.asList(eventA, eventB, eventC), actual.getEvents()); + Assertions.assertEquals( + Arrays.asList(account, KEY), + new ArrayList<>(actual.getState().getVersionsByFactKey().keySet())); + Assertions.assertEquals( + Arrays.asList(versionB, versionA, versionC), + actual.getState().getVersions()); + Assertions.assertEquals( + Arrays.asList("opaque-a", "opaque-b", "opaque-c"), + new ArrayList<>(actual.getGeneratingEventIds().keySet())); + Assertions.assertEquals( + Arrays.asList(supersedes, duplicate), actual.getState().getRelations()); + Assertions.assertEquals( + Arrays.asList(second, first, first), + shuffled.get(KEY).get(1).getEvidence()); + } + + @Test + public void testSnapshotDefensivelyCopiesInputsAndIsDeeplyImmutable() { + NormalizedMemoryEvent event = add("event-a", KEY, FIRST); + MemoryFactVersion version = version( + "opaque-a", FIRST, event.getEvidence()); + List events = new ArrayList<>(); + events.add(event); + Map generating = new LinkedHashMap<>(); + generating.put(version.getId(), event.getEventId()); + CanonicalSnapshot snapshot = new CanonicalSnapshot( + state(KEY, Collections.singletonList(version)), events, generating); + + events.clear(); + generating.clear(); + + Assertions.assertEquals(Collections.singletonList(event), snapshot.getEvents()); + Assertions.assertEquals( + Collections.singletonMap("opaque-a", "event-a"), + snapshot.getGeneratingEventIds()); + Assertions.assertThrows( + UnsupportedOperationException.class, () -> snapshot.getEvents().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getGeneratingEventIds().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getState().getVersionsByFactKey().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getState().getVersionsByFactKey().get(KEY).clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getState().getVersions().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getState().getRelations().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getState().getVersions().get(0).getEvidence().clear()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getEvents().get(0).getEvidence().clear()); + } + + @Test + public void testPreservesTombstoneAndEventsWithoutMaterializedOutput() { + FactKey relationshipKey = new FactKey("person:alice", "lives_in", "profile"); + MemoryFact relationship = MemoryFact.relationship( + "fact-city", new MemoryEntity("person:alice", "person"), + "lives_in", new MemoryEntity("city:beijing", "city")); + Evidence proof = evidence("evidence-original"); + NormalizedMemoryEvent add = normalizer.normalize( + MemoryEvent.add("event-a", relationship, VALID, FIRST, + Arrays.asList(proof, proof)), + relationshipKey); + NormalizedMemoryEvent retract = normalizer.normalize( + MemoryEvent.retract("event-b", "fact-city", VALID, FIRST, + Collections.singletonList(evidence("evidence-retract"))) + .targeting("original-version"), + relationshipKey); + MemoryFactVersion tombstone = new MemoryFactVersion( + "opaque-tombstone", relationship, MemoryFactVersionStatus.RETRACTED, + VALID, TimeInterval.unboundedFrom(FIRST), retract.getEvidence()); + CanonicalSnapshot snapshot = new CanonicalSnapshot( + state(relationshipKey, Collections.singletonList(tombstone)), + Arrays.asList(retract, add), + Collections.singletonMap("opaque-tombstone", "event-b")); + + Assertions.assertEquals(Arrays.asList(add, retract), snapshot.getEvents()); + Assertions.assertEquals("original-version", + snapshot.getEvents().get(1).getTargetVersionId().get()); + Assertions.assertEquals( + Arrays.asList(proof, proof), snapshot.getEvents().get(0).getEvidence()); + Assertions.assertEquals( + add.getPayloadHash(), snapshot.getEvents().get(0).getPayloadHash()); + Assertions.assertEquals( + Collections.singletonList(tombstone), snapshot.getState().getVersions()); + Assertions.assertEquals( + "city:beijing", + snapshot.getState().getVersions().get(0).getFact().getTarget().get().getId()); + Assertions.assertEquals(1, snapshot.getGeneratingEventIds().size()); + Assertions.assertEquals( + "event-b", snapshot.getGeneratingEventIds().get("opaque-tombstone")); + } + + @Test + public void testEmptyPartitionsAreOmittedAndNullInputsAreRejected() { + TemporalState empty = state(KEY, Collections.emptyList()); + CanonicalSnapshot snapshot = new CanonicalSnapshot( + empty, Collections.emptyList(), Collections.emptyMap()); + + Assertions.assertTrue(snapshot.getState().getVersionsByFactKey().isEmpty()); + Assertions.assertTrue(snapshot.getState().getRelations().isEmpty()); + Assertions.assertTrue(snapshot.getEvents().isEmpty()); + Assertions.assertTrue(snapshot.getGeneratingEventIds().isEmpty()); + Assertions.assertThrows(NullPointerException.class, + () -> new CanonicalSnapshot(null, Collections.emptyList(), Collections.emptyMap())); + Assertions.assertThrows(NullPointerException.class, + () -> new CanonicalSnapshot(empty, null, Collections.emptyMap())); + Assertions.assertThrows(NullPointerException.class, + () -> new CanonicalSnapshot(empty, Collections.emptyList(), null)); + Assertions.assertThrows(NullPointerException.class, + () -> new CanonicalSnapshot( + empty, Collections.singletonList(null), Collections.emptyMap())); + } + + @Test + public void testRepeatedEventIsNoopButReusedEventIdIsRejected() { + NormalizedMemoryEvent original = add("event-a", KEY, FIRST); + NormalizedMemoryEvent reused = add("event-a", KEY, SECOND); + TemporalState empty = state(KEY, Collections.emptyList()); + CanonicalSnapshot once = new CanonicalSnapshot( + empty, Collections.singletonList(original), Collections.emptyMap()); + CanonicalSnapshot repeated = new CanonicalSnapshot( + empty, Arrays.asList(original, original), Collections.emptyMap()); + + Assertions.assertEquals(once, repeated); + Assertions.assertEquals(Collections.singletonList(original), repeated.getEvents()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot( + empty, Arrays.asList(original, reused), Collections.emptyMap())); + } + + @Test + public void testRejectsDuplicateVersionIdsAndMismatchedFactKey() { + NormalizedMemoryEvent event = add("event-a", KEY, FIRST); + MemoryFactVersion version = version("opaque-a", FIRST, event.getEvidence()); + List events = Collections.singletonList(event); + Map generating = Collections.singletonMap("opaque-a", "event-a"); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot( + state(KEY, Arrays.asList(version, version)), events, generating)); + + Map> duplicateAcrossKeys = new LinkedHashMap<>(); + duplicateAcrossKeys.put(KEY, Collections.singletonList(version)); + duplicateAcrossKeys.put(key("account"), Collections.singletonList(version)); + IllegalArgumentException duplicateId = Assertions.assertThrows( + IllegalArgumentException.class, + () -> new CanonicalSnapshot( + new TemporalState(duplicateAcrossKeys, Collections.emptyList()), + events, generating)); + Assertions.assertEquals("Duplicate version id: opaque-a", duplicateId.getMessage()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot( + state(new FactKey("person:bob", "city", "profile"), + Collections.singletonList(version)), + events, generating)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot( + state(new FactKey("person:alice", "name", "profile"), + Collections.singletonList(version)), + events, generating)); + } + + @Test + public void testRejectsMissingUnknownOrCrossScopeGenerationReferences() { + NormalizedMemoryEvent event = add("event-a", KEY, FIRST); + MemoryFactVersion version = version("opaque-a", FIRST, event.getEvidence()); + TemporalState state = state(KEY, Collections.singletonList(version)); + List events = Collections.singletonList(event); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot(state, events, Collections.emptyMap())); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot(state, events, + Collections.singletonMap("opaque-a", "event-missing"))); + Map extra = new LinkedHashMap<>(); + extra.put("opaque-a", "event-a"); + extra.put("opaque-missing", "event-a"); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot(state, events, extra)); + NormalizedMemoryEvent otherScope = add("event-account", key("account"), FIRST); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot(state, Arrays.asList(event, otherScope), + Collections.singletonMap("opaque-a", "event-account"))); + Assertions.assertThrows(NullPointerException.class, + () -> new CanonicalSnapshot(state, events, + Collections.singletonMap("opaque-a", null))); + } + + @Test + public void testRejectsEitherDanglingRelationEndpoint() { + NormalizedMemoryEvent event = add("event-a", KEY, FIRST); + MemoryFactVersion version = version("opaque-a", FIRST, event.getEvidence()); + Map> versions = Collections.singletonMap( + KEY, Collections.singletonList(version)); + List events = Collections.singletonList(event); + Map generating = Collections.singletonMap("opaque-a", "event-a"); + VersionRelation missingTarget = new VersionRelation( + VersionRelationType.SUPERSEDES, "opaque-a", "opaque-missing"); + VersionRelation missingSource = new VersionRelation( + VersionRelationType.SUPERSEDES, "opaque-missing", "opaque-a"); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot( + new TemporalState(versions, Collections.singletonList(missingTarget)), + events, generating)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CanonicalSnapshot( + new TemporalState(versions, Collections.singletonList(missingSource)), + events, generating)); + } + + private NormalizedMemoryEvent add(String eventId, FactKey key, Instant recordedAt) { + return normalizer.normalize( + MemoryEvent.add(eventId, fact(), VALID, recordedAt, + Collections.singletonList(evidence("evidence-" + eventId))), + key); + } + + private static MemoryFactVersion version( + String id, Instant recordedAt, List evidence) { + return new MemoryFactVersion( + id, fact(), VALID, TimeInterval.unboundedFrom(recordedAt), evidence); + } + + private static MemoryFact fact() { + return MemoryFact.attribute( + "fact-city", new MemoryEntity("person:alice", "person"), "city", "Beijing"); + } + + private static FactKey key(String scope) { + return new FactKey("person:alice", "city", scope); + } + + private static Evidence evidence(String id) { + return new Evidence(id, new Source("source-registry", "registry"), "Proof " + id); + } + + private static TemporalState state(FactKey key, List versions) { + return new TemporalState(Collections.singletonMap(key, versions), Collections.emptyList()); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/EventLedgerTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/EventLedgerTest.java new file mode 100644 index 000000000..d13f21874 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/EventLedgerTest.java @@ -0,0 +1,148 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.Collections; +import java.util.Optional; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class EventLedgerTest { + + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testCheckDoesNotMutateBeforeCommit() { + EventLedger ledger = new EventLedger(); + NormalizedMemoryEvent original = normalized( + "event-1", + "Beijing"); + NormalizedMemoryEvent reusedBeforeCommit = normalized( + "event-1", + "Shanghai"); + + Assertions.assertEquals( + EventLedgerDecision.ACCEPTED, + ledger.check(original)); + Assertions.assertEquals( + EventLedgerDecision.ACCEPTED, + ledger.check(reusedBeforeCommit)); + Assertions.assertEquals( + Optional.empty(), + ledger.getPayloadHash("event-1")); + + Assertions.assertEquals( + EventLedgerDecision.ACCEPTED, + ledger.commit(original)); + Assertions.assertEquals( + Optional.of(original.getPayloadHash()), + ledger.getPayloadHash("event-1")); + } + + @Test + public void testDuplicateNoopAndRejectedReuseRemainAtomic() { + EventLedger ledger = new EventLedger(); + NormalizedMemoryEvent original = normalized( + "event-1", + "Beijing"); + NormalizedMemoryEvent duplicate = normalized( + "event-1", + "Beijing"); + NormalizedMemoryEvent reused = normalized( + "event-1", + "Shanghai"); + NormalizedMemoryEvent otherId = normalized( + "event-2", + "Beijing"); + + ledger.commit(original); + + Assertions.assertEquals( + EventLedgerDecision.DUPLICATE_NOOP, + ledger.check(duplicate)); + Assertions.assertEquals( + EventLedgerDecision.DUPLICATE_NOOP, + ledger.commit(duplicate)); + Assertions.assertEquals( + EventLedgerDecision.REJECT_EVENT_ID_REUSE, + ledger.check(reused)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> ledger.commit(reused)); + Assertions.assertEquals( + Optional.of(original.getPayloadHash()), + ledger.getPayloadHash("event-1")); + Assertions.assertEquals( + EventLedgerDecision.ACCEPTED, + ledger.check(otherId)); + Assertions.assertEquals( + Optional.empty(), + ledger.getPayloadHash("event-2")); + } + + @Test + public void testRejectNullEvent() { + EventLedger ledger = new EventLedger(); + + Assertions.assertThrows( + NullPointerException.class, + () -> ledger.check(null)); + Assertions.assertThrows( + NullPointerException.class, + () -> ledger.commit(null)); + } + + private NormalizedMemoryEvent normalized( + String eventId, + String value) { + MemoryEvent event = MemoryEvent.add( + eventId, + MemoryFact.attribute( + "fact-location", + new MemoryEntity("person:alice", "person"), + "location", + value), + TimeInterval.unboundedFrom( + time("2024-01-01T00:00:00Z")), + time("2024-03-01T00:00:00Z"), + Collections.singletonList(new Evidence( + "evidence-1", + new Source("source-1", "registry"), + "recorded location"))); + return normalizer.normalize( + event, + new FactKey( + "person:alice", + "location", + "profile")); + } + + private static Instant time(String value) { + return Instant.parse(value); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/EventNormalizerTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/EventNormalizerTest.java new file mode 100644 index 000000000..4b729300c --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/EventNormalizerTest.java @@ -0,0 +1,280 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +import org.apache.geaflow.ai.temporal.model.FactValue; +import org.apache.geaflow.ai.temporal.model.MemoryEntity; +import org.apache.geaflow.ai.temporal.model.MemoryEvent; +import org.apache.geaflow.ai.temporal.model.MemoryEventOperation; +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class EventNormalizerTest { + + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testNormalizeUnicodeTimeEvidenceAndEntityReference() { + String decomposedEventId = "event-e\u0301"; + MemoryFact relationship = MemoryFact.relationship( + "fact-location", + new MemoryEntity("person:cafe\u0301", "pe\u0301rson"), + "li\u0301ves_in", + new MemoryEntity("city:be\u0301ijing", "ci\u0301ty")); + MemoryEvent event = MemoryEvent.add( + decomposedEventId, + relationship, + new TimeInterval( + time("2024-01-01T00:00:00.123456789Z"), + time("2025-01-01T00:00:00.987654321Z")), + time("2024-03-01T00:00:00.456789123Z"), + Arrays.asList( + evidence( + "evidence-b", + "source-b", + "registre\u0301-b", + "second"), + evidence( + "evidence-a", + "source-a", + "registre\u0301-a", + "first"))); + FactKey key = new FactKey( + "person:cafe\u0301", + "li\u0301ves_in", + "pro\u0301file"); + + NormalizedMemoryEvent normalized = + normalizer.normalize(event, key); + + Assertions.assertEquals("event-\u00e9", normalized.getEventId()); + Assertions.assertEquals( + MemoryEventOperation.ADD, + normalized.getOperation()); + Assertions.assertEquals("fact-location", normalized.getFactId()); + Assertions.assertEquals( + new FactKey( + "person:caf\u00e9", + "l\u00edves_in", + "pr\u00f3file"), + normalized.getFactKey()); + Assertions.assertEquals( + Optional.of(FactValue.entityReference("city:b\u00e9ijing")), + normalized.getFactValue()); + Assertions.assertEquals( + time("2024-01-01T00:00:00.123456789Z"), + normalized.getValidTime().getStart()); + Assertions.assertEquals( + Optional.of(time("2025-01-01T00:00:00.987654321Z")), + normalized.getValidTime().getEnd()); + Assertions.assertEquals( + time("2024-03-01T00:00:00.456789123Z"), + normalized.getRecordedAt()); + Assertions.assertEquals( + Arrays.asList("evidence-a", "evidence-b"), + Arrays.asList( + normalized.getEvidence().get(0).getId(), + normalized.getEvidence().get(1).getId())); + Assertions.assertEquals( + "registr\u00e9-a", + normalized.getEvidence().get(0).getSource().getName()); + Assertions.assertTrue( + normalized.getPayloadHash().matches("[0-9a-f]{64}")); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> normalized.getEvidence().clear()); + Assertions.assertEquals(decomposedEventId, event.getId()); + } + + @Test + public void testPayloadHashIsCanonicalAndCoversPayload() { + FactKey key = key("profile"); + Evidence first = evidence( + "evidence-a", + "source-a", + "registry-a", + "first"); + Evidence second = evidence( + "evidence-b", + "source-b", + "registry-b", + "second"); + NormalizedMemoryEvent original = normalizer.normalize( + addEvent( + "event-1", + "Beijing", + "2024-03-01T00:00:00Z", + Arrays.asList(second, first)), + key); + NormalizedMemoryEvent samePayload = normalizer.normalize( + addEvent( + "event-2", + "Beijing", + "2024-03-01T00:00:00Z", + Arrays.asList(first, second)), + key); + NormalizedMemoryEvent differentValue = normalizer.normalize( + addEvent( + "event-1", + "Shanghai", + "2024-03-01T00:00:00Z", + Arrays.asList(first, second)), + key); + NormalizedMemoryEvent differentRecordedAt = normalizer.normalize( + addEvent( + "event-1", + "Beijing", + "2024-04-01T00:00:00Z", + Arrays.asList(first, second)), + key); + NormalizedMemoryEvent differentRecordedAtNanos = normalizer.normalize( + addEvent( + "event-1", + "Beijing", + "2024-03-01T00:00:00.000000001Z", + Arrays.asList(first, second)), + key); + NormalizedMemoryEvent differentScope = normalizer.normalize( + addEvent( + "event-1", + "Beijing", + "2024-03-01T00:00:00Z", + Arrays.asList(first, second)), + key("account")); + + Assertions.assertEquals( + Optional.of(FactValue.literal("Beijing")), + original.getFactValue()); + Assertions.assertEquals( + original.getPayloadHash(), + samePayload.getPayloadHash()); + Assertions.assertNotEquals( + original.getPayloadHash(), + differentValue.getPayloadHash()); + Assertions.assertNotEquals( + original.getPayloadHash(), + differentRecordedAt.getPayloadHash()); + Assertions.assertNotEquals( + original.getPayloadHash(), + differentRecordedAtNanos.getPayloadHash()); + Assertions.assertNotEquals( + original.getPayloadHash(), + differentScope.getPayloadHash()); + } + + @Test + public void testNormalizeRetractAndRejectMismatchedKey() { + FactKey key = key("profile"); + MemoryEvent retract = MemoryEvent.retract( + "event-retract", + "fact-location", + TimeInterval.unboundedFrom( + time("2024-01-01T00:00:00.123456789Z")), + time("2024-06-01T00:00:00.987654321Z"), + Collections.singletonList(evidence( + "evidence-a", + "source-a", + "registry-a", + "withdrawn"))).targeting("target-location"); + + NormalizedMemoryEvent normalized = + normalizer.normalize(retract, key); + + Assertions.assertEquals(key, normalized.getFactKey()); + Assertions.assertEquals( + Optional.empty(), + normalized.getFactValue()); + Assertions.assertEquals( + time("2024-06-01T00:00:00.987654321Z"), + normalized.getRecordedAt()); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> normalizer.normalize( + addEvent( + "event-add", + "Beijing", + "2024-03-01T00:00:00Z", + Collections.singletonList(evidence( + "evidence-a", + "source-a", + "registry-a", + "first"))), + new FactKey( + "person:bob", + "location", + "profile"))); + Assertions.assertThrows( + NullPointerException.class, + () -> normalizer.normalize(null, key)); + Assertions.assertThrows( + NullPointerException.class, + () -> normalizer.normalize(retract, null)); + } + + private static MemoryEvent addEvent( + String eventId, + String value, + String recordedAt, + List evidence) { + return MemoryEvent.add( + eventId, + MemoryFact.attribute( + "fact-location", + new MemoryEntity("person:alice", "person"), + "location", + value), + TimeInterval.unboundedFrom( + time("2024-01-01T00:00:00Z")), + time(recordedAt), + evidence); + } + + private static FactKey key(String scope) { + return new FactKey( + "person:alice", + "location", + scope); + } + + private static Evidence evidence( + String evidenceId, + String sourceId, + String sourceName, + String content) { + return new Evidence( + evidenceId, + new Source(sourceId, sourceName), + content); + } + + private static Instant time(String value) { + return Instant.parse(value); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/SnapshotComparatorTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/SnapshotComparatorTest.java new file mode 100644 index 000000000..42cf844e0 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/SnapshotComparatorTest.java @@ -0,0 +1,259 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.model.VersionRelation; +import org.apache.geaflow.ai.temporal.model.VersionRelationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Compares events, versions, generators and relations in that order. + * Paths align events and versions by ID; times use ISO-8601 or "infinity". + * Context comes from the expected side, or the present side for a missing record. + * Generator differences name the expected event; relation differences use the + * expected from-version's FactKey and generating event. + */ +public class SnapshotComparatorTest { + + private static final FactKey KEY = new FactKey("person:alice", "location", "profile"); + private static final List EVIDENCE = Collections.singletonList( + new Evidence("evidence-a", new Source("source-a", "registry"), "recorded location")); + + private final SnapshotComparator comparator = new SnapshotComparator(); + + @Test + public void testEquivalentSnapshotsHaveNoDifferenceOrContext() { + CanonicalSnapshot empty = snapshot(Collections.emptyList(), Collections.emptyList(), + Collections.emptyMap(), Collections.emptyList()); + assertEquivalent(comparator.compare(empty, empty)); + + MemoryFactVersion first = version("version-a"); + MemoryFactVersion second = version("version-b"); + NormalizedMemoryEvent firstEvent = event("event-a", "profile"); + NormalizedMemoryEvent secondEvent = event("event-b", "profile"); + CanonicalSnapshot expected = snapshot(Arrays.asList(first, second), + Arrays.asList(firstEvent, secondEvent), generatingIds(), Collections.emptyList()); + Map reversedIds = new LinkedHashMap<>(); + reversedIds.put("version-b", "event-b"); + reversedIds.put("version-a", "event-a"); + CanonicalSnapshot actual = snapshot(Arrays.asList(second, first), + Arrays.asList(secondEvent, firstEvent), reversedIds, Collections.emptyList()); + + assertEquivalent(comparator.compare(expected, actual)); + } + + @Test + public void testStatusPrecedesTransactionEndAndReportsVersionContext() { + CanonicalSnapshot expected = singleVersion(version("version-a")); + CanonicalSnapshot actual = singleVersion(version("version-a", "Paris", + MemoryFactVersionStatus.RETRACTED, Instant.ofEpochMilli(5000))); + + DiffReport difference = comparator.compare(expected, actual); + assertDifference(difference, "versions[version-a].status", + "ACTIVE", "RETRACTED", KEY, "event-a"); + assertEquivalent(comparator.compare(expected, expected)); + assertDifference(difference, "versions[version-a].status", + "ACTIVE", "RETRACTED", KEY, "event-a"); + } + + @Test + public void testTransactionEndUsesExactInstantAndExplicitInfinity() { + CanonicalSnapshot expected = singleVersion(version("version-a")); + CanonicalSnapshot actual = singleVersion(version("version-a", "Paris", + MemoryFactVersionStatus.ACTIVE, Instant.ofEpochMilli(5000))); + + assertDifference(comparator.compare(expected, actual), + "versions[version-a].transactionTime.end", "infinity", + "1970-01-01T00:00:05Z", KEY, "event-a"); + } + + @Test + public void testTransactionEndPreservesNanosecondPrecision() { + Instant expectedEnd = Instant.ofEpochSecond(5, 100); + Instant actualEnd = Instant.ofEpochSecond(5, 200); + CanonicalSnapshot expected = singleVersion(version("version-a", "Paris", + MemoryFactVersionStatus.ACTIVE, expectedEnd)); + CanonicalSnapshot actual = singleVersion(version("version-a", "Paris", + MemoryFactVersionStatus.ACTIVE, actualEnd)); + + assertDifference(comparator.compare(expected, actual), + "versions[version-a].transactionTime.end", + expectedEnd.toString(), actualEnd.toString(), KEY, "event-a"); + } + + @Test + public void testReportsOutputFactValueEvenWhenInputEventsMatch() { + CanonicalSnapshot expected = singleVersion(version("version-a")); + CanonicalSnapshot actual = singleVersion(version("version-a", "Rome", + MemoryFactVersionStatus.ACTIVE, null)); + + assertDifference(comparator.compare(expected, actual), + "versions[version-a].fact.literalValue", "Paris", "Rome", KEY, "event-a"); + } + + @Test + public void testEventOnlyScopeDifferencePrecedesPayloadHash() { + NormalizedMemoryEvent expectedEvent = event("event-a", "profile"); + NormalizedMemoryEvent actualEvent = event("event-a", "private"); + Assertions.assertNotEquals(expectedEvent.getPayloadHash(), actualEvent.getPayloadHash()); + CanonicalSnapshot expected = snapshot(Collections.emptyList(), + Collections.singletonList(expectedEvent), Collections.emptyMap(), + Collections.emptyList()); + CanonicalSnapshot actual = snapshot(Collections.emptyList(), + Collections.singletonList(actualEvent), Collections.emptyMap(), + Collections.emptyList()); + + assertDifference(comparator.compare(expected, actual), "events[event-a].factKey.scope", + "profile", "private", KEY, "event-a"); + } + + @Test + public void testMissingVersionAlignsByIdAndUsesPresentSideContext() { + MemoryFactVersion first = version("version-a"); + MemoryFactVersion second = version("version-b"); + List events = Arrays.asList(event("event-a", "profile"), + event("event-b", "profile")); + CanonicalSnapshot expected = snapshot(Arrays.asList(second, first), events, + generatingIds(), Collections.emptyList()); + CanonicalSnapshot actual = snapshot(Collections.singletonList(second), events, + Collections.singletonMap("version-b", "event-b"), Collections.emptyList()); + + assertDifference(comparator.compare(expected, actual), "versions[version-a]", + "present", null, KEY, "event-a"); + assertDifference(comparator.compare(actual, expected), "versions[version-a]", + null, "present", KEY, "event-a"); + } + + @Test + public void testGeneratingEventDifferenceUsesExpectedEventContext() { + List versions = Collections.singletonList(version("version-a")); + List events = Arrays.asList(event("event-a", "profile"), + event("event-b", "profile")); + CanonicalSnapshot expected = snapshot(versions, events, + Collections.singletonMap("version-a", "event-a"), Collections.emptyList()); + CanonicalSnapshot actual = snapshot(versions, events, + Collections.singletonMap("version-a", "event-b"), Collections.emptyList()); + + assertDifference(comparator.compare(expected, actual), "generatingEventIds[version-a]", + "event-a", "event-b", KEY, "event-a"); + } + + @Test + public void testRelationTypeDifferenceReportsFromVersionContext() { + List versions = Arrays.asList(version("version-a"), + version("version-b")); + List events = Arrays.asList(event("event-a", "profile"), + event("event-b", "profile")); + CanonicalSnapshot expected = snapshot(versions, events, generatingIds(), + Collections.singletonList(new VersionRelation(VersionRelationType.SUPERSEDES, + "version-b", "version-a"))); + CanonicalSnapshot actual = snapshot(versions, events, generatingIds(), + Collections.singletonList(new VersionRelation(VersionRelationType.DUPLICATE_OF, + "version-b", "version-a"))); + + assertDifference(comparator.compare(expected, actual), "relations[0].type", + "SUPERSEDES", "DUPLICATE_OF", KEY, "event-b"); + } + + private static void assertEquivalent(DiffReport report) { + Assertions.assertTrue(report.isEquivalent()); + Assertions.assertEquals(Optional.empty(), report.getFieldPath()); + Assertions.assertEquals(Optional.empty(), report.getExpectedValue()); + Assertions.assertEquals(Optional.empty(), report.getActualValue()); + Assertions.assertEquals(Optional.empty(), report.getFactKey()); + Assertions.assertEquals(Optional.empty(), report.getEventId()); + } + + private static void assertDifference(DiffReport report, String path, String expected, + String actual, FactKey key, String eventId) { + Assertions.assertFalse(report.isEquivalent()); + Assertions.assertEquals(Optional.of(path), report.getFieldPath()); + Assertions.assertEquals(Optional.ofNullable(expected), report.getExpectedValue()); + Assertions.assertEquals(Optional.ofNullable(actual), report.getActualValue()); + Assertions.assertEquals(Optional.of(key), report.getFactKey()); + Assertions.assertEquals(Optional.of(eventId), report.getEventId()); + } + + private static CanonicalSnapshot singleVersion(MemoryFactVersion version) { + return snapshot(Collections.singletonList(version), + Collections.singletonList(event("event-a", "profile")), + Collections.singletonMap(version.getId(), "event-a"), Collections.emptyList()); + } + + private static CanonicalSnapshot snapshot(List versions, + List events, + Map generatingEventIds, + List relations) { + Map> byKey = versions.isEmpty() + ? Collections.emptyMap() : Collections.singletonMap(KEY, versions); + return new CanonicalSnapshot(new TemporalState(byKey, relations), events, + generatingEventIds); + } + + private static Map generatingIds() { + Map ids = new LinkedHashMap<>(); + ids.put("version-a", "event-a"); + ids.put("version-b", "event-b"); + return ids; + } + + private static NormalizedMemoryEvent event(String eventId, String scope) { + MemoryEvent event = MemoryEvent.add(eventId, fact("Paris"), validTime(), + Instant.ofEpochMilli(2000), EVIDENCE); + return new EventNormalizer().normalize(event, + new FactKey(KEY.getSubjectId(), KEY.getPredicate(), scope)); + } + + private static MemoryFactVersion version(String versionId) { + return version(versionId, "Paris", MemoryFactVersionStatus.ACTIVE, null); + } + + private static MemoryFactVersion version(String versionId, String value, + MemoryFactVersionStatus status, Instant end) { + return new MemoryFactVersion(versionId, fact(value), status, validTime(), + new TimeInterval(Instant.ofEpochMilli(2000), end), EVIDENCE); + } + + private static MemoryFact fact(String value) { + return MemoryFact.attribute("fact-location", new MemoryEntity("person:alice", "person"), + "location", value); + } + + private static TimeInterval validTime() { + return new TimeInterval(Instant.ofEpochMilli(1000), Instant.ofEpochMilli(9000)); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/TargetNormalizationTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/TargetNormalizationTest.java new file mode 100644 index 000000000..f92e41e5f --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/semantics/TargetNormalizationTest.java @@ -0,0 +1,110 @@ +/* + * 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.semantics; + +import java.time.Instant; +import java.util.Collections; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +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 TargetNormalizationTest { + + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private static final MemoryFact FACT = MemoryFact.attribute( + "fact", new MemoryEntity("alice", "person"), "city", "Beijing"); + private static final Instant RECORDED = Instant.parse("2024-03-01T00:00:00.123456789Z"); + private static final TimeInterval VALID = TimeInterval.unboundedFrom( + Instant.parse("2024-01-01T00:00:00.000000001Z")); + private final EventNormalizer normalizer = new EventNormalizer(); + + @Test + public void testNormalizedChangeRequiresExplicitTargetButRawFactoriesRemainCompatible() { + MemoryEvent correct = correction(); + MemoryEvent retract = MemoryEvent.retract("retract", "fact", VALID, RECORDED, + Collections.singletonList(proof())); + for (MemoryEvent event : new MemoryEvent[] {correct, retract}) { + Assertions.assertFalse(event.getTargetVersionId().isPresent()); + TemporalValidationException failure = Assertions.assertThrows( + TemporalValidationException.class, () -> normalizer.normalize(event, KEY)); + Assertions.assertEquals(TemporalErrorCode.REJECT_TARGET_REQUIRED, failure.getCode()); + } + } + + @Test + public void testTargetIsImmutableCanonicalAndPartOfPayloadIdentity() { + MemoryEvent original = correction(); + MemoryEvent targeted = original.targeting("target-e\u0301"); + NormalizedMemoryEvent first = normalizer.normalize(targeted, KEY); + NormalizedMemoryEvent equivalent = normalizer.normalize( + original.targeting("target-\u00e9"), KEY); + NormalizedMemoryEvent different = normalizer.normalize( + original.targeting("other-target"), KEY); + Assertions.assertFalse(original.getTargetVersionId().isPresent()); + Assertions.assertNotEquals(original, targeted); + Assertions.assertEquals(first, equivalent); + Assertions.assertEquals("target-\u00e9", first.getTargetVersionId().get()); + Assertions.assertNotEquals(first.getPayloadHash(), different.getPayloadHash()); + Assertions.assertEquals(RECORDED, first.getRecordedAt()); + Assertions.assertEquals(VALID, first.getValidTime()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> original.targeting(" ")); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MemoryEvent.add("add", FACT, VALID, RECORDED, + Collections.singletonList(proof())).targeting("target")); + } + + @Test + public void testSemanticVersionIdentityCannotRebindToDifferentResidual() { + NormalizedMemoryEvent event = normalizer.normalize(correction().targeting("source"), KEY); + String identity = VersionIdentity.create(event, FACT, + MemoryFactVersionStatus.ACTIVE, VALID, "source"); + Assertions.assertEquals(identity, VersionIdentity.create(event, FACT, + MemoryFactVersionStatus.ACTIVE, VALID, "source")); + Assertions.assertNotEquals(identity, VersionIdentity.create(event, FACT, + MemoryFactVersionStatus.ACTIVE, VALID, "another-source")); + Assertions.assertNotEquals(identity, VersionIdentity.create(event, FACT, + MemoryFactVersionStatus.RETRACTED, VALID, "source")); + Assertions.assertNotEquals(identity, VersionIdentity.create(event, FACT, + MemoryFactVersionStatus.ACTIVE, TimeInterval.unboundedFrom( + VALID.getStart().plusNanos(1)), "source")); + MemoryFact other = MemoryFact.attribute( + "fact", new MemoryEntity("alice", "person"), "city", "Shanghai"); + Assertions.assertNotEquals(identity, VersionIdentity.create(event, other, + MemoryFactVersionStatus.ACTIVE, VALID, "source")); + Assertions.assertTrue(identity.startsWith("correct:version:v2:")); + } + + private static MemoryEvent correction() { + return MemoryEvent.correct("correct", FACT, VALID, RECORDED, + Collections.singletonList(proof())); + } + + private static Evidence proof() { + return new Evidence("proof", new Source("registry", "Registry"), "source text"); + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/trace/CandidateDecisionTraceTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/trace/CandidateDecisionTraceTest.java new file mode 100644 index 000000000..210c5379d --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/trace/CandidateDecisionTraceTest.java @@ -0,0 +1,263 @@ +/* + * 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.trace; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.apache.geaflow.ai.temporal.candidate.RetrievalProfile; +import org.apache.geaflow.ai.temporal.integration.IncrementalTemporalIntegrator; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.TemporalState; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CandidateDecisionTraceTest { + + private static final Instant BASE = Instant.parse("2024-01-01T00:00:00Z"); + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private static final String EVIDENCE = "climate temperature increases across the planet each year"; + private static final List PROFILES = Arrays.asList(RetrievalProfile.RULE_ONLY, + RetrievalProfile.RULE_BM25, RetrievalProfile.RULE_MINHASH, RetrievalProfile.RULE_ALL); + + @Test + public void testStrategiesKeepSemanticDeltasAndSoftCandidatesCannotCreateRelations() { + final NormalizedMemoryEvent first = assertion("first", KEY, "Paris", 0, 100, 1); + final NormalizedMemoryEvent soft = assertion("soft", new FactKey("bob", "city", "profile"), + "Paris", 0, 100, 2); + final NormalizedMemoryEvent conflict = assertion("conflict", KEY, "Berlin", 0, 100, 3); + final List events = Arrays.asList(first, soft, conflict); + final JsonObject legacy = TraceBuilder.replay(events).get("conflict"); + final String softId = versionId(soft); + final String firstId = versionId(first); + JsonObject ruleScores = null; + for (RetrievalProfile profile : PROFILES) { + Map traces = TraceBuilder.replay(events, profile, 1); + Assertions.assertTrue(values(traces.get("first"), "candidateVersionIds").isEmpty()); + Assertions.assertTrue(values(traces.get("first"), "softCandidateVersionIds").isEmpty()); + Assertions.assertTrue(traces.get("first").getAsJsonObject("candidateScores") + .entrySet().isEmpty()); + JsonObject trace = traces.get("conflict"); + assertDeltasEqual(legacy, trace); + Assertions.assertEquals(Collections.singletonList(firstId), values(trace, + "candidateVersionIds")); + Assertions.assertEquals(Collections.singletonList(firstId), values(trace, + "readVersionIds")); + Assertions.assertEquals("COMPLETED", trace.get("retrievalStatus").getAsString()); + Assertions.assertEquals(profile.name(), trace.get("retrievalProfile").getAsString()); + Assertions.assertEquals("COMMITTED", trace.getAsJsonObject("decision") + .get("status").getAsString()); + JsonObject scores = trace.getAsJsonObject("candidateScores"); + Assertions.assertTrue(scores.getAsJsonObject(firstId).get("mandatory").getAsBoolean()); + if (profile.usesBm25()) { + Assertions.assertEquals(Collections.singletonList(softId), values(trace, + "softCandidateVersionIds")); + Assertions.assertFalse(scores.getAsJsonObject(softId).get("mandatory") + .getAsBoolean()); + Assertions.assertFalse(values(trace, "readVersionIds").contains(softId)); + Assertions.assertFalse(trace.get("addedRelations").toString().contains(softId)); + Assertions.assertTrue(scores.getAsJsonObject(firstId).getAsJsonObject("scores") + .has("BM25")); + } + if (profile == RetrievalProfile.RULE_ONLY) { + ruleScores = scores; + Assertions.assertTrue(values(trace, "softCandidateVersionIds").isEmpty()); + } else if (profile.usesBm25()) { + Assertions.assertNotEquals(ruleScores, scores); + } + } + Assertions.assertEquals("NOT_APPLICABLE_WEEK6", legacy.get("retrievalStatus").getAsString()); + Assertions.assertTrue(legacy.get("candidateScores").isJsonNull()); + Assertions.assertFalse(legacy.has("decision")); + Assertions.assertFalse(legacy.has("softCandidateVersionIds")); + } + + @Test + public void testMergedEffectiveSpanKeepsMoreThanTopKMandatoryCandidates() { + final List events = new ArrayList<>(); + events.add(assertion("paris", KEY, "Paris", 0, 100, 1)); + for (int i = 0; i < 25; i++) { + events.add(assertion("conflict-" + i, KEY, "City-" + i, 0, 80, i + 2)); + } + events.add(assertion("supplement", KEY, "Paris", 90, 95, 30)); + JsonObject trace = TraceBuilder.replay(events, RetrievalProfile.RULE_ALL, 1) + .get("supplement"); + Assertions.assertEquals(26, trace.getAsJsonArray("candidateVersionIds").size()); + Assertions.assertEquals(26, trace.getAsJsonArray("readVersionIds").size()); + Assertions.assertEquals(26, trace.getAsJsonObject("candidateScores").entrySet().size()); + Assertions.assertEquals(BASE.toString(), trace.getAsJsonObject("candidateEffectiveInterval") + .get("validFrom").getAsString()); + Assertions.assertEquals(BASE.plusSeconds(100).toString(), + trace.getAsJsonObject("candidateEffectiveInterval").get("validTo").getAsString()); + Assertions.assertTrue(values(trace, "rules").contains("MERGE_EQUAL_VALUE")); + Assertions.assertTrue(values(trace, "rules").contains("COEXIST_CONFLICT")); + } + + @Test + public void testCorrectionUsesTargetIntersectionAndOnlyTargetIsAuthoritativeRead() { + final NormalizedMemoryEvent first = assertion("first", KEY, "Paris", 0, 100, 1); + final NormalizedMemoryEvent conflict = assertion("conflict", KEY, "Berlin", 0, 100, 2); + final String target = versionId(first); + final MemoryFact corrected = fact("corrected", KEY, "London"); + final NormalizedMemoryEvent correction = new EventNormalizer().normalize( + MemoryEvent.correct("correction", corrected, interval(50, 120), BASE.plusSeconds(3), + evidence("correction")).targeting(target), KEY); + final List events = Arrays.asList(first, conflict, correction); + final JsonObject trace = TraceBuilder.replay(events, RetrievalProfile.RULE_BM25, 1) + .get("correction"); + Assertions.assertEquals(2, trace.getAsJsonArray("candidateVersionIds").size()); + Assertions.assertEquals(Collections.singletonList(target), values(trace, "readVersionIds")); + Assertions.assertEquals(Collections.singletonList(target), values(trace, "closedVersionIds")); + Assertions.assertEquals(2, trace.getAsJsonArray("generatedVersionIds").size()); + Assertions.assertEquals(Collections.singletonList("CORRECT_TARGET"), values(trace, "rules")); + Assertions.assertEquals(BASE.plusSeconds(100).toString(), + trace.getAsJsonObject("candidateEffectiveInterval").get("validTo").getAsString()); + Assertions.assertEquals(target, trace.getAsJsonObject("decision").get("targetVersionId") + .getAsString()); + Assertions.assertTrue(values(trace, "relationReadVersionIds").contains(target)); + Assertions.assertTrue(values(trace, "relationReadVersionIds").contains(versionId(conflict))); + Assertions.assertEquals("EXISTING_ENDPOINTS_OF_ADDED_RELATIONS", + trace.get("relationReadSource").getAsString()); + IncrementalTemporalIntegrator integrator = new IncrementalTemporalIntegrator(); + integrator.apply(first); + integrator.apply(conflict); + TemporalState before = integrator.stateSnapshot(); + integrator.apply(correction); + TemporalState after = integrator.stateSnapshot(); + Assertions.assertEquals(before.getVersions().size() + 2, after.getVersions().size()); + Assertions.assertEquals(after.getRelations().size() - before.getRelations().size(), + trace.getAsJsonArray("addedRelations").size()); + Assertions.assertEquals(0, trace.getAsJsonArray("removedRelations").size()); + } + + @Test + public void testRetractionReportsTombstoneAndResidualSupersedesWithoutSoftReads() { + final NormalizedMemoryEvent first = assertion("first", KEY, "Paris", 0, 100, 1); + final String target = versionId(first); + final NormalizedMemoryEvent retract = new EventNormalizer().normalize(MemoryEvent.retract( + "retract", first.getFactId(), interval(20, 80), BASE.plusSeconds(2), evidence("retract")) + .targeting(target), KEY); + JsonObject trace = TraceBuilder.replay(Arrays.asList(first, retract), + RetrievalProfile.RULE_ALL, 20).get("retract"); + Assertions.assertEquals(Collections.singletonList(target), values(trace, "readVersionIds")); + Assertions.assertEquals(Collections.singletonList(target), values(trace, "closedVersionIds")); + Assertions.assertEquals(Collections.singletonList("RETRACT_TARGET"), values(trace, "rules")); + Assertions.assertEquals(3, trace.getAsJsonArray("generatedVersionIds").size()); + Assertions.assertEquals(3, trace.getAsJsonArray("addedRelations").size()); + Assertions.assertEquals(Collections.singletonList(target), + values(trace, "relationReadVersionIds")); + Assertions.assertEquals(0, trace.getAsJsonArray("softCandidateVersionIds").size()); + } + + @Test + public void testSameTimestampDiscardedVersionRemainsInDecisionButGeneratedIsOmitted() { + final NormalizedMemoryEvent first = assertion("a-first", KEY, "Paris", 0, 100, 1); + final NormalizedMemoryEvent second = assertion("b-second", KEY, "Paris", 20, 30, 1); + Map traces = TraceBuilder.replay(Arrays.asList(second, first), + RetrievalProfile.RULE_ONLY, 20); + Assertions.assertTrue(values(traces.get("a-first"), "generatedVersionIds").isEmpty()); + Assertions.assertEquals(Collections.singletonList(versionId(first)), + values(traces.get("a-first"), "omittedVersionIds")); + Assertions.assertEquals(Collections.singletonList(versionId(first)), + values(traces.get("b-second"), "discardedVersionIds")); + Assertions.assertEquals(Collections.singletonList(versionId(first)), + values(traces.get("b-second"), "readVersionIds")); + Assertions.assertTrue(traces.get("b-second").getAsJsonArray("addedRelations").size() == 0); + } + + @Test + public void testDuplicateAndRolledBackAttemptsDoNotPublishCandidateDecision() { + final NormalizedMemoryEvent event = assertion("first", KEY, "Paris", 0, 100, 1); + final JsonObject semantic = TraceBuilder.replay(Collections.singletonList(event), + RetrievalProfile.RULE_ALL, 20).get("first"); + for (String code : Arrays.asList("DUPLICATE_NOOP", "REJECT_TARGET_NOT_FOUND")) { + JsonObject trace = TraceBuilder.decision(event, semantic, "attempt", 2, "INPUT", code, + code, null, RetrievalProfile.RULE_ALL, 20).toJson(); + Assertions.assertEquals("DUPLICATE_NOOP".equals(code) + ? "NOT_EVALUATED_DUPLICATE" : "BATCH_NOT_COMMITTED", + trace.get("retrievalStatus").getAsString()); + Assertions.assertTrue(trace.get("decision").isJsonNull()); + Assertions.assertTrue(trace.get("candidateScores").isJsonNull()); + Assertions.assertTrue(values(trace, "candidateVersionIds").isEmpty()); + Assertions.assertTrue(values(trace, "generatedVersionIds").isEmpty()); + } + Assertions.assertThrows(IllegalArgumentException.class, () -> TraceBuilder.replay( + Collections.singletonList(event), RetrievalProfile.ENGLISH, 20)); + Assertions.assertThrows(IllegalArgumentException.class, () -> TraceBuilder.replay( + Collections.singletonList(event), RetrievalProfile.RULE_ALL, 0)); + } + + private static void assertDeltasEqual(JsonObject expected, JsonObject actual) { + for (String field : Arrays.asList("rules", "closedVersionIds", "generatedVersionIds", + "omittedVersionIds", "discardedVersionIds", "addedRelations", "removedRelations")) { + Assertions.assertEquals(expected.get(field), actual.get(field), field); + } + } + + private static NormalizedMemoryEvent assertion(String id, FactKey key, String value, + int from, int to, int recordedAt) { + return new EventNormalizer().normalize(MemoryEvent.add(id, fact(id, key, value), + interval(from, to), BASE.plusSeconds(recordedAt), evidence(id)), key); + } + + private static MemoryFact fact(String id, FactKey key, String value) { + return MemoryFact.attribute("fact-" + id, new MemoryEntity(key.getSubjectId(), "person"), + key.getPredicate(), value); + } + + private static List evidence(String id) { + return Collections.singletonList(new Evidence("proof-" + id, + new Source("source", "Registry"), EVIDENCE)); + } + + private static TimeInterval interval(int from, int to) { + return new TimeInterval(BASE.plusSeconds(from), BASE.plusSeconds(to)); + } + + private static String versionId(NormalizedMemoryEvent event) { + return VersionIdentity.create(event, event.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, event.getValidTime(), null); + } + + private static List values(JsonObject json, String field) { + JsonArray array = json.getAsJsonArray(field); + List result = new ArrayList<>(); + for (JsonElement value : array) { + result.add(value.getAsString()); + } + return result; + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/trace/TraceBuilderTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/trace/TraceBuilderTest.java new file mode 100644 index 000000000..d9fb984ed --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/trace/TraceBuilderTest.java @@ -0,0 +1,126 @@ +/* + * 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.trace; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +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.api.QueryMode; +import org.apache.geaflow.ai.temporal.api.QueryResult; +import org.apache.geaflow.ai.temporal.api.TemporalMemoryFacade; +import org.apache.geaflow.ai.temporal.model.Evidence; +import org.apache.geaflow.ai.temporal.model.FactKey; +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.MemoryFactVersionStatus; +import org.apache.geaflow.ai.temporal.model.Source; +import org.apache.geaflow.ai.temporal.model.TimeInterval; +import org.apache.geaflow.ai.temporal.query.TemporalQueryService; +import org.apache.geaflow.ai.temporal.semantics.EventNormalizer; +import org.apache.geaflow.ai.temporal.semantics.NormalizedMemoryEvent; +import org.apache.geaflow.ai.temporal.semantics.VersionIdentity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Explanations cover the complete merged interval and retain the query's result order. */ +public class TraceBuilderTest { + + private static final FactKey KEY = new FactKey("alice", "city", "profile"); + private static final Instant JANUARY = Instant.parse("2024-01-01T00:00:00Z"); + private static final Instant MARCH = Instant.parse("2024-03-01T00:00:00Z"); + private static final Instant APRIL = Instant.parse("2024-04-01T00:00:00Z"); + private static final Instant MAY = Instant.parse("2024-05-01T00:00:00Z"); + private static final Instant JUNE = Instant.parse("2024-06-01T00:00:00Z"); + private static final Instant OCTOBER = Instant.parse("2024-10-01T00:00:00Z"); + private static final Instant NOVEMBER = Instant.parse("2024-11-01T00:00:00Z"); + private static final Instant DECEMBER = Instant.parse("2024-12-01T00:00:00Z"); + + @Test + public void testMergedSpanIncludesConflictOutsideSupplementalEvidenceInterval() { + final NormalizedMemoryEvent paris = assertion("paris", "Paris", JANUARY, DECEMBER, MARCH); + NormalizedMemoryEvent berlin = assertion("berlin", "Berlin", JANUARY, MARCH, APRIL); + NormalizedMemoryEvent supplemental = assertion("supplemental", "Paris", + OCTOBER, NOVEMBER, MAY); + String berlinVersion = VersionIdentity.create(berlin, berlin.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, berlin.getValidTime(), null); + String parisVersion = VersionIdentity.create(paris, paris.getEvent().getFact().get(), + MemoryFactVersionStatus.ACTIVE, paris.getValidTime(), null); + JsonObject trace = TraceBuilder.replay(Arrays.asList(supplemental, berlin, paris)) + .get(supplemental.getEventId()); + + Assertions.assertTrue(values(trace.getAsJsonArray("candidateVersionIds")).contains(berlinVersion)); + Assertions.assertTrue(values(trace.getAsJsonArray("readVersionIds")).contains(berlinVersion)); + Assertions.assertTrue(values(trace.getAsJsonArray("readVersionIds")).contains(parisVersion)); + Assertions.assertTrue(values(trace.getAsJsonArray("rules")).contains("MERGE_EQUAL_VALUE")); + Assertions.assertTrue(values(trace.getAsJsonArray("rules")).contains("COEXIST_CONFLICT")); + Assertions.assertEquals(Collections.singletonList(parisVersion), + values(trace.getAsJsonArray("closedVersionIds"))); + Assertions.assertTrue(trace.getAsJsonArray("addedRelations").size() >= 2); + } + + @Test + public void testQueryHitIdsFollowQueryIntervalOrderInsteadOfLexicographicIds() { + NormalizedMemoryEvent earlierValid = assertion("z-earlier-valid", "Paris", + JANUARY, DECEMBER, MARCH); + NormalizedMemoryEvent laterValid = assertion("a-later-valid", "Berlin", + APRIL, DECEMBER, MAY); + TemporalMemoryFacade facade = new TemporalMemoryFacade(); + facade.replay(Arrays.asList(laterValid, earlierValid)); + List expected = new TemporalQueryService().current( + facade.getSnapshot().getState(), KEY, JUNE); + final QueryResult result = facade.query(QueryMode.CURRENT, KEY, JUNE, null); + List expectedIds = new ArrayList<>(); + for (MemoryFactVersion version : expected) { + expectedIds.add(version.getId()); + } + + Assertions.assertEquals(2, expected.size()); + Assertions.assertTrue(expectedIds.get(0).startsWith("z-earlier-valid:")); + Assertions.assertTrue(expectedIds.get(1).startsWith("a-later-valid:")); + Assertions.assertEquals(expected, result.getVersions()); + Assertions.assertEquals(expectedIds, + values(result.getTrace().toJson().getAsJsonArray("hitVersionIds"))); + } + + private static NormalizedMemoryEvent assertion(String id, String value, + Instant from, Instant to, Instant recordedAt) { + MemoryFact fact = MemoryFact.attribute("fact-" + id, + new MemoryEntity("alice", "person"), "city", value); + Evidence evidence = new Evidence("proof-" + id, + new Source("source", "Registry"), "Evidence for " + id); + return new EventNormalizer().normalize(MemoryEvent.add(id, fact, + new TimeInterval(from, to), recordedAt, Collections.singletonList(evidence)), KEY); + } + + private static List values(JsonArray array) { + List result = new ArrayList<>(); + for (JsonElement value : array) { + result.add(value.getAsString()); + } + return result; + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaFeasibilityTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaFeasibilityTest.java new file mode 100644 index 000000000..277d19b43 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/temporal/udga/TemporalUdgaFeasibilityTest.java @@ -0,0 +1,230 @@ +/* + * 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.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.apache.geaflow.cluster.system.ClusterMetaStore; +import org.apache.geaflow.common.config.Configuration; +import org.apache.geaflow.common.config.keys.DSLConfigKeys; +import org.apache.geaflow.common.config.keys.ExecutionConfigKeys; +import org.apache.geaflow.dsl.connector.file.FileConstants; +import org.apache.geaflow.dsl.runtime.QueryClient; +import org.apache.geaflow.dsl.runtime.QueryContext; +import org.apache.geaflow.dsl.runtime.engine.GQLPipeLine; +import org.apache.geaflow.dsl.runtime.engine.GQLPipeLine.GQLPipelineHook; +import org.apache.geaflow.env.Environment; +import org.apache.geaflow.env.EnvironmentFactory; +import org.apache.geaflow.file.FileConfigKeys; +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 TemporalUdgaFeasibilityTest { + + private static final String QUERY_RESOURCE = + "/temporal/temporal_udga_feasibility.sql"; + + @TempDir + Path tempDirectory; + + @Test + public void testRowUdgaCarriesStateAcrossTwoDynamicEdgeBatches() + throws Exception { + Path vertices = writeInput( + "vertices.csv", + "1,Alice", + "2,Bob", + "3,Carol"); + Path edges = writeInput( + "edges.csv", + "1,2", + "2,3"); + Path output = tempDirectory.resolve("result"); + Environment environment = null; + + try { + environment = EnvironmentFactory.onLocalEnvironment(); + environment.getEnvironmentContext().withConfig( + localConfiguration()); + GQLPipeLine pipeline = new GQLPipeLine(environment, 0); + pipeline.setPipelineHook(new PathReplacingHook( + vertices, + edges, + output)); + + pipeline.execute(); + + List results = readResults(output); + Set observedBatchCounts = new HashSet<>(); + for (ProbeResult result : results) { + observedBatchCounts.add(result.batchCount); + } + Assertions.assertEquals( + new HashSet<>(Arrays.asList(1, 2)), + observedBatchCounts); + Assertions.assertTrue(results.stream().anyMatch(result -> + result.vertexId == 2L + && result.batchCount == 2 + && result.hadPreviousValue)); + Assertions.assertTrue(results.stream().anyMatch(result -> + result.dynamicEdgeCount > 0)); + Assertions.assertTrue(results.stream().anyMatch(result -> + result.receivedMessageCount > 0)); + } finally { + if (environment != null) { + environment.shutdown(); + } + ClusterMetaStore.close(); + ScheduledWorkerManagerFactory.clear(); + } + } + + private Path writeInput(String fileName, String... lines) + throws IOException { + Path path = tempDirectory.resolve(fileName); + Files.write(path, Arrays.asList(lines), StandardCharsets.UTF_8); + return path; + } + + private Map localConfiguration() { + Map config = new HashMap<>(); + config.put( + DSLConfigKeys.GEAFLOW_DSL_QUERY_PATH.getKey(), + FileConstants.PREFIX_JAVA_RESOURCE + QUERY_RESOURCE); + config.put( + ExecutionConfigKeys.JOB_APP_NAME.getKey(), + "TemporalUdgaFeasibilityTest"); + config.put( + ExecutionConfigKeys.JOB_WORK_PATH.getKey(), + tempDirectory.resolve("work").toString()); + config.put( + FileConfigKeys.ROOT.getKey(), + tempDirectory.resolve("state").toString()); + return config; + } + + private static List readResults(Path output) + throws IOException { + List results = new ArrayList<>(); + try (Stream paths = Files.walk(output)) { + for (Path path : (Iterable) paths + .filter(Files::isRegularFile)::iterator) { + for (String line : Files.readAllLines( + path, + StandardCharsets.UTF_8)) { + if (!line.trim().isEmpty()) { + results.add(ProbeResult.parse(line)); + } + } + } + } + Assertions.assertFalse(results.isEmpty()); + return results; + } + + private static String sqlPath(Path path) { + return path.toAbsolutePath().toString().replace('\\', '/'); + } + + private static final class PathReplacingHook + implements GQLPipelineHook, Serializable { + + private final String vertices; + private final String edges; + private final String output; + + private PathReplacingHook( + Path vertices, + Path edges, + Path output) { + this.vertices = sqlPath(vertices); + this.edges = sqlPath(edges); + this.output = sqlPath(output); + } + + @Override + public String rewriteScript( + String script, + Configuration configuration) { + return script + .replace("${vertices}", vertices) + .replace("${edges}", edges) + .replace("${output}", output); + } + + @Override + public void beforeExecute( + QueryClient queryClient, + QueryContext queryContext) { + } + + @Override + public void afterExecute( + QueryClient queryClient, + QueryContext queryContext) { + } + } + + private static final class ProbeResult { + + private final long vertexId; + private final int batchCount; + private final boolean hadPreviousValue; + private final int dynamicEdgeCount; + private final int receivedMessageCount; + + private ProbeResult( + long vertexId, + int batchCount, + boolean hadPreviousValue, + int dynamicEdgeCount, + int receivedMessageCount) { + this.vertexId = vertexId; + this.batchCount = batchCount; + this.hadPreviousValue = hadPreviousValue; + this.dynamicEdgeCount = dynamicEdgeCount; + this.receivedMessageCount = receivedMessageCount; + } + + private static ProbeResult parse(String line) { + String[] fields = line.split(","); + Assertions.assertEquals(5, fields.length, line); + return new ProbeResult( + Long.parseLong(fields[0]), + Integer.parseInt(fields[1]), + Boolean.parseBoolean(fields[2]), + Integer.parseInt(fields[3]), + Integer.parseInt(fields[4])); + } + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/01-assert-entity-reference.json b/geaflow-ai/src/test/resources/temporal/gold/01-assert-entity-reference.json new file mode 100644 index 000000000..554cfb822 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/01-assert-entity-reference.json @@ -0,0 +1,235 @@ +{ + "schemaVersion": 2, + "name": "01-assert-entity-reference", + "events": [ + { + "alias": "assert-city", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00.000000001Z", + "validTo": null, + "recordedAt": "2024-02-01T00:00:00.000000001Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "assert-relation", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "knows", + "scope": "profile", + "factId": "fact-bob", + "value": { + "kind": "ENTITY_REF", + "value": "bob", + "label": "person" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": null, + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "assert-city", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "assert-relation", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "city", + "event": "assert-city", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00.000000001Z", + "validTo": null, + "systemFrom": "2024-02-01T00:00:00.000000001Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "knows", + "event": "assert-relation", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "knows", + "scope": "profile", + "factId": "fact-bob", + "value": { + "kind": "ENTITY_REF", + "value": "bob", + "label": "person" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": null, + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00.000000001Z", + "versions": [ + "city" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "city" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "city" + ], + "systemAt": "2024-02-01T00:00:00.000000001Z" + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "knows", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "knows" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "knows", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "knows" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "knows", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "knows" + ], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/02-identical-duplicate.json b/geaflow-ai/src/test/resources/temporal/gold/02-identical-duplicate.json new file mode 100644 index 000000000..f65091301 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/02-identical-duplicate.json @@ -0,0 +1,171 @@ +{ + "schemaVersion": 2, + "name": "02-identical-duplicate", + "events": [ + { + "alias": "original", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "duplicate", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "original", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "duplicate", + "code": "DUPLICATE_NOOP" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "original-version", + "event": "original", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "relations": [], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [ + "original-version" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-10T00:00:00Z", + "versions": [] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "original-version" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [], + "systemAt": "2024-01-31T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "original-version" + ], + "systemAt": "2024-02-01T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/03-event-id-reuse.json b/geaflow-ai/src/test/resources/temporal/gold/03-event-id-reuse.json new file mode 100644 index 000000000..b15cd99dc --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/03-event-id-reuse.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 2, + "name": "03-event-id-reuse", + "events": [ + { + "alias": "original", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "reused", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "original", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "expectedFailure": "REJECT_EVENT_ID_REUSE" + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "original-version", + "event": "original", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "relations": [], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "original-version" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "original-version" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "original-version" + ], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-10T00:00:00Z", + "versions": [], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/04-evidence-merge.json b/geaflow-ai/src/test/resources/temporal/gold/04-evidence-merge.json new file mode 100644 index 000000000..bfb46d88f --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/04-evidence-merge.json @@ -0,0 +1,239 @@ +{ + "schemaVersion": 2, + "name": "04-evidence-merge", + "events": [ + { + "alias": "original", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-06T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "supplement", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-04T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "original", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "supplement", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "old", + "event": "original", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-06T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "merged", + "event": "supplement", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + }, + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [ + { + "type": "DUPLICATE_OF", + "from": "merged", + "to": "old" + } + ], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [ + "merged" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-10T00:00:00Z", + "versions": [] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-04T00:00:00Z", + "versions": [ + "old", + "merged" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-04T00:00:00Z", + "versions": [ + "old" + ], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-04T00:00:00Z", + "versions": [ + "merged" + ], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-08T00:00:00Z", + "versions": [], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-08T00:00:00Z", + "versions": [ + "merged" + ], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/05-conflict-coexistence.json b/geaflow-ai/src/test/resources/temporal/gold/05-conflict-coexistence.json new file mode 100644 index 000000000..955c17c2e --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/05-conflict-coexistence.json @@ -0,0 +1,215 @@ +{ + "schemaVersion": 2, + "name": "05-conflict-coexistence", + "events": [ + { + "alias": "paris", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "berlin", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-04T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "paris", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "berlin", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "paris-version", + "event": "paris", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "berlin-version", + "event": "berlin", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "status": "ACTIVE", + "validFrom": "2024-01-04T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [ + { + "type": "CONFLICTS_WITH", + "from": "paris-version", + "to": "berlin-version" + } + ], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-04T00:00:00Z", + "versions": [ + "paris-version", + "berlin-version" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-07T00:00:00Z", + "versions": [ + "paris-version" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "paris-version", + "berlin-version" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "paris-version" + ], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "paris-version", + "berlin-version" + ], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/06-adjacent-intervals.json b/geaflow-ai/src/test/resources/temporal/gold/06-adjacent-intervals.json new file mode 100644 index 000000000..1e80c3e75 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/06-adjacent-intervals.json @@ -0,0 +1,213 @@ +{ + "schemaVersion": 2, + "name": "06-adjacent-intervals", + "events": [ + { + "alias": "left", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-05T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "right", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-05T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "left", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "right", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "left-version", + "event": "left", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-05T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "right-version", + "event": "right", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-05T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-04T00:00:00Z", + "versions": [ + "left-version" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "right-version" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "right-version" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "right-version" + ], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-10T00:00:00Z", + "versions": [], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/07-partial-correction.json b/geaflow-ai/src/test/resources/temporal/gold/07-partial-correction.json new file mode 100644 index 000000000..3f9965216 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/07-partial-correction.json @@ -0,0 +1,379 @@ +{ + "schemaVersion": 2, + "name": "07-partial-correction", + "events": [ + { + "alias": "original", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "conflict", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "validFrom": "2024-01-04T00:00:00Z", + "validTo": "2024-01-06T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + }, + { + "alias": "correct", + "id": "event-3", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-03T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "recordedAt": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-3", + "source": { + "id": "source-3", + "name": "source 3" + }, + "content": "Evidence 3." + } + ], + "target": "old" + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "original", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "conflict", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 3, + "decisions": [ + { + "event": "correct", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "old", + "event": "original", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "conflicting", + "event": "conflict", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "status": "ACTIVE", + "validFrom": "2024-01-04T00:00:00Z", + "validTo": "2024-01-06T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + }, + { + "alias": "left", + "event": "correct", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-03T00:00:00Z", + "systemFrom": "2024-02-03T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "middle", + "event": "correct", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "status": "ACTIVE", + "validFrom": "2024-01-03T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "systemFrom": "2024-02-03T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-3", + "source": { + "id": "source-3", + "name": "source 3" + }, + "content": "Evidence 3." + } + ] + }, + { + "alias": "right", + "event": "correct", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-07T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-03T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "relations": [ + { + "type": "CONFLICTS_WITH", + "from": "old", + "to": "conflicting" + }, + { + "type": "CONFLICTS_WITH", + "from": "middle", + "to": "conflicting" + }, + { + "type": "SUPERSEDES", + "from": "left", + "to": "old" + }, + { + "type": "SUPERSEDES", + "from": "middle", + "to": "old" + }, + { + "type": "SUPERSEDES", + "from": "right", + "to": "old" + } + ], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-03T00:00:00Z", + "versions": [ + "middle" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "middle", + "conflicting" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-07T00:00:00Z", + "versions": [ + "right" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "old", + "middle", + "conflicting" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "old", + "conflicting" + ], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "middle", + "conflicting" + ], + "systemAt": "2024-02-03T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/08-partial-retraction.json b/geaflow-ai/src/test/resources/temporal/gold/08-partial-retraction.json new file mode 100644 index 000000000..a02abc5e8 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/08-partial-retraction.json @@ -0,0 +1,300 @@ +{ + "schemaVersion": 2, + "name": "08-partial-retraction", + "events": [ + { + "alias": "original", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "retract", + "id": "event-2", + "operation": "RETRACT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": null, + "validFrom": "2024-01-03T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ], + "target": "old" + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "original", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "retract", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "old", + "event": "original", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "left", + "event": "retract", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-03T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "tombstone", + "event": "retract", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "RETRACTED", + "validFrom": "2024-01-03T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + }, + { + "alias": "right", + "event": "retract", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-07T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "relations": [ + { + "type": "SUPERSEDES", + "from": "left", + "to": "old" + }, + { + "type": "SUPERSEDES", + "from": "tombstone", + "to": "old" + }, + { + "type": "SUPERSEDES", + "from": "right", + "to": "old" + } + ], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [ + "left" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-03T00:00:00Z", + "versions": [] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-07T00:00:00Z", + "versions": [ + "right" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "old", + "tombstone" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "old" + ], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-07T00:00:00Z", + "versions": [ + "right" + ], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/09-valid-time-late.json b/geaflow-ai/src/test/resources/temporal/gold/09-valid-time-late.json new file mode 100644 index 000000000..83fe99816 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/09-valid-time-late.json @@ -0,0 +1,212 @@ +{ + "schemaVersion": 2, + "name": "09-valid-time-late", + "events": [ + { + "alias": "recent", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-05T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "late-valid", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-04T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "recent", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "late-valid", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "recent-version", + "event": "recent", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-05T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "late-version", + "event": "late-valid", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-04T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [ + "late-version" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-04T00:00:00Z", + "versions": [] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "recent-version" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "late-version" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-02T00:00:00Z", + "versions": [ + "late-version" + ], + "systemAt": "2024-02-02T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/10-system-time-disorder.json b/geaflow-ai/src/test/resources/temporal/gold/10-system-time-disorder.json new file mode 100644 index 000000000..4dee96434 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/10-system-time-disorder.json @@ -0,0 +1,231 @@ +{ + "schemaVersion": 2, + "name": "10-system-time-disorder", + "events": [ + { + "alias": "arrived-later-time", + "id": "event-2", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-04T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + }, + { + "alias": "arrived-earlier-time", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-06T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "arrived-later-time", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 2, + "decisions": [ + { + "event": "arrived-earlier-time", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "early", + "event": "arrived-earlier-time", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-06T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "merged", + "event": "arrived-later-time", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-03T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + }, + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [ + { + "type": "DUPLICATE_OF", + "from": "merged", + "to": "early" + } + ], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [ + "merged" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "early", + "merged" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "early" + ], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "merged" + ], + "systemAt": "2024-02-03T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-08T00:00:00Z", + "versions": [], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-08T00:00:00Z", + "versions": [ + "merged" + ], + "systemAt": "2024-02-03T00:00:00Z" + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/11-same-timestamp.json b/geaflow-ai/src/test/resources/temporal/gold/11-same-timestamp.json new file mode 100644 index 000000000..87f6ba92b --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/11-same-timestamp.json @@ -0,0 +1,199 @@ +{ + "schemaVersion": 2, + "name": "11-same-timestamp", + "events": [ + { + "alias": "second", + "id": "event-2", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ], + "target": "intermediate" + }, + { + "alias": "first", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "batches": [ + { + "endExclusive": 2, + "decisions": [ + { + "event": "first", + "code": "ACCEPTED" + }, + { + "event": "second", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [ + { + "alias": "intermediate", + "event": "first", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + } + ], + "versions": [ + { + "alias": "final", + "event": "second", + "sourceVersion": "intermediate", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + } + ], + "relations": [], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-01T00:00:00Z", + "versions": [ + "final" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "final" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [], + "systemAt": "2024-01-31T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "final" + ], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-10T00:00:00Z", + "versions": [] + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/gold/12-invalid-target-atomicity.json b/geaflow-ai/src/test/resources/temporal/gold/12-invalid-target-atomicity.json new file mode 100644 index 000000000..0a27433a3 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/gold/12-invalid-target-atomicity.json @@ -0,0 +1,538 @@ +{ + "schemaVersion": 2, + "name": "12-invalid-target-atomicity", + "events": [ + { + "alias": "base", + "id": "event-1", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-01T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "staged-before-failure", + "id": "retry-event", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "backup", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-4", + "source": { + "id": "source-4", + "name": "source 4" + }, + "content": "Evidence 4." + } + ] + }, + { + "alias": "unknown", + "id": "unknown-event", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ], + "target": "absent-version" + }, + { + "alias": "cross-scope", + "id": "cross-event", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "backup", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ], + "target": "old" + }, + { + "alias": "correction", + "id": "event-2", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "validFrom": "2024-01-03T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ], + "target": "old" + }, + { + "alias": "inactive", + "id": "inactive-event", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-3", + "source": { + "id": "source-3", + "name": "source 3" + }, + "content": "Evidence 3." + } + ], + "target": "old" + }, + { + "alias": "no-overlap", + "id": "no-overlap-event", + "operation": "CORRECT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "validFrom": "2024-01-07T00:00:00Z", + "validTo": "2024-01-09T00:00:00Z", + "recordedAt": "2024-02-03T00:00:00Z", + "evidence": [ + { + "id": "ev-3", + "source": { + "id": "source-3", + "name": "source 3" + }, + "content": "Evidence 3." + } + ], + "target": "middle" + }, + { + "alias": "retry-after-failure", + "id": "retry-event", + "operation": "ASSERT", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "backup", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "recordedAt": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-4", + "source": { + "id": "source-4", + "name": "source 4" + }, + "content": "Evidence 4." + } + ] + } + ], + "batches": [ + { + "endExclusive": 1, + "decisions": [ + { + "event": "base", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 3, + "expectedFailure": "REJECT_TARGET_NOT_FOUND" + }, + { + "endExclusive": 4, + "expectedFailure": "REJECT_TARGET_NOT_FOUND" + }, + { + "endExclusive": 5, + "decisions": [ + { + "event": "correction", + "code": "ACCEPTED" + } + ] + }, + { + "endExclusive": 6, + "expectedFailure": "REJECT_TARGET_INACTIVE" + }, + { + "endExclusive": 7, + "expectedFailure": "REJECT_TARGET_NO_OVERLAP" + }, + { + "endExclusive": 8, + "decisions": [ + { + "event": "retry-after-failure", + "code": "ACCEPTED" + } + ] + } + ], + "expected": { + "identities": [], + "versions": [ + { + "alias": "old", + "event": "base", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-01T00:00:00Z", + "systemTo": "2024-02-02T00:00:00Z", + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "left", + "event": "correction", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-03T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "middle", + "event": "correction", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-berlin", + "value": { + "kind": "LITERAL", + "value": "Berlin" + }, + "status": "ACTIVE", + "validFrom": "2024-01-03T00:00:00Z", + "validTo": "2024-01-07T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-2", + "source": { + "id": "source-2", + "name": "source 2" + }, + "content": "Evidence 2." + } + ] + }, + { + "alias": "right", + "event": "correction", + "sourceVersion": "old", + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "profile", + "factId": "fact-paris", + "value": { + "kind": "LITERAL", + "value": "Paris" + }, + "status": "ACTIVE", + "validFrom": "2024-01-07T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-1", + "source": { + "id": "source-1", + "name": "source 1" + }, + "content": "Evidence 1." + } + ] + }, + { + "alias": "retried", + "event": "retry-after-failure", + "sourceVersion": null, + "subject": { + "id": "alice", + "label": "person" + }, + "predicate": "city", + "scope": "backup", + "factId": "fact-rome", + "value": { + "kind": "LITERAL", + "value": "Rome" + }, + "status": "ACTIVE", + "validFrom": "2024-01-01T00:00:00Z", + "validTo": "2024-01-10T00:00:00Z", + "systemFrom": "2024-02-02T00:00:00Z", + "systemTo": null, + "evidence": [ + { + "id": "ev-4", + "source": { + "id": "source-4", + "name": "source 4" + }, + "content": "Evidence 4." + } + ] + } + ], + "relations": [ + { + "type": "SUPERSEDES", + "from": "left", + "to": "old" + }, + { + "type": "SUPERSEDES", + "from": "middle", + "to": "old" + }, + { + "type": "SUPERSEDES", + "from": "right", + "to": "old" + } + ], + "queries": [ + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-03T00:00:00Z", + "versions": [ + "middle" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-07T00:00:00Z", + "versions": [ + "right" + ] + }, + { + "mode": "HISTORICAL", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "old", + "middle" + ] + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "old" + ], + "systemAt": "2024-02-01T00:00:00Z" + }, + { + "mode": "AS_OF", + "subjectId": "alice", + "predicate": "city", + "scope": "profile", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "middle" + ], + "systemAt": "2024-02-02T00:00:00Z" + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "backup", + "validAt": "2024-01-05T00:00:00Z", + "versions": [ + "retried" + ] + }, + { + "mode": "CURRENT", + "subjectId": "alice", + "predicate": "city", + "scope": "absent", + "validAt": "2024-01-05T00:00:00Z", + "versions": [] + } + ] + } +} diff --git a/geaflow-ai/src/test/resources/temporal/temporal_udga_feasibility.sql b/geaflow-ai/src/test/resources/temporal/temporal_udga_feasibility.sql new file mode 100644 index 000000000..9e9b9f833 --- /dev/null +++ b/geaflow-ai/src/test/resources/temporal/temporal_udga_feasibility.sql @@ -0,0 +1,87 @@ +/* + * 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. + */ + +CREATE FUNCTION temporal_udga_probe AS +'org.apache.geaflow.ai.temporal.udga.TemporalUdgaProbe'; + +CREATE GRAPH temporal_probe_graph ( + Vertex entity ( + id bigint ID, + name varchar + ), + Edge relates ( + src_id bigint SOURCE ID, + target_id bigint DESTINATION ID + ) +) WITH ( + storeType = 'memory', + shardCount = 1 +); + +CREATE TABLE temporal_probe_vertices ( + id bigint, + name varchar +) WITH ( + type = 'file', + geaflow.dsl.file.path = '${vertices}', + geaflow.dsl.window.size = -1 +); + +CREATE TABLE temporal_probe_edges ( + src_id bigint, + target_id bigint +) WITH ( + type = 'file', + geaflow.dsl.file.path = '${edges}', + geaflow.dsl.window.size = 1 +); + +INSERT INTO temporal_probe_graph.entity +SELECT id, name FROM temporal_probe_vertices; + +INSERT INTO temporal_probe_graph.relates +SELECT src_id, target_id FROM temporal_probe_edges; + +CREATE TABLE temporal_probe_results ( + vertex_id bigint, + batch_count int, + had_previous_value boolean, + dynamic_edge_count int, + received_message_count int +) WITH ( + type = 'file', + geaflow.dsl.file.path = '${output}' +); + +USE GRAPH temporal_probe_graph; + +INSERT INTO temporal_probe_results +CALL temporal_udga_probe() YIELD ( + vertex_id, + batch_count, + had_previous_value, + dynamic_edge_count, + received_message_count +) +RETURN + vertex_id, + batch_count, + had_previous_value, + dynamic_edge_count, + received_message_count;