diff --git a/javaTest/pom.xml b/javaTest/pom.xml
index 9385f63..d4b9b92 100644
--- a/javaTest/pom.xml
+++ b/javaTest/pom.xml
@@ -14,9 +14,15 @@
1.8
1.8
5.10.2
+ 2.17.2
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
org.junit.jupiter
junit-jupiter
diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java b/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java
index c91de9a..5672956 100644
--- a/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java
+++ b/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java
@@ -13,9 +13,23 @@ import com.ft546.wcs.demo.simulation.SimulatedProtocolAdapter;
import com.ft546.wcs.demo.traffic.GridMap;
import com.ft546.wcs.demo.traffic.GridPathPlanner;
import com.ft546.wcs.demo.traffic.TrafficController;
+import com.ft546.wcs.shadow.application.ShadowTrafficCoordinator;
+import com.ft546.wcs.shadow.decision.DecisionType;
+import com.ft546.wcs.shadow.decision.RollingDispatchPolicy;
+import com.ft546.wcs.shadow.decision.ShadowCandidate;
+import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
+import com.ft546.wcs.shadow.map.TrafficMapCompiler;
+import com.ft546.wcs.shadow.replay.DecisionEvent;
+import com.ft546.wcs.shadow.resource.ResourceKey;
+import com.ft546.wcs.shadow.resource.ResourceLedger;
+import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
import java.io.PrintStream;
+import java.time.Instant;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
public final class DemoApplication {
@@ -82,6 +96,7 @@ public final class DemoApplication {
+ " history=" + state.getHistory().size()
+ " protocolAttempts=" + protocol.getAttemptedQueueIds().size()
);
+ printOfflineShadowSummary(out);
}
private static void printCycle(PrintStream out, CycleResult result) {
@@ -135,4 +150,39 @@ public final class DemoApplication {
private static VehicleSnapshot vehicle(String id, String site) {
return new VehicleSnapshot(id, 1, site, 5, 1, 80, true, true, false);
}
+
+ private static void printOfflineShadowSummary(PrintStream out) {
+ LinkedHashSet nodes = new LinkedHashSet(Arrays.asList("A", "B", "C"));
+ LinkedHashSet edges =
+ new LinkedHashSet();
+ edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B"));
+ edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
+ EffectiveTrafficMap map = new TrafficMapCompiler().compile(
+ new TrafficMapCompiler.MapDefinition(
+ "FT546", 2L, "ZONE-DEMO", true, "demo-sha", "demo-sha",
+ nodes, edges, new LinkedHashMap(),
+ Collections.singleton("A"), Collections.singleton("C")
+ )
+ );
+ ResourceLedger ledger = new ResourceLedger(map.getCapacities());
+ ledger.evaluate("BLOCKER", Collections.singleton(ResourceKey.node("B")));
+ DecisionEvent event = new ShadowTrafficCoordinator(
+ map, ledger, new RollingDispatchPolicy()
+ ).run(
+ new TrafficSnapshot(1L, Instant.parse("2026-07-30T05:00:00Z"),
+ "FT546", 2L, "POLICY-DEMO", Collections.emptyList()),
+ Arrays.asList(
+ ShadowCandidate.waiting("SHADOW-1", "V1", false, 2, 5, 1,
+ Collections.singleton(ResourceKey.node("B")), null),
+ ShadowCandidate.waiting("SHADOW-2", "V2", false, 1, 1, 1,
+ Collections.singleton(ResourceKey.node("C")), null)
+ )
+ );
+ long grants = event.getDecisions().stream()
+ .filter(decision -> decision.getType() == DecisionType.GRANT).count();
+ long holds = event.getDecisions().stream()
+ .filter(decision -> decision.getType() == DecisionType.HOLD).count();
+ out.println("SHADOW-SUMMARY mode=OFFLINE_SHADOW decisions="
+ + event.getDecisions().size() + " grants=" + grants + " holds=" + holds + " writes=0");
+ }
}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowTrafficCoordinator.java b/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowTrafficCoordinator.java
new file mode 100644
index 0000000..90c4344
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowTrafficCoordinator.java
@@ -0,0 +1,67 @@
+package com.ft546.wcs.shadow.application;
+
+import com.ft546.wcs.shadow.decision.RollingDispatchPolicy;
+import com.ft546.wcs.shadow.decision.ShadowCandidate;
+import com.ft546.wcs.shadow.decision.ShadowDecision;
+import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
+import com.ft546.wcs.shadow.replay.DecisionEvent;
+import com.ft546.wcs.shadow.resource.ResourceKey;
+import com.ft546.wcs.shadow.resource.ResourceLedger;
+import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.List;
+
+public final class ShadowTrafficCoordinator {
+ private final EffectiveTrafficMap map;
+ private final ResourceLedger authoritativeLedger;
+ private final RollingDispatchPolicy policy;
+
+ public ShadowTrafficCoordinator(EffectiveTrafficMap map, ResourceLedger authoritativeLedger,
+ RollingDispatchPolicy policy) {
+ this.map = map;
+ this.authoritativeLedger = authoritativeLedger;
+ this.policy = policy;
+ }
+
+ public DecisionEvent run(TrafficSnapshot snapshot, List candidates) {
+ if (!map.getMapCode().equals(snapshot.getMapCode())
+ || map.getVersionId() != snapshot.getMapVersion()) {
+ throw new IllegalArgumentException("snapshot map version mismatch");
+ }
+ String hash = hash(snapshot, candidates);
+ List decisions = policy.decide(candidates, authoritativeLedger, map);
+ return new DecisionEvent("shadow-" + snapshot.getTickSeq() + "-" + hash.substring(0, 12),
+ snapshot.getTickSeq(), snapshot.getMapCode(), snapshot.getMapVersion(),
+ snapshot.getPolicyVersion(), hash, decisions);
+ }
+
+ private String hash(TrafficSnapshot snapshot, List candidates) {
+ StringBuilder canonical = new StringBuilder()
+ .append(snapshot.getTickSeq()).append('|')
+ .append(snapshot.getMapCode()).append('|')
+ .append(snapshot.getMapVersion()).append('|')
+ .append(snapshot.getPolicyVersion());
+ for (ShadowCandidate candidate : candidates) {
+ canonical.append('|').append(candidate.getTaskId()).append(':')
+ .append(candidate.getVehicleId()).append(':')
+ .append(candidate.isCommitted()).append(':')
+ .append(candidate.getBusinessPriority()).append(':')
+ .append(candidate.getWaitAge());
+ for (ResourceKey resource : candidate.getResources()) {
+ canonical.append(':').append(resource.value());
+ }
+ }
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(canonical.toString().getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder();
+ for (byte value : digest) hex.append(String.format("%02x", value & 0xff));
+ return hex.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowWriteForbiddenException.java b/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowWriteForbiddenException.java
new file mode 100644
index 0000000..1f8389b
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowWriteForbiddenException.java
@@ -0,0 +1,7 @@
+package com.ft546.wcs.shadow.application;
+
+public final class ShadowWriteForbiddenException extends IllegalStateException {
+ public ShadowWriteForbiddenException(String operation) {
+ super("SHADOW forbids authoritative write: " + operation);
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowWritePort.java b/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowWritePort.java
new file mode 100644
index 0000000..b26c19c
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/application/ShadowWritePort.java
@@ -0,0 +1,15 @@
+package com.ft546.wcs.shadow.application;
+
+public final class ShadowWritePort {
+ public void sendPlcCommand(String vehicleId) {
+ throw new ShadowWriteForbiddenException("PLC " + vehicleId);
+ }
+
+ public void updateBusinessState(String taskId) {
+ throw new ShadowWriteForbiddenException("BUSINESS " + taskId);
+ }
+
+ public void writeReservation(String segmentId) {
+ throw new ShadowWriteForbiddenException("RESERVATION " + segmentId);
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/replay/DecisionEvent.java b/javaTest/src/main/java/com/ft546/wcs/shadow/replay/DecisionEvent.java
new file mode 100644
index 0000000..75b2751
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/replay/DecisionEvent.java
@@ -0,0 +1,36 @@
+package com.ft546.wcs.shadow.replay;
+
+import com.ft546.wcs.shadow.decision.ShadowDecision;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class DecisionEvent {
+ private final String decisionId;
+ private final long tickSeq;
+ private final String mapCode;
+ private final long mapVersion;
+ private final String policyVersion;
+ private final String inputHash;
+ private final List decisions;
+
+ public DecisionEvent(String decisionId, long tickSeq, String mapCode, long mapVersion,
+ String policyVersion, String inputHash, List decisions) {
+ this.decisionId = decisionId;
+ this.tickSeq = tickSeq;
+ this.mapCode = mapCode;
+ this.mapVersion = mapVersion;
+ this.policyVersion = policyVersion;
+ this.inputHash = inputHash;
+ this.decisions = Collections.unmodifiableList(new ArrayList(decisions));
+ }
+
+ public String getDecisionId() { return decisionId; }
+ public long getTickSeq() { return tickSeq; }
+ public String getMapCode() { return mapCode; }
+ public long getMapVersion() { return mapVersion; }
+ public String getPolicyVersion() { return policyVersion; }
+ public String getInputHash() { return inputHash; }
+ public List getDecisions() { return decisions; }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/replay/DecisionReplayService.java b/javaTest/src/main/java/com/ft546/wcs/shadow/replay/DecisionReplayService.java
new file mode 100644
index 0000000..446f650
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/replay/DecisionReplayService.java
@@ -0,0 +1,46 @@
+package com.ft546.wcs.shadow.replay;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ft546.wcs.shadow.decision.DecisionType;
+import com.ft546.wcs.shadow.decision.ShadowDecision;
+import com.ft546.wcs.shadow.resource.ResourceKey;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class DecisionReplayService {
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ public DecisionEvent read(InputStream input) throws IOException {
+ if (input == null) throw new IllegalArgumentException("decision event input is required");
+ JsonNode root = mapper.readTree(input);
+ List decisions = new ArrayList();
+ for (JsonNode node : root.withArray("decisions")) {
+ Set conflicts = new LinkedHashSet();
+ for (JsonNode resource : node.withArray("conflictingResources")) {
+ conflicts.add(ResourceKey.fromValue(resource.asText()));
+ }
+ Set blockers = new LinkedHashSet();
+ for (JsonNode blocker : node.withArray("blockingVehicles")) {
+ blockers.add(blocker.asText());
+ }
+ decisions.add(new ShadowDecision(
+ node.path("taskId").asText(), node.path("vehicleId").asText(),
+ DecisionType.valueOf(node.path("type").asText()),
+ node.path("reasonCode").asText(), conflicts, blockers,
+ node.path("yieldSite").isNull() ? null : node.path("yieldSite").asText()));
+ }
+ return new DecisionEvent(root.path("decisionId").asText(), root.path("tickSeq").asLong(),
+ root.path("mapCode").asText(), root.path("mapVersion").asLong(),
+ root.path("policyVersion").asText(), root.path("inputHash").asText(), decisions);
+ }
+
+ public List replay(DecisionEvent event) {
+ return event.getDecisions();
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java b/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java
index b667b6f..3c19242 100644
--- a/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/resource/ResourceKey.java
@@ -23,6 +23,16 @@ public final class ResourceKey implements Comparable {
return new ResourceKey("CONFLICT:" + required(code));
}
+ public static ResourceKey fromValue(String value) {
+ String normalized = required(value);
+ if (!normalized.startsWith("NODE:")
+ && !normalized.startsWith("EDGE:")
+ && !normalized.startsWith("CONFLICT:")) {
+ throw new IllegalArgumentException("unsupported resource key " + normalized);
+ }
+ return new ResourceKey(normalized);
+ }
+
public String value() {
return value;
}
diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java
index 4ec78ba..f3ce9e6 100644
--- a/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java
+++ b/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java
@@ -31,6 +31,9 @@ class DemoScenarioTest {
assertTrue(text.contains(
"SUMMARY cycles=7 activeQueues=1 history=2 protocolAttempts=3"
));
+ assertTrue(text.contains(
+ "SHADOW-SUMMARY mode=OFFLINE_SHADOW decisions=2 grants=1 holds=1 writes=0"
+ ));
int assign = text.indexOf("ASSIGN task=TASK-OUT-1");
int avoid = text.indexOf("AVOID queue=");
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/ShadowCoordinatorReplayTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/ShadowCoordinatorReplayTest.java
new file mode 100644
index 0000000..5f9611b
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/ShadowCoordinatorReplayTest.java
@@ -0,0 +1,86 @@
+package com.ft546.wcs.shadow;
+
+import com.ft546.wcs.shadow.application.ShadowTrafficCoordinator;
+import com.ft546.wcs.shadow.application.ShadowWriteForbiddenException;
+import com.ft546.wcs.shadow.application.ShadowWritePort;
+import com.ft546.wcs.shadow.decision.DecisionType;
+import com.ft546.wcs.shadow.decision.RollingDispatchPolicy;
+import com.ft546.wcs.shadow.decision.ShadowCandidate;
+import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
+import com.ft546.wcs.shadow.map.TrafficMapCompiler;
+import com.ft546.wcs.shadow.replay.DecisionEvent;
+import com.ft546.wcs.shadow.replay.DecisionReplayService;
+import com.ft546.wcs.shadow.resource.ResourceKey;
+import com.ft546.wcs.shadow.resource.ResourceLedger;
+import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class ShadowCoordinatorReplayTest {
+
+ @Test
+ void shadowPortsRejectEveryAuthoritativeWrite() {
+ ShadowWritePort port = new ShadowWritePort();
+
+ assertThrows(ShadowWriteForbiddenException.class, () -> port.sendPlcCommand("V1"));
+ assertThrows(ShadowWriteForbiddenException.class, () -> port.updateBusinessState("T1"));
+ assertThrows(ShadowWriteForbiddenException.class, () -> port.writeReservation("SEG-1"));
+ }
+
+ @Test
+ void coordinatorProducesStableEventWithoutMutatingAuthoritativeLedger() {
+ EffectiveTrafficMap map = map();
+ ResourceLedger ledger = new ResourceLedger(map.getCapacities());
+ ledger.evaluate("BLOCKER", Collections.singleton(ResourceKey.node("B")));
+ ShadowTrafficCoordinator coordinator =
+ new ShadowTrafficCoordinator(map, ledger, new RollingDispatchPolicy());
+ TrafficSnapshot snapshot =
+ new TrafficSnapshot(9L, Instant.parse("2026-07-30T05:00:00Z"),
+ "FT546", 2L, "POLICY-1", Collections.emptyList());
+ ShadowCandidate candidate = ShadowCandidate.waiting(
+ "T1", "V1", false, 5, 12, 1,
+ Collections.singleton(ResourceKey.node("B")), null);
+
+ DecisionEvent first = coordinator.run(snapshot, Collections.singletonList(candidate));
+ DecisionEvent second = coordinator.run(snapshot, Collections.singletonList(candidate));
+
+ assertEquals(first.getInputHash(), second.getInputHash());
+ assertEquals(DecisionType.HOLD, first.getDecisions().get(0).getType());
+ assertEquals(1, ledger.protectedCount(ResourceKey.node("B")));
+ }
+
+ @Test
+ void readsAndReplaysGoldenDecisionEvent() throws Exception {
+ InputStream input = getClass().getResourceAsStream("/shadow/golden/basic-conflict.json");
+ DecisionReplayService replay = new DecisionReplayService();
+
+ DecisionEvent event = replay.read(input);
+
+ assertEquals("golden-basic-conflict", event.getDecisionId());
+ assertEquals(DecisionType.HOLD, replay.replay(event).get(0).getType());
+ assertEquals("NO_SAFE_YIELD_SITE", replay.replay(event).get(0).getReasonCode());
+ assertEquals(Collections.singleton(ResourceKey.node("B")),
+ replay.replay(event).get(0).getConflictingResources());
+ }
+
+ private EffectiveTrafficMap map() {
+ LinkedHashSet nodes = new LinkedHashSet(Arrays.asList("A", "B", "C"));
+ LinkedHashSet edges =
+ new LinkedHashSet();
+ edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B"));
+ edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
+ return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
+ "FT546", 2L, "ZONE-1", true, "sha", "sha", nodes, edges,
+ new LinkedHashMap(), Collections.singleton("A"),
+ Collections.singleton("C")));
+ }
+}
diff --git a/javaTest/src/test/resources/shadow/golden/basic-conflict.json b/javaTest/src/test/resources/shadow/golden/basic-conflict.json
new file mode 100644
index 0000000..a8662e7
--- /dev/null
+++ b/javaTest/src/test/resources/shadow/golden/basic-conflict.json
@@ -0,0 +1,19 @@
+{
+ "decisionId": "golden-basic-conflict",
+ "tickSeq": 9,
+ "mapCode": "FT546",
+ "mapVersion": 2,
+ "policyVersion": "POLICY-1",
+ "inputHash": "golden-input-sha256",
+ "decisions": [
+ {
+ "taskId": "T1",
+ "vehicleId": "V1",
+ "type": "HOLD",
+ "reasonCode": "NO_SAFE_YIELD_SITE",
+ "conflictingResources": ["NODE:B"],
+ "blockingVehicles": ["BLOCKER"],
+ "yieldSite": null
+ }
+ ]
+}