test(evaluation): replay artifact version boundaries with real file IO

This commit is contained in:
mateaix 2026-09-13 21:16:18 +08:00
parent 501a1f8bdb
commit bb3d477aa2
5 changed files with 560 additions and 0 deletions

View File

@ -0,0 +1,140 @@
package vip.mate.evaluation;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.model.ToolContext;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.execution.evidence.service.ExecutionObservationSink;
import vip.mate.tool.document.GeneratedFileCache;
import java.io.IOException;
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.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Runs allowlisted platform actions on temporary files. No model, shell command or user path is executed. */
final class OfflineArtifactTaskReplay {
static final ObjectMapper JSON = new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS);
enum Operation { REGISTER, MUTATE_INPUT, MUTATE_DOWNLOAD, RESTART_MUTATE_DOWNLOAD,
STORAGE_UNAVAILABLE, DIRECT_RETURN, MISSING_OWNER, REWRITE_DISK }
record Suite(Integer schemaVersion, String suiteId, List<Task> tasks) { }
record Task(String id, String task, String source, Operation operation, String content,
Expected expected) { }
record Expected(String hotContent, String coldContent, List<String> observations,
Boolean hotMatchesSnapshot, Boolean coldMatchesSnapshot) { }
record Actual(String hotContent, String coldContent, List<String> observations,
String snapshotDigest, Boolean hotMatchesSnapshot, Boolean coldMatchesSnapshot) { }
record Result(String id, String task, String source, Operation operation, Expected expected,
Actual actual, boolean matched, String error) { }
record Report(int schemaVersion, String suiteId, String suiteSha256, String revisionLabel,
Map<String, String> productionClassSha256, String executionMode, int onlineModelCalls,
String agentTaskSuccessRate, String onlineCost, int matchedCases, int mismatchedCases,
List<Result> cases) { }
static Suite parse(byte[] bytes) throws IOException {
Suite suite = JSON.readValue(bytes, Suite.class);
require(suite != null && Integer.valueOf(1).equals(suite.schemaVersion()), "schemaVersion must be 1");
require(nonblank(suite.suiteId()) && suite.tasks() != null && !suite.tasks().isEmpty()
&& suite.tasks().size() <= 100, "suiteId and 1..100 tasks required");
var ids = new HashSet<String>();
for (Task task : suite.tasks()) {
require(task != null && nonblank(task.id()) && ids.add(task.id()), "unique task IDs required");
require(nonblank(task.task()) && nonblank(task.source()) && task.operation() != null
&& nonblank(task.content()) && task.content().length() <= 16_384,
task.id() + ": invalid task inputs");
Expected expected = task.expected();
require(expected != null && expected.hotContent() != null && expected.observations() != null,
task.id() + ": hotContent and observations expectations required");
require(expected.observations().isEmpty()
|| expected.observations().equals(List.of("ARTIFACT_SNAPSHOT:OBSERVED")),
task.id() + ": unsupported observations expectation");
boolean snapshotExpected = !expected.observations().isEmpty();
require(snapshotExpected == (expected.hotMatchesSnapshot() != null)
&& (snapshotExpected && expected.coldContent() != null) == (expected.coldMatchesSnapshot() != null),
task.id() + ": snapshot match expectations must be explicit when comparable");
}
return suite;
}
static Report run(byte[] bytes, Path root, String revisionLabel) throws IOException {
Suite suite = parse(bytes);
require(nonblank(revisionLabel), "revisionLabel required");
List<Result> results = new ArrayList<>();
for (Task task : suite.tasks()) {
try {
// Paths are generated by the harness, never taken from task JSON.
Actual actual = execute(task, Files.createTempDirectory(root, "artifact-case-"));
Expected e = task.expected();
boolean matched = Objects.equals(e.hotContent(), actual.hotContent())
&& Objects.equals(e.coldContent(), actual.coldContent())
&& e.observations().equals(actual.observations())
&& Objects.equals(e.hotMatchesSnapshot(), actual.hotMatchesSnapshot())
&& Objects.equals(e.coldMatchesSnapshot(), actual.coldMatchesSnapshot());
results.add(new Result(task.id(), task.task(), task.source(), task.operation(), e, actual, matched, null));
} catch (IOException | RuntimeException error) {
results.add(new Result(task.id(), task.task(), task.source(), task.operation(), task.expected(),
null, false, error.getClass().getSimpleName()));
}
}
int matched = (int) results.stream().filter(Result::matched).count();
Map<String, String> classes = new LinkedHashMap<>();
for (Class<?> type : List.of(GeneratedFileCache.class, GeneratedFileCache.Entry.class, ExecutionObservationSink.class)) {
try (var stream = type.getResourceAsStream("/" + type.getName().replace('.', '/') + ".class")) {
if (stream == null) throw new IOException("Missing tested class " + type.getName());
classes.put(type.getName(), digest(stream.readAllBytes()));
}
}
return new Report(1, suite.suiteId(), digest(bytes), revisionLabel, classes,
"offline_platform_fixture_io", 0, "not_measured", "not_measured", matched,
results.size() - matched, List.copyOf(results));
}
private static Actual execute(Task task, Path root) throws IOException {
Path storage = root.resolve("cache");
if (task.operation() == Operation.STORAGE_UNAVAILABLE) Files.writeString(storage, "not a directory");
var cache = new GeneratedFileCache(storage);
var sink = new ExecutionObservationSink(task.operation() == Operation.DIRECT_RETURN);
ToolContext origin = task.operation() == Operation.MISSING_OWNER ? new ToolContext(Map.of())
: ChatOrigin.web("offline-artifact", "fixture-owner", 1L, root.toString()).toToolContext();
byte[] input = task.content().getBytes(StandardCharsets.UTF_8);
String id = cache.put(input, "report.txt", "text/plain", sink.attach(origin));
switch (task.operation()) {
case MUTATE_INPUT -> input[0] ^= 0x01;
case MUTATE_DOWNLOAD -> cache.get(id).orElseThrow().bytes()[0] ^= 0x01;
case RESTART_MUTATE_DOWNLOAD -> {
cache = new GeneratedFileCache(storage);
cache.get(id).orElseThrow().bytes()[0] ^= 0x01;
}
case REWRITE_DISK -> Files.writeString(storage.resolve(id), "externally replaced");
default -> { }
}
byte[] hot = cache.get(id).orElseThrow().bytes();
byte[] cold = new GeneratedFileCache(storage).get(id).map(GeneratedFileCache.Entry::bytes).orElse(null);
var observations = sink.observations();
String snapshot = observations.isEmpty() ? null : observations.getFirst().artifactDigest();
return new Actual(new String(hot, StandardCharsets.UTF_8),
cold == null ? null : new String(cold, StandardCharsets.UTF_8),
observations.stream().map(o -> o.kind() + ":" + o.result()).toList(), snapshot,
snapshot == null ? null : snapshot.equals(digest(hot)),
snapshot == null || cold == null ? null : snapshot.equals(digest(cold)));
}
private static String digest(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
}
private static boolean nonblank(String s) { return s != null && !s.isBlank(); }
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalArgumentException(message);
}
}

