feat: replay guarded shadow decisions

This commit is contained in:
zgw
2026-07-30 13:49:28 +08:00
parent 63071fab6f
commit 2cbaceada2
11 changed files with 345 additions and 0 deletions

View File

@@ -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>

View File

@@ -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");
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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=");

View File

@@ -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")));
}
}

View File

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