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 + } + ] +}