Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Provenance records reference source coordinates only. They must never
* carry raw document text so they can be logged and persisted without leaking
* private content.</p>
*/
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 + "'}";
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
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 + '}';
}
}
Original file line number Diff line number Diff line change
@@ -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> T readJson(String resource, Class<T> 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);
}
}
}
Loading