feat: replay guarded shadow decisions
This commit is contained in:
@@ -14,9 +14,15 @@
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
<junit.version>5.10.2</junit.version>
|
||||
<jackson.version>2.17.2</jackson.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -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<String> nodes = new LinkedHashSet<String>(Arrays.asList("A", "B", "C"));
|
||||
LinkedHashSet<TrafficMapCompiler.EdgeDefinition> edges =
|
||||
new LinkedHashSet<TrafficMapCompiler.EdgeDefinition>();
|
||||
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<ResourceKey, Integer>(),
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ShadowCandidate> candidates) {
|
||||
if (!map.getMapCode().equals(snapshot.getMapCode())
|
||||
|| map.getVersionId() != snapshot.getMapVersion()) {
|
||||
throw new IllegalArgumentException("snapshot map version mismatch");
|
||||
}
|
||||
String hash = hash(snapshot, candidates);
|
||||
List<ShadowDecision> 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<ShadowCandidate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<ShadowDecision> decisions;
|
||||
|
||||
public DecisionEvent(String decisionId, long tickSeq, String mapCode, long mapVersion,
|
||||
String policyVersion, String inputHash, List<ShadowDecision> 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<ShadowDecision>(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<ShadowDecision> getDecisions() { return decisions; }
|
||||
}
|
||||
@@ -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<ShadowDecision> decisions = new ArrayList<ShadowDecision>();
|
||||
for (JsonNode node : root.withArray("decisions")) {
|
||||
Set<ResourceKey> conflicts = new LinkedHashSet<ResourceKey>();
|
||||
for (JsonNode resource : node.withArray("conflictingResources")) {
|
||||
conflicts.add(ResourceKey.fromValue(resource.asText()));
|
||||
}
|
||||
Set<String> blockers = new LinkedHashSet<String>();
|
||||
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<ShadowDecision> replay(DecisionEvent event) {
|
||||
return event.getDecisions();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,16 @@ public final class ResourceKey implements Comparable<ResourceKey> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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=");
|
||||
|
||||
@@ -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<String> nodes = new LinkedHashSet<String>(Arrays.asList("A", "B", "C"));
|
||||
LinkedHashSet<TrafficMapCompiler.EdgeDefinition> edges =
|
||||
new LinkedHashSet<TrafficMapCompiler.EdgeDefinition>();
|
||||
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<ResourceKey, Integer>(), Collections.singleton("A"),
|
||||
Collections.singleton("C")));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user