View File

@ -0,0 +1,53 @@
package vip.mate.evaluation;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class OfflineArtifactTaskReplayTest {
@TempDir Path root;
private byte[] fixture() throws Exception {
try (var stream = getClass().getResourceAsStream("/agent-evaluation/artifact-boundaries-v1.json")) {
assertNotNull(stream);
return stream.readAllBytes();
}
}
@Test
void executePlatformTasksAndWriteReport() throws Exception {
String input = System.getProperty("artifact.eval.suite");
var report = OfflineArtifactTaskReplay.run(input == null ? fixture() : Files.readAllBytes(Path.of(input)),
root, System.getProperty("artifact.eval.revision", "unrecorded"));
Path output = Path.of(System.getProperty("artifact.eval.report", "target/agent-evaluation/artifact-baseline.json"));
Files.createDirectories(output.toAbsolutePath().getParent());
OfflineArtifactTaskReplay.JSON.writerWithDefaultPrettyPrinter().writeValue(output.toFile(), report);
assertEquals(0, report.mismatchedCases(), () -> "Artifact task mismatches: " + output.toAbsolutePath());
assertEquals(0, report.onlineModelCalls());
assertEquals(3, report.productionClassSha256().size());
}
@Test
void invalidInputIsRejectedBeforeCreatingFiles() throws Exception {
ObjectNode suite = (ObjectNode) OfflineArtifactTaskReplay.JSON.readTree(fixture());
((ObjectNode) suite.withArray("tasks").get(0).get("expected")).remove("hotMatchesSnapshot");
assertThrows(IllegalArgumentException.class, () -> OfflineArtifactTaskReplay.run(
suite.toString().getBytes(StandardCharsets.UTF_8), root, "test"));
try (var files = Files.list(root)) { assertEquals(0, files.count()); }
}
@Test
void wrongExpectationReportsMismatchAndContinues() throws Exception {
ObjectNode suite = (ObjectNode) OfflineArtifactTaskReplay.JSON.readTree(fixture());
((ObjectNode) suite.withArray("tasks").get(0).get("expected")).put("hotContent", "wrong");
var report = OfflineArtifactTaskReplay.run(suite.toString().getBytes(StandardCharsets.UTF_8), root, "test");
assertEquals(1, report.mismatchedCases());
assertEquals(suite.withArray("tasks").size(), report.cases().size());
}
}

View File

@ -60,3 +60,48 @@ strong acceptance. A future trusted file recipe must use a separate execution
mode and record its actual filesystem/version checks. A real model comparison
must additionally pin runtime/skill/model versions and measure real calls; it
cannot reuse these matched counts as its success rate.
## Artifact tasks with actual temporary-file I/O
`artifact-boundaries-v1.json` is a separate schema/execution mode. Run it from the
repository root:
```sh
mvn -pl mateclaw-server -am -Dtest=OfflineArtifactTaskReplayTest \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dartifact.eval.revision="$(git rev-parse HEAD)" test
```
Output: `mateclaw-server/target/agent-evaluation/artifact-baseline.json`.
Override `artifact.eval.suite` and `artifact.eval.report` with absolute paths for
candidate runs. The initial committed artifact baseline's revision label refers
to the production source before this harness was added. Reports additionally
hash the actual loaded `GeneratedFileCache`, `Entry`, and
`ExecutionObservationSink` class bytes, so a revision label alone is not treated
as proof of which implementations were exercised. Class hashes depend on the
compiler/build; compare revisions under the same build environment. They cover
these named classes, not the entire transitive runtime or a signed attestation.
Each artifact task has `id`, `task`, `source`, `operation`, `content`, and
`expected`. Operations are a closed set: `REGISTER`, `MUTATE_INPUT`,
`MUTATE_DOWNLOAD`, `RESTART_MUTATE_DOWNLOAD`, `STORAGE_UNAVAILABLE`,
`DIRECT_RETURN`, `MISSING_OWNER`, `REWRITE_DISK`. Input strings are bounded to
16,384 characters. The harness generates its own temporary paths and never
executes fixture-provided paths, scripts or shell commands.
Expected results include `hotContent`, nullable `coldContent`, `observations`
(`[]` or `["ARTIFACT_SNAPSHOT:OBSERVED"]`), and nullable
`hotMatchesSnapshot`/`coldMatchesSnapshot`. A match is null when no snapshot or
readable version exists; it never silently becomes a pass. Every task uses the
production cache and sink, writes/reads real temporary files, and reopens the
cache to test restart behavior. The directory is removed by JUnit after the run.
A mismatched expectation preserves all per-case results and fails the command.
The checked-in `artifact-baseline-v1.json` documents these eight scenarios.
`REWRITE_DISK` intentionally observes that the hot cache keeps the original
bytes while a new cache reads the externally replaced version, which no longer
matches the historic snapshot digest. This is a documented limit of unmanaged
storage, not an accepted strong-validation result. The report never emits a
CHECK_RESULT/PASS. These eight tests plus ten evaluator replays are **18 fixed
scenarios, not 18 Agent task executions**. Actual task-solving models, their
latency, cost and success rate remain unmeasured.

View File

@ -0,0 +1,194 @@
{
"schemaVersion" : 1,
"suiteId" : "artifact-boundaries-v1",
"suiteSha256" : "d3c50469bc767b532790745437635d9e92234bc04d0732d7a6d26e8dcc6bedcf",
"revisionLabel" : "0513e2cde98b153ffa18f946e9edee55dd24b7f8",
"productionClassSha256" : {
"vip.mate.tool.document.GeneratedFileCache" : "7a795aadcad53c8f4f5baf1e5f79cd303dbc0098b0bd5459eaa3a9eb951b2d8f",
"vip.mate.tool.document.GeneratedFileCache$Entry" : "440483cf1084202180a60b7959996e216362af12f4034cd2a7d9f92dd59254f0",
"vip.mate.execution.evidence.service.ExecutionObservationSink" : "23975edeb8970c70d5ef756e4e4b68ff1b98818d64e63dba9edc55b0b134a2ae"
},
"executionMode" : "offline_platform_fixture_io",
"onlineModelCalls" : 0,
"agentTaskSuccessRate" : "not_measured",
"onlineCost" : "not_measured",
"matchedCases" : 8,
"mismatchedCases" : 0,
"cases" : [ {
"id" : "register",
"task" : "Register a durable report and reopen it after restart",
"source" : "TrustedExecutionObservationTest.onlyDurablyReadableArtifactsProduceSnapshots",
"operation" : "REGISTER",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"snapshotDigest" : "231d40769a0f1d875c0fee33d8dc02eaab83925e80c9e95a2ed00eae1a9916d1",
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"matched" : true,
"error" : null
}, {
"id" : "mutate-input",
"task" : "Producer reuses its buffer after registering a report",
"source" : "cycle003 reproduced input byte alias failure",
"operation" : "MUTATE_INPUT",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"snapshotDigest" : "231d40769a0f1d875c0fee33d8dc02eaab83925e80c9e95a2ed00eae1a9916d1",
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"matched" : true,
"error" : null
}, {
"id" : "mutate-download",
"task" : "Consumer modifies a returned download buffer",
"source" : "cycle003 reproduced returned byte alias failure",
"operation" : "MUTATE_DOWNLOAD",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"snapshotDigest" : "231d40769a0f1d875c0fee33d8dc02eaab83925e80c9e95a2ed00eae1a9916d1",
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"matched" : true,
"error" : null
}, {
"id" : "restart-mutate-download",
"task" : "Consumer modifies bytes reloaded after restart",
"source" : "GeneratedFileCachePersistenceTest persistence contract",
"operation" : "RESTART_MUTATE_DOWNLOAD",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"snapshotDigest" : "231d40769a0f1d875c0fee33d8dc02eaab83925e80c9e95a2ed00eae1a9916d1",
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : true
},
"matched" : true,
"error" : null
}, {
"id" : "storage-unavailable",
"task" : "Register a report when the durable storage path is not a directory",
"source" : "TrustedExecutionObservationTest durable storage failure",
"operation" : "STORAGE_UNAVAILABLE",
"expected" : {
"hotContent" : "registered report",
"coldContent" : null,
"observations" : [ ],
"hotMatchesSnapshot" : null,
"coldMatchesSnapshot" : null
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : null,
"observations" : [ ],
"snapshotDigest" : null,
"hotMatchesSnapshot" : null,
"coldMatchesSnapshot" : null
},
"matched" : true,
"error" : null
}, {
"id" : "direct-return",
"task" : "Deliver a direct-return file without recording content evidence",
"source" : "TrustedExecutionObservationTest direct-return privacy boundary",
"operation" : "DIRECT_RETURN",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ ],
"hotMatchesSnapshot" : null,
"coldMatchesSnapshot" : null
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ ],
"snapshotDigest" : null,
"hotMatchesSnapshot" : null,
"coldMatchesSnapshot" : null
},
"matched" : true,
"error" : null
}, {
"id" : "missing-owner",
"task" : "Register a file without canonical workspace/conversation ownership",
"source" : "GeneratedFileCache.put typed observation owner checks",
"operation" : "MISSING_OWNER",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ ],
"hotMatchesSnapshot" : null,
"coldMatchesSnapshot" : null
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "registered report",
"observations" : [ ],
"snapshotDigest" : null,
"hotMatchesSnapshot" : null,
"coldMatchesSnapshot" : null
},
"matched" : true,
"error" : null
}, {
"id" : "rewrite-disk",
"task" : "Reopen a report after an external writer replaces its persisted bytes",
"source" : "RFC096 unmanaged shared-directory boundary; known Observe limitation",
"operation" : "REWRITE_DISK",
"expected" : {
"hotContent" : "registered report",
"coldContent" : "externally replaced",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : false
},
"actual" : {
"hotContent" : "registered report",
"coldContent" : "externally replaced",
"observations" : [ "ARTIFACT_SNAPSHOT:OBSERVED" ],
"snapshotDigest" : "231d40769a0f1d875c0fee33d8dc02eaab83925e80c9e95a2ed00eae1a9916d1",
"hotMatchesSnapshot" : true,
"coldMatchesSnapshot" : false
},
"matched" : true,
"error" : null
} ]
}

View File

@ -0,0 +1,128 @@
{
"schemaVersion": 1,
"suiteId": "artifact-boundaries-v1",
"tasks": [
{
"id": "register",
"task": "Register a durable report and reopen it after restart",
"source": "TrustedExecutionObservationTest.onlyDurablyReadableArtifactsProduceSnapshots",
"operation": "REGISTER",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "registered report",
"observations": [
"ARTIFACT_SNAPSHOT:OBSERVED"
],
"hotMatchesSnapshot": true,
"coldMatchesSnapshot": true
}
},
{
"id": "mutate-input",
"task": "Producer reuses its buffer after registering a report",
"source": "cycle003 reproduced input byte alias failure",
"operation": "MUTATE_INPUT",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "registered report",
"observations": [
"ARTIFACT_SNAPSHOT:OBSERVED"
],
"hotMatchesSnapshot": true,
"coldMatchesSnapshot": true
}
},
{
"id": "mutate-download",
"task": "Consumer modifies a returned download buffer",
"source": "cycle003 reproduced returned byte alias failure",
"operation": "MUTATE_DOWNLOAD",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "registered report",
"observations": [
"ARTIFACT_SNAPSHOT:OBSERVED"
],
"hotMatchesSnapshot": true,
"coldMatchesSnapshot": true
}
},
{
"id": "restart-mutate-download",
"task": "Consumer modifies bytes reloaded after restart",
"source": "GeneratedFileCachePersistenceTest persistence contract",
"operation": "RESTART_MUTATE_DOWNLOAD",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "registered report",
"observations": [
"ARTIFACT_SNAPSHOT:OBSERVED"
],
"hotMatchesSnapshot": true,
"coldMatchesSnapshot": true
}
},
{
"id": "storage-unavailable",
"task": "Register a report when the durable storage path is not a directory",
"source": "TrustedExecutionObservationTest durable storage failure",
"operation": "STORAGE_UNAVAILABLE",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": null,
"observations": [],
"hotMatchesSnapshot": null,
"coldMatchesSnapshot": null
}
},
{
"id": "direct-return",
"task": "Deliver a direct-return file without recording content evidence",
"source": "TrustedExecutionObservationTest direct-return privacy boundary",
"operation": "DIRECT_RETURN",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "registered report",
"observations": [],
"hotMatchesSnapshot": null,
"coldMatchesSnapshot": null
}
},
{
"id": "missing-owner",
"task": "Register a file without canonical workspace/conversation ownership",
"source": "GeneratedFileCache.put typed observation owner checks",
"operation": "MISSING_OWNER",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "registered report",
"observations": [],
"hotMatchesSnapshot": null,
"coldMatchesSnapshot": null
}
},
{
"id": "rewrite-disk",
"task": "Reopen a report after an external writer replaces its persisted bytes",
"source": "RFC096 unmanaged shared-directory boundary; known Observe limitation",
"operation": "REWRITE_DISK",
"content": "registered report",
"expected": {
"hotContent": "registered report",
"coldContent": "externally replaced",
"observations": [
"ARTIFACT_SNAPSHOT:OBSERVED"
],
"hotMatchesSnapshot": true,
"coldMatchesSnapshot": false
}
}
]
}