From 938a889708aa11e87592c7481e915ce2d4f4a70c Mon Sep 17 00:00:00 2001 From: qihongcheng Date: Sat, 22 Aug 2026 12:45:57 +0800 Subject: [PATCH] [ISSUE-842] Define provenance model for facts, chunks, and evidence Add SourceSpan and ProvenanceRef contracts under org.apache.geaflow.ai.provenance linking extracted facts to source document, chunk, optional span within the chunk, extractor version and schema version. Validation fails closed for missing provenance and provenance records reference source coordinates only, never raw document text. Co-Authored-By: Claude --- .../geaflow/ai/provenance/ProvenanceRef.java | 138 ++++++++++++++++++ .../geaflow/ai/provenance/SourceSpan.java | 106 ++++++++++++++ .../ai/provenance/ProvenanceRefTest.java | 129 ++++++++++++++++ .../geaflow/ai/provenance/SourceSpanTest.java | 71 +++++++++ .../fact-provenance-without-span.json | 7 + .../resources/provenance/fact-provenance.json | 11 ++ .../resources/provenance/source-span.json | 5 + 7 files changed, 467 insertions(+) create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java create mode 100644 geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java create mode 100644 geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java create mode 100644 geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json create mode 100644 geaflow-ai/src/test/resources/provenance/fact-provenance.json create mode 100644 geaflow-ai/src/test/resources/provenance/source-span.json diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java new file mode 100644 index 000000000..41947dc1b --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/ProvenanceRef.java @@ -0,0 +1,138 @@ +/* + * 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.provenance; + +import java.util.Objects; + +/** + * Source lineage of an extracted fact: the document and chunk the fact came + * from, the span within the chunk, and the extractor and schema versions that + * produced it. + * + *

Provenance records reference source coordinates only. They must never + * carry raw document text so they can be logged and persisted without leaking + * private content.

+ */ +public class ProvenanceRef { + + private final String documentId; + private final String chunkId; + private final SourceSpan sourceSpan; + private final String extractorVersion; + private final String schemaVersion; + + public ProvenanceRef(String documentId, String chunkId, SourceSpan sourceSpan, + String extractorVersion, String schemaVersion) { + this.documentId = documentId; + this.chunkId = chunkId; + this.sourceSpan = sourceSpan; + this.extractorVersion = extractorVersion; + this.schemaVersion = schemaVersion; + } + + public String getDocumentId() { + return documentId; + } + + public String getChunkId() { + return chunkId; + } + + /** + * The span within the chunk this fact was extracted from, or null when the + * fact is attributed to the chunk as a whole. + */ + public SourceSpan getSourceSpan() { + return sourceSpan; + } + + public String getExtractorVersion() { + return extractorVersion; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + /** + * Validates that provenance is present for an extracted fact. + * + * @throws IllegalArgumentException when provenance is missing + */ + public static void validate(ProvenanceRef provenance) { + if (provenance == null) { + throw new IllegalArgumentException("provenance is missing for extracted fact"); + } + provenance.validate(); + } + + /** + * Validates this reference, throwing {@link IllegalArgumentException} when + * the document, chunk, extractor version or schema version is missing, or + * when the optional span does not belong to the referenced chunk. + */ + public void validate() { + require(documentId, "document id"); + require(chunkId, "chunk id"); + require(extractorVersion, "extractor version"); + require(schemaVersion, "schema version"); + if (sourceSpan != null) { + sourceSpan.validate(); + if (!Objects.equals(sourceSpan.getChunkId(), chunkId)) { + throw new IllegalArgumentException("source span chunk id '" + sourceSpan.getChunkId() + + "' does not match provenance chunk id '" + chunkId + "'"); + } + } + } + + private static void require(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("provenance " + name + " is required"); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProvenanceRef that = (ProvenanceRef) o; + return Objects.equals(documentId, that.documentId) + && Objects.equals(chunkId, that.chunkId) + && Objects.equals(sourceSpan, that.sourceSpan) + && Objects.equals(extractorVersion, that.extractorVersion) + && Objects.equals(schemaVersion, that.schemaVersion); + } + + @Override + public int hashCode() { + return Objects.hash(documentId, chunkId, sourceSpan, extractorVersion, schemaVersion); + } + + @Override + public String toString() { + return "ProvenanceRef{documentId='" + documentId + "', chunkId='" + chunkId + + "', sourceSpan=" + sourceSpan + ", extractorVersion='" + extractorVersion + + "', schemaVersion='" + schemaVersion + "'}"; + } +} diff --git a/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java new file mode 100644 index 000000000..48c1e3654 --- /dev/null +++ b/geaflow-ai/src/main/java/org/apache/geaflow/ai/provenance/SourceSpan.java @@ -0,0 +1,106 @@ +/* + * 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.provenance; + +import java.util.Objects; + +/** + * Identifies a contiguous span of text within a chunk. + * + *

A span references positions only. It must never carry raw document text so + * that provenance records can be logged and persisted without leaking private + * content.

+ */ +public class SourceSpan { + + private final String chunkId; + private final int startOffset; + private final int endOffset; + + public SourceSpan(String chunkId, int startOffset, int endOffset) { + this.chunkId = chunkId; + this.startOffset = startOffset; + this.endOffset = endOffset; + } + + /** + * Creates a span and eagerly validates it. + */ + public static SourceSpan of(String chunkId, int startOffset, int endOffset) { + SourceSpan span = new SourceSpan(chunkId, startOffset, endOffset); + span.validate(); + return span; + } + + public String getChunkId() { + return chunkId; + } + + public int getStartOffset() { + return startOffset; + } + + public int getEndOffset() { + return endOffset; + } + + /** + * Validates this span, throwing {@link IllegalArgumentException} when the + * chunk id is missing or the offsets do not form a valid range. + */ + public void validate() { + if (chunkId == null || chunkId.trim().isEmpty()) { + throw new IllegalArgumentException("source span chunk id is required"); + } + if (startOffset < 0) { + throw new IllegalArgumentException( + "source span start offset must be non-negative, got " + startOffset); + } + if (endOffset < startOffset) { + throw new IllegalArgumentException("source span end offset " + endOffset + + " must be greater than or equal to start offset " + startOffset); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SourceSpan that = (SourceSpan) o; + return startOffset == that.startOffset + && endOffset == that.endOffset + && Objects.equals(chunkId, that.chunkId); + } + + @Override + public int hashCode() { + return Objects.hash(chunkId, startOffset, endOffset); + } + + @Override + public String toString() { + return "SourceSpan{chunkId='" + chunkId + "', startOffset=" + startOffset + + ", endOffset=" + endOffset + '}'; + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java new file mode 100644 index 000000000..a8de8c379 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/ProvenanceRefTest.java @@ -0,0 +1,129 @@ +/* + * 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.provenance; + +import com.google.gson.Gson; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ProvenanceRefTest { + + private static final Gson GSON = new Gson(); + + @Test + public void testValidProvenancePassesValidation() { + SourceSpan span = new SourceSpan("chunk-000012", 48, 132); + ProvenanceRef provenance = new ProvenanceRef("doc-001", "chunk-000012", span, + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + Assertions.assertDoesNotThrow(() -> ProvenanceRef.validate(provenance)); + Assertions.assertEquals("doc-001", provenance.getDocumentId()); + Assertions.assertEquals("chunk-000012", provenance.getChunkId()); + Assertions.assertEquals(span, provenance.getSourceSpan()); + } + + @Test + public void testProvenanceWithoutSpanIsValid() { + ProvenanceRef provenance = new ProvenanceRef("doc-001", "chunk-000013", null, + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + Assertions.assertDoesNotThrow(() -> provenance.validate()); + Assertions.assertNull(provenance.getSourceSpan()); + } + + @Test + public void testMissingProvenanceFailsValidation() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> ProvenanceRef.validate(null)); + } + + @Test + public void testMissingRequiredFieldsFailValidation() { + SourceSpan span = new SourceSpan("chunk-1", 0, 10); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef(null, "chunk-1", span, "e-1", "s-1").validate()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", " ", span, "e-1", "s-1").validate()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", "chunk-1", span, null, "s-1").validate()); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", "chunk-1", span, "e-1", null).validate()); + } + + @Test + public void testSpanFromOtherChunkFails() { + SourceSpan span = new SourceSpan("chunk-other", 0, 10); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ProvenanceRef("doc-1", "chunk-1", span, "e-1", "s-1").validate()); + } + + @Test + public void testJsonExampleRoundTrip() throws IOException { + ProvenanceRef provenance = readJson("/provenance/fact-provenance.json", + ProvenanceRef.class); + Assertions.assertDoesNotThrow(() -> provenance.validate()); + ProvenanceRef expected = new ProvenanceRef("doc-20260822-001", "chunk-000012", + new SourceSpan("chunk-000012", 48, 132), + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + Assertions.assertEquals(expected, provenance); + Assertions.assertEquals(expected, GSON.fromJson(GSON.toJson(provenance), ProvenanceRef.class)); + } + + @Test + public void testJsonExampleWithoutSpanRoundTrip() throws IOException { + ProvenanceRef provenance = readJson("/provenance/fact-provenance-without-span.json", + ProvenanceRef.class); + Assertions.assertDoesNotThrow(() -> provenance.validate()); + Assertions.assertNull(provenance.getSourceSpan()); + Assertions.assertEquals(provenance, + GSON.fromJson(GSON.toJson(provenance), ProvenanceRef.class)); + } + + @Test + public void testSourceSpanJsonExampleRoundTrip() throws IOException { + SourceSpan span = readJson("/provenance/source-span.json", SourceSpan.class); + Assertions.assertDoesNotThrow(() -> span.validate()); + Assertions.assertEquals(new SourceSpan("chunk-000012", 48, 132), span); + } + + @Test + public void testProvenanceDoesNotCarryRawText() { + String rawDocumentText = "Alice works at Acme Corp in Shanghai."; + SourceSpan span = new SourceSpan("chunk-000012", 0, rawDocumentText.length()); + ProvenanceRef provenance = new ProvenanceRef("doc-001", "chunk-000012", span, + "fake-extractor-1.0.0", "extraction-schema-1.0.0"); + String json = GSON.toJson(provenance); + Assertions.assertFalse(json.contains(rawDocumentText), + "provenance must not embed raw document text"); + } + + private static T readJson(String resource, Class type) throws IOException { + try (InputStream stream = ProvenanceRefTest.class.getResourceAsStream(resource)) { + Assertions.assertNotNull(stream, "missing test resource " + resource); + BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8)); + return GSON.fromJson(reader.lines().collect(Collectors.joining("\n")), type); + } + } +} diff --git a/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java new file mode 100644 index 000000000..2b0a62109 --- /dev/null +++ b/geaflow-ai/src/test/java/org/apache/geaflow/ai/provenance/SourceSpanTest.java @@ -0,0 +1,71 @@ +/* + * 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.provenance; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SourceSpanTest { + + @Test + public void testValidSpanPassesValidation() { + SourceSpan span = new SourceSpan("chunk-000012", 48, 132); + Assertions.assertDoesNotThrow(() -> span.validate()); + Assertions.assertEquals("chunk-000012", span.getChunkId()); + Assertions.assertEquals(48, span.getStartOffset()); + Assertions.assertEquals(132, span.getEndOffset()); + } + + @Test + public void testEmptySpanIsValid() { + // A zero-length span (e.g. an insertion point) is still a valid range. + Assertions.assertDoesNotThrow(() -> SourceSpan.of("chunk-1", 10, 10)); + } + + @Test + public void testMissingChunkIdFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of(null, 0, 10)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of(" ", 0, 10)); + } + + @Test + public void testNegativeStartOffsetFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of("chunk-1", -1, 10)); + } + + @Test + public void testEndBeforeStartFails() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> SourceSpan.of("chunk-1", 20, 10)); + } + + @Test + public void testEqualsAndHashCode() { + SourceSpan first = new SourceSpan("chunk-1", 0, 10); + SourceSpan second = new SourceSpan("chunk-1", 0, 10); + SourceSpan other = new SourceSpan("chunk-2", 0, 10); + Assertions.assertEquals(first, second); + Assertions.assertEquals(first.hashCode(), second.hashCode()); + Assertions.assertNotEquals(first, other); + } +} diff --git a/geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json b/geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json new file mode 100644 index 000000000..deca22e50 --- /dev/null +++ b/geaflow-ai/src/test/resources/provenance/fact-provenance-without-span.json @@ -0,0 +1,7 @@ +{ + "documentId": "doc-20260822-001", + "chunkId": "chunk-000013", + "sourceSpan": null, + "extractorVersion": "fake-extractor-1.0.0", + "schemaVersion": "extraction-schema-1.0.0" +} diff --git a/geaflow-ai/src/test/resources/provenance/fact-provenance.json b/geaflow-ai/src/test/resources/provenance/fact-provenance.json new file mode 100644 index 000000000..a042f13e5 --- /dev/null +++ b/geaflow-ai/src/test/resources/provenance/fact-provenance.json @@ -0,0 +1,11 @@ +{ + "documentId": "doc-20260822-001", + "chunkId": "chunk-000012", + "sourceSpan": { + "chunkId": "chunk-000012", + "startOffset": 48, + "endOffset": 132 + }, + "extractorVersion": "fake-extractor-1.0.0", + "schemaVersion": "extraction-schema-1.0.0" +} diff --git a/geaflow-ai/src/test/resources/provenance/source-span.json b/geaflow-ai/src/test/resources/provenance/source-span.json new file mode 100644 index 000000000..22e08cc58 --- /dev/null +++ b/geaflow-ai/src/test/resources/provenance/source-span.json @@ -0,0 +1,5 @@ +{ + "chunkId": "chunk-000012", + "startOffset": 48, + "endOffset": 132 +}