fix: close shadow safety review gaps
This commit is contained in:
@@ -23,6 +23,7 @@ import com.ft546.wcs.shadow.replay.DecisionEvent;
|
|||||||
import com.ft546.wcs.shadow.resource.ResourceKey;
|
import com.ft546.wcs.shadow.resource.ResourceKey;
|
||||||
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
||||||
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.TrafficVehicleSnapshot;
|
||||||
|
|
||||||
import java.io.PrintStream;
|
import java.io.PrintStream;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -161,7 +162,7 @@ public final class DemoApplication {
|
|||||||
new TrafficMapCompiler.MapDefinition(
|
new TrafficMapCompiler.MapDefinition(
|
||||||
"FT546", 2L, "ZONE-DEMO", true, "demo-sha", "demo-sha",
|
"FT546", 2L, "ZONE-DEMO", true, "demo-sha", "demo-sha",
|
||||||
nodes, edges, new LinkedHashMap<ResourceKey, Integer>(),
|
nodes, edges, new LinkedHashMap<ResourceKey, Integer>(),
|
||||||
Collections.singleton("A"), Collections.singleton("C")
|
Collections.singleton("B"), Collections.singleton("C")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
ResourceLedger ledger = new ResourceLedger(map.getCapacities());
|
ResourceLedger ledger = new ResourceLedger(map.getCapacities());
|
||||||
@@ -170,7 +171,14 @@ public final class DemoApplication {
|
|||||||
map, ledger, new RollingDispatchPolicy()
|
map, ledger, new RollingDispatchPolicy()
|
||||||
).run(
|
).run(
|
||||||
new TrafficSnapshot(1L, Instant.parse("2026-07-30T05:00:00Z"),
|
new TrafficSnapshot(1L, Instant.parse("2026-07-30T05:00:00Z"),
|
||||||
"FT546", 2L, "POLICY-DEMO", Collections.emptyList()),
|
"FT546", 2L, "POLICY-DEMO", Arrays.asList(
|
||||||
|
new TrafficVehicleSnapshot("V1", "BOOT-V1", 1L, 1L,
|
||||||
|
Instant.parse("2026-07-30T04:59:59Z"), "A", "B",
|
||||||
|
Arrays.asList("A", "B"), true, false, false),
|
||||||
|
new TrafficVehicleSnapshot("V2", "BOOT-V2", 1L, 1L,
|
||||||
|
Instant.parse("2026-07-30T04:59:59Z"), "C", null,
|
||||||
|
Collections.singletonList("C"), true, false, false)
|
||||||
|
)),
|
||||||
Arrays.asList(
|
Arrays.asList(
|
||||||
ShadowCandidate.waiting("SHADOW-1", "V1", false, 2, 5, 1,
|
ShadowCandidate.waiting("SHADOW-1", "V1", false, 2, 5, 1,
|
||||||
Collections.singleton(ResourceKey.node("B")), null),
|
Collections.singleton(ResourceKey.node("B")), null),
|
||||||
|
|||||||
@@ -5,19 +5,35 @@ import com.ft546.wcs.shadow.decision.ShadowCandidate;
|
|||||||
import com.ft546.wcs.shadow.decision.ShadowDecision;
|
import com.ft546.wcs.shadow.decision.ShadowDecision;
|
||||||
import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
|
import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
|
||||||
import com.ft546.wcs.shadow.replay.DecisionEvent;
|
import com.ft546.wcs.shadow.replay.DecisionEvent;
|
||||||
|
import com.ft546.wcs.shadow.replay.DecisionReplayInput;
|
||||||
import com.ft546.wcs.shadow.resource.ResourceKey;
|
import com.ft546.wcs.shadow.resource.ResourceKey;
|
||||||
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
||||||
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.TrafficVehicleSnapshot;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.SnapshotFreshnessPolicy;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.ControlMode;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public final class ShadowTrafficCoordinator {
|
public final class ShadowTrafficCoordinator {
|
||||||
private final EffectiveTrafficMap map;
|
private final EffectiveTrafficMap map;
|
||||||
private final ResourceLedger authoritativeLedger;
|
private final ResourceLedger authoritativeLedger;
|
||||||
private final RollingDispatchPolicy policy;
|
private final RollingDispatchPolicy policy;
|
||||||
|
private final SnapshotFreshnessPolicy freshnessPolicy =
|
||||||
|
new SnapshotFreshnessPolicy(Duration.ofSeconds(2));
|
||||||
|
private final Map<String, TrafficVehicleSnapshot> previousSnapshots =
|
||||||
|
new LinkedHashMap<String, TrafficVehicleSnapshot>();
|
||||||
|
|
||||||
public ShadowTrafficCoordinator(EffectiveTrafficMap map, ResourceLedger authoritativeLedger,
|
public ShadowTrafficCoordinator(EffectiveTrafficMap map, ResourceLedger authoritativeLedger,
|
||||||
RollingDispatchPolicy policy) {
|
RollingDispatchPolicy policy) {
|
||||||
@@ -27,30 +43,66 @@ public final class ShadowTrafficCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DecisionEvent run(TrafficSnapshot snapshot, List<ShadowCandidate> candidates) {
|
public DecisionEvent run(TrafficSnapshot snapshot, List<ShadowCandidate> candidates) {
|
||||||
|
if (snapshot.getControlMode() != ControlMode.SHADOW) {
|
||||||
|
throw new IllegalStateException("coordinator accepts SHADOW snapshots only");
|
||||||
|
}
|
||||||
if (!map.getMapCode().equals(snapshot.getMapCode())
|
if (!map.getMapCode().equals(snapshot.getMapCode())
|
||||||
|| map.getVersionId() != snapshot.getMapVersion()) {
|
|| map.getVersionId() != snapshot.getMapVersion()) {
|
||||||
throw new IllegalArgumentException("snapshot map version mismatch");
|
throw new IllegalArgumentException("snapshot map version mismatch");
|
||||||
}
|
}
|
||||||
String hash = hash(snapshot, candidates);
|
Map<String, TrafficVehicleSnapshot> current = new LinkedHashMap<String, TrafficVehicleSnapshot>();
|
||||||
|
for (TrafficVehicleSnapshot vehicle : snapshot.getVehicles()) {
|
||||||
|
SnapshotFreshnessPolicy.SnapshotHealth health = freshnessPolicy.validate(
|
||||||
|
previousSnapshots.get(vehicle.getVehicleId()), vehicle, snapshot.getDecisionTime());
|
||||||
|
if (!health.isHealthy()) {
|
||||||
|
throw new IllegalStateException("vehicle " + vehicle.getVehicleId()
|
||||||
|
+ " quarantined: " + health.getReasonCode());
|
||||||
|
}
|
||||||
|
current.put(vehicle.getVehicleId(), vehicle);
|
||||||
|
}
|
||||||
|
for (ShadowCandidate candidate : candidates) {
|
||||||
|
if (!current.containsKey(candidate.getVehicleId())) {
|
||||||
|
throw new IllegalStateException("candidate has no frozen vehicle snapshot "
|
||||||
|
+ candidate.getVehicleId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String hash = computeInputHash(map, authoritativeLedger, snapshot, candidates);
|
||||||
List<ShadowDecision> decisions = policy.decide(candidates, authoritativeLedger, map);
|
List<ShadowDecision> decisions = policy.decide(candidates, authoritativeLedger, map);
|
||||||
|
previousSnapshots.putAll(current);
|
||||||
return new DecisionEvent("shadow-" + snapshot.getTickSeq() + "-" + hash.substring(0, 12),
|
return new DecisionEvent("shadow-" + snapshot.getTickSeq() + "-" + hash.substring(0, 12),
|
||||||
snapshot.getTickSeq(), snapshot.getMapCode(), snapshot.getMapVersion(),
|
snapshot.getTickSeq(), snapshot.getMapCode(), snapshot.getMapVersion(),
|
||||||
snapshot.getPolicyVersion(), hash, decisions);
|
snapshot.getPolicyVersion(), hash, decisions,
|
||||||
|
new DecisionReplayInput(map, authoritativeLedger, snapshot, candidates));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String hash(TrafficSnapshot snapshot, List<ShadowCandidate> candidates) {
|
public static String computeInputHash(EffectiveTrafficMap map, ResourceLedger ledger,
|
||||||
|
TrafficSnapshot snapshot, List<ShadowCandidate> candidates) {
|
||||||
StringBuilder canonical = new StringBuilder()
|
StringBuilder canonical = new StringBuilder()
|
||||||
.append(snapshot.getTickSeq()).append('|')
|
.append(snapshot.getTickSeq()).append('|')
|
||||||
.append(snapshot.getMapCode()).append('|')
|
.append(snapshot.getMapCode()).append('|')
|
||||||
.append(snapshot.getMapVersion()).append('|')
|
.append(snapshot.getMapVersion()).append('|')
|
||||||
.append(snapshot.getPolicyVersion());
|
.append(snapshot.getPolicyVersion());
|
||||||
for (ShadowCandidate candidate : candidates) {
|
List<ResourceKey> capacityKeys = new ArrayList<ResourceKey>(map.getCapacities().keySet());
|
||||||
|
Collections.sort(capacityKeys);
|
||||||
|
for (ResourceKey key : capacityKeys) {
|
||||||
|
canonical.append("|CAP:").append(key.value()).append('=')
|
||||||
|
.append(map.getCapacities().get(key));
|
||||||
|
List<String> holders = new ArrayList<String>(ledger.holdersFor(key));
|
||||||
|
Collections.sort(holders);
|
||||||
|
for (String holder : holders) canonical.append(':').append(holder);
|
||||||
|
}
|
||||||
|
List<ShadowCandidate> ordered = new ArrayList<ShadowCandidate>(candidates);
|
||||||
|
Collections.sort(ordered, Comparator.comparing(ShadowCandidate::getTaskId)
|
||||||
|
.thenComparing(ShadowCandidate::getVehicleId));
|
||||||
|
for (ShadowCandidate candidate : ordered) {
|
||||||
canonical.append('|').append(candidate.getTaskId()).append(':')
|
canonical.append('|').append(candidate.getTaskId()).append(':')
|
||||||
.append(candidate.getVehicleId()).append(':')
|
.append(candidate.getVehicleId()).append(':')
|
||||||
.append(candidate.isCommitted()).append(':')
|
.append(candidate.isCommitted()).append(':')
|
||||||
.append(candidate.getBusinessPriority()).append(':')
|
.append(candidate.getBusinessPriority()).append(':')
|
||||||
.append(candidate.getWaitAge());
|
.append(candidate.getWaitAge());
|
||||||
for (ResourceKey resource : candidate.getResources()) {
|
List<ResourceKey> resources = new ArrayList<ResourceKey>(candidate.getResources());
|
||||||
|
Collections.sort(resources);
|
||||||
|
for (ResourceKey resource : resources) {
|
||||||
canonical.append(':').append(resource.value());
|
canonical.append(':').append(resource.value());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ public final class RollingDispatchPolicy {
|
|||||||
List<ShadowDecision> decisions = new ArrayList<ShadowDecision>();
|
List<ShadowDecision> decisions = new ArrayList<ShadowDecision>();
|
||||||
for (ShadowCandidate candidate : candidates) {
|
for (ShadowCandidate candidate : candidates) {
|
||||||
if (candidate.isCommitted()) {
|
if (candidate.isCommitted()) {
|
||||||
decisions.add(decision(candidate, DecisionType.GRANT, "COMMITTED_CONTINUATION",
|
ResourceLedger.ProtectionResult committed =
|
||||||
Collections.<ResourceKey>emptySet(), Collections.<String>emptySet(), null));
|
simulation.evaluate(candidate.getVehicleId(), candidate.getResources());
|
||||||
|
decisions.add(committed.isGranted()
|
||||||
|
? decision(candidate, DecisionType.GRANT, "COMMITTED_CONTINUATION",
|
||||||
|
Collections.<ResourceKey>emptySet(), Collections.<String>emptySet(), null)
|
||||||
|
: decision(candidate, DecisionType.HOLD, "COMMITTED_RESOURCE_MISMATCH",
|
||||||
|
committed.getConflictingResources(), committed.getBlockingVehicles(), null));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ResourceLedger.ProtectionResult result =
|
ResourceLedger.ProtectionResult result =
|
||||||
@@ -31,6 +36,11 @@ public final class RollingDispatchPolicy {
|
|||||||
Collections.<ResourceKey>emptySet(), Collections.<String>emptySet(), null));
|
Collections.<ResourceKey>emptySet(), Collections.<String>emptySet(), null));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (result.isUnknownResource()) {
|
||||||
|
decisions.add(decision(candidate, DecisionType.HOLD, "UNKNOWN_RESOURCE",
|
||||||
|
result.getConflictingResources(), Collections.<String>emptySet(), null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
boolean cycle = waits.wouldCreateCycle(candidate.getVehicleId(), result.getBlockingVehicles());
|
boolean cycle = waits.wouldCreateCycle(candidate.getVehicleId(), result.getBlockingVehicles());
|
||||||
waits.addWait(candidate.getVehicleId(), result.getBlockingVehicles());
|
waits.addWait(candidate.getVehicleId(), result.getBlockingVehicles());
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.ft546.wcs.shadow.resource.ResourceKey;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class ShadowDecision {
|
public final class ShadowDecision {
|
||||||
private final String taskId;
|
private final String taskId;
|
||||||
@@ -36,4 +37,23 @@ public final class ShadowDecision {
|
|||||||
public Set<ResourceKey> getConflictingResources() { return conflictingResources; }
|
public Set<ResourceKey> getConflictingResources() { return conflictingResources; }
|
||||||
public Set<String> getBlockingVehicles() { return blockingVehicles; }
|
public Set<String> getBlockingVehicles() { return blockingVehicles; }
|
||||||
public String getYieldSite() { return yieldSite; }
|
public String getYieldSite() { return yieldSite; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object other) {
|
||||||
|
if (!(other instanceof ShadowDecision)) return false;
|
||||||
|
ShadowDecision that = (ShadowDecision) other;
|
||||||
|
return Objects.equals(taskId, that.taskId)
|
||||||
|
&& Objects.equals(vehicleId, that.vehicleId)
|
||||||
|
&& type == that.type
|
||||||
|
&& Objects.equals(reasonCode, that.reasonCode)
|
||||||
|
&& Objects.equals(conflictingResources, that.conflictingResources)
|
||||||
|
&& Objects.equals(blockingVehicles, that.blockingVehicles)
|
||||||
|
&& Objects.equals(yieldSite, that.yieldSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(taskId, vehicleId, type, reasonCode,
|
||||||
|
conflictingResources, blockingVehicles, yieldSite);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,23 +19,46 @@ public final class TrafficMapCompiler {
|
|||||||
if (!definition.baseChecksum.equals(definition.configuredChecksum)) {
|
if (!definition.baseChecksum.equals(definition.configuredChecksum)) {
|
||||||
throw new IllegalArgumentException("base map checksum mismatch");
|
throw new IllegalArgumentException("base map checksum mismatch");
|
||||||
}
|
}
|
||||||
|
if (definition.versionId <= 0 || definition.nodes.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("positive map version and nodes are required");
|
||||||
|
}
|
||||||
|
if (!definition.nodes.containsAll(definition.safeWaitSites)
|
||||||
|
|| !definition.nodes.containsAll(definition.safeYieldSites)) {
|
||||||
|
throw new IllegalArgumentException("safe site references missing node");
|
||||||
|
}
|
||||||
LinkedHashSet<ResourceKey> edges = new LinkedHashSet<ResourceKey>();
|
LinkedHashSet<ResourceKey> edges = new LinkedHashSet<ResourceKey>();
|
||||||
LinkedHashMap<ResourceKey, Integer> capacities = new LinkedHashMap<ResourceKey, Integer>();
|
LinkedHashMap<ResourceKey, Integer> capacities = new LinkedHashMap<ResourceKey, Integer>();
|
||||||
|
LinkedHashMap<String, Integer> degree = new LinkedHashMap<String, Integer>();
|
||||||
for (String node : definition.nodes) {
|
for (String node : definition.nodes) {
|
||||||
capacities.put(ResourceKey.node(node), 1);
|
capacities.put(ResourceKey.node(node), 1);
|
||||||
|
degree.put(node, 0);
|
||||||
}
|
}
|
||||||
for (EdgeDefinition edge : definition.edges) {
|
for (EdgeDefinition edge : definition.edges) {
|
||||||
if (!definition.nodes.contains(edge.first) || !definition.nodes.contains(edge.second)) {
|
if (!definition.nodes.contains(edge.first) || !definition.nodes.contains(edge.second)) {
|
||||||
throw new IllegalArgumentException("edge references missing node");
|
throw new IllegalArgumentException("edge references missing node");
|
||||||
}
|
}
|
||||||
|
if (edge.first.equals(edge.second)) {
|
||||||
|
throw new IllegalArgumentException("self edge is not allowed");
|
||||||
|
}
|
||||||
ResourceKey key = ResourceKey.edge(edge.first, edge.second);
|
ResourceKey key = ResourceKey.edge(edge.first, edge.second);
|
||||||
edges.add(key);
|
if (!edges.add(key)) throw new IllegalArgumentException("duplicate edge " + key);
|
||||||
capacities.put(key, 1);
|
capacities.put(key, 1);
|
||||||
|
degree.put(edge.first, degree.get(edge.first) + 1);
|
||||||
|
degree.put(edge.second, degree.get(edge.second) + 1);
|
||||||
|
}
|
||||||
|
for (String waitSite : definition.safeWaitSites) {
|
||||||
|
if (degree.get(waitSite) <= 1) {
|
||||||
|
throw new IllegalArgumentException("safe wait site is a unique exit " + waitSite);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (Map.Entry<ResourceKey, Integer> entry : definition.explicitCapacities.entrySet()) {
|
for (Map.Entry<ResourceKey, Integer> entry : definition.explicitCapacities.entrySet()) {
|
||||||
if (entry.getValue() == null || entry.getValue() <= 0) {
|
if (entry.getValue() == null || entry.getValue() <= 0) {
|
||||||
throw new IllegalArgumentException("resource capacity must be positive");
|
throw new IllegalArgumentException("resource capacity must be positive");
|
||||||
}
|
}
|
||||||
|
if (!entry.getKey().value().startsWith("CONFLICT:")
|
||||||
|
&& !capacities.containsKey(entry.getKey())) {
|
||||||
|
throw new IllegalArgumentException("capacity references unknown resource " + entry.getKey());
|
||||||
|
}
|
||||||
capacities.put(entry.getKey(), entry.getValue());
|
capacities.put(entry.getKey(), entry.getValue());
|
||||||
}
|
}
|
||||||
return new EffectiveTrafficMap(definition.mapCode, definition.versionId, definition.zoneCode,
|
return new EffectiveTrafficMap(definition.mapCode, definition.versionId, definition.zoneCode,
|
||||||
@@ -87,6 +110,30 @@ public final class TrafficMapCompiler {
|
|||||||
this.safeYieldSites = immutable(safeYieldSites);
|
this.safeYieldSites = immutable(safeYieldSites);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public MapDefinition withSafeWaitSites(Set<String> sites) {
|
||||||
|
return new MapDefinition(mapCode, versionId, zoneCode, confirmed, baseChecksum,
|
||||||
|
configuredChecksum, nodes, edges, explicitCapacities, sites, safeYieldSites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MapDefinition withAdditionalEdge(EdgeDefinition edge) {
|
||||||
|
Set<EdgeDefinition> changed = new LinkedHashSet<EdgeDefinition>(edges);
|
||||||
|
changed.add(edge);
|
||||||
|
return new MapDefinition(mapCode, versionId, zoneCode, confirmed, baseChecksum,
|
||||||
|
configuredChecksum, nodes, changed, explicitCapacities, safeWaitSites, safeYieldSites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MapDefinition withCapacity(ResourceKey key, int capacity) {
|
||||||
|
Map<ResourceKey, Integer> changed = new LinkedHashMap<ResourceKey, Integer>(explicitCapacities);
|
||||||
|
changed.put(key, capacity);
|
||||||
|
return new MapDefinition(mapCode, versionId, zoneCode, confirmed, baseChecksum,
|
||||||
|
configuredChecksum, nodes, edges, changed, safeWaitSites, safeYieldSites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MapDefinition withVersionId(long changedVersionId) {
|
||||||
|
return new MapDefinition(mapCode, changedVersionId, zoneCode, confirmed, baseChecksum,
|
||||||
|
configuredChecksum, nodes, edges, explicitCapacities, safeWaitSites, safeYieldSites);
|
||||||
|
}
|
||||||
|
|
||||||
private static <T> Set<T> immutable(Set<T> source) {
|
private static <T> Set<T> immutable(Set<T> source) {
|
||||||
return Collections.unmodifiableSet(new LinkedHashSet<T>(source));
|
return Collections.unmodifiableSet(new LinkedHashSet<T>(source));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,16 @@ public final class DecisionEvent {
|
|||||||
private final String policyVersion;
|
private final String policyVersion;
|
||||||
private final String inputHash;
|
private final String inputHash;
|
||||||
private final List<ShadowDecision> decisions;
|
private final List<ShadowDecision> decisions;
|
||||||
|
private final DecisionReplayInput replayInput;
|
||||||
|
|
||||||
public DecisionEvent(String decisionId, long tickSeq, String mapCode, long mapVersion,
|
public DecisionEvent(String decisionId, long tickSeq, String mapCode, long mapVersion,
|
||||||
String policyVersion, String inputHash, List<ShadowDecision> decisions) {
|
String policyVersion, String inputHash, List<ShadowDecision> decisions) {
|
||||||
|
this(decisionId, tickSeq, mapCode, mapVersion, policyVersion, inputHash, decisions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DecisionEvent(String decisionId, long tickSeq, String mapCode, long mapVersion,
|
||||||
|
String policyVersion, String inputHash, List<ShadowDecision> decisions,
|
||||||
|
DecisionReplayInput replayInput) {
|
||||||
this.decisionId = decisionId;
|
this.decisionId = decisionId;
|
||||||
this.tickSeq = tickSeq;
|
this.tickSeq = tickSeq;
|
||||||
this.mapCode = mapCode;
|
this.mapCode = mapCode;
|
||||||
@@ -24,6 +31,7 @@ public final class DecisionEvent {
|
|||||||
this.policyVersion = policyVersion;
|
this.policyVersion = policyVersion;
|
||||||
this.inputHash = inputHash;
|
this.inputHash = inputHash;
|
||||||
this.decisions = Collections.unmodifiableList(new ArrayList<ShadowDecision>(decisions));
|
this.decisions = Collections.unmodifiableList(new ArrayList<ShadowDecision>(decisions));
|
||||||
|
this.replayInput = replayInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDecisionId() { return decisionId; }
|
public String getDecisionId() { return decisionId; }
|
||||||
@@ -33,4 +41,15 @@ public final class DecisionEvent {
|
|||||||
public String getPolicyVersion() { return policyVersion; }
|
public String getPolicyVersion() { return policyVersion; }
|
||||||
public String getInputHash() { return inputHash; }
|
public String getInputHash() { return inputHash; }
|
||||||
public List<ShadowDecision> getDecisions() { return decisions; }
|
public List<ShadowDecision> getDecisions() { return decisions; }
|
||||||
|
public DecisionReplayInput getReplayInput() { return replayInput; }
|
||||||
|
|
||||||
|
public DecisionEvent withInputHash(String changedHash) {
|
||||||
|
return new DecisionEvent(decisionId, tickSeq, mapCode, mapVersion, policyVersion,
|
||||||
|
changedHash, decisions, replayInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DecisionEvent withDecisions(List<ShadowDecision> changedDecisions) {
|
||||||
|
return new DecisionEvent(decisionId, tickSeq, mapCode, mapVersion, policyVersion,
|
||||||
|
inputHash, changedDecisions, replayInput);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ft546.wcs.shadow.replay;
|
||||||
|
|
||||||
|
import com.ft546.wcs.shadow.decision.ShadowCandidate;
|
||||||
|
import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
|
||||||
|
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class DecisionReplayInput {
|
||||||
|
private final EffectiveTrafficMap map;
|
||||||
|
private final ResourceLedger ledger;
|
||||||
|
private final TrafficSnapshot snapshot;
|
||||||
|
private final List<ShadowCandidate> candidates;
|
||||||
|
|
||||||
|
public DecisionReplayInput(EffectiveTrafficMap map, ResourceLedger ledger,
|
||||||
|
TrafficSnapshot snapshot, List<ShadowCandidate> candidates) {
|
||||||
|
this.map = map;
|
||||||
|
this.ledger = ledger.copy();
|
||||||
|
this.snapshot = snapshot;
|
||||||
|
this.candidates = Collections.unmodifiableList(new ArrayList<ShadowCandidate>(candidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
public EffectiveTrafficMap getMap() { return map; }
|
||||||
|
public ResourceLedger freshLedger() { return ledger.copy(); }
|
||||||
|
public TrafficSnapshot getSnapshot() { return snapshot; }
|
||||||
|
public List<ShadowCandidate> getCandidates() { return candidates; }
|
||||||
|
ResourceLedger storedLedger() { return ledger; }
|
||||||
|
}
|
||||||
@@ -4,13 +4,24 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.ft546.wcs.shadow.decision.DecisionType;
|
import com.ft546.wcs.shadow.decision.DecisionType;
|
||||||
import com.ft546.wcs.shadow.decision.ShadowDecision;
|
import com.ft546.wcs.shadow.decision.ShadowDecision;
|
||||||
|
import com.ft546.wcs.shadow.decision.ShadowCandidate;
|
||||||
|
import com.ft546.wcs.shadow.decision.RollingDispatchPolicy;
|
||||||
|
import com.ft546.wcs.shadow.application.ShadowTrafficCoordinator;
|
||||||
|
import com.ft546.wcs.shadow.map.EffectiveTrafficMap;
|
||||||
|
import com.ft546.wcs.shadow.map.TrafficMapCompiler;
|
||||||
import com.ft546.wcs.shadow.resource.ResourceKey;
|
import com.ft546.wcs.shadow.resource.ResourceKey;
|
||||||
|
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public final class DecisionReplayService {
|
public final class DecisionReplayService {
|
||||||
@@ -19,6 +30,66 @@ public final class DecisionReplayService {
|
|||||||
public DecisionEvent read(InputStream input) throws IOException {
|
public DecisionEvent read(InputStream input) throws IOException {
|
||||||
if (input == null) throw new IllegalArgumentException("decision event input is required");
|
if (input == null) throw new IllegalArgumentException("decision event input is required");
|
||||||
JsonNode root = mapper.readTree(input);
|
JsonNode root = mapper.readTree(input);
|
||||||
|
requireText(root, "decisionId");
|
||||||
|
requireText(root, "mapCode");
|
||||||
|
requireText(root, "policyVersion");
|
||||||
|
requireText(root, "inputHash");
|
||||||
|
requireArray(root, "nodes");
|
||||||
|
requireArray(root, "edges");
|
||||||
|
requireArray(root, "candidates");
|
||||||
|
requireArray(root, "decisions");
|
||||||
|
long tickSeq = requirePositive(root, "tickSeq");
|
||||||
|
long mapVersion = requirePositive(root, "mapVersion");
|
||||||
|
|
||||||
|
Set<String> nodes = strings(root.withArray("nodes"));
|
||||||
|
Set<TrafficMapCompiler.EdgeDefinition> edges =
|
||||||
|
new LinkedHashSet<TrafficMapCompiler.EdgeDefinition>();
|
||||||
|
for (JsonNode edge : root.withArray("edges")) {
|
||||||
|
if (!edge.isArray() || edge.size() != 2) {
|
||||||
|
throw new IllegalArgumentException("edge must contain exactly two sites");
|
||||||
|
}
|
||||||
|
edges.add(new TrafficMapCompiler.EdgeDefinition(edge.get(0).asText(), edge.get(1).asText()));
|
||||||
|
}
|
||||||
|
Map<ResourceKey, Integer> capacities = new LinkedHashMap<ResourceKey, Integer>();
|
||||||
|
JsonNode capacityNode = root.path("explicitCapacities");
|
||||||
|
if (!capacityNode.isObject()) throw new IllegalArgumentException("explicitCapacities is required");
|
||||||
|
java.util.Iterator<Map.Entry<String, JsonNode>> capacityFields = capacityNode.fields();
|
||||||
|
while (capacityFields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> entry = capacityFields.next();
|
||||||
|
capacities.put(ResourceKey.fromValue(entry.getKey()), entry.getValue().asInt());
|
||||||
|
}
|
||||||
|
EffectiveTrafficMap map = new TrafficMapCompiler().compile(
|
||||||
|
new TrafficMapCompiler.MapDefinition(root.path("mapCode").asText(), mapVersion,
|
||||||
|
requireText(root, "zoneCode"), true,
|
||||||
|
requireText(root, "checksum"), requireText(root, "checksum"),
|
||||||
|
nodes, edges, capacities, strings(root.withArray("safeWaitSites")),
|
||||||
|
strings(root.withArray("safeYieldSites"))));
|
||||||
|
ResourceLedger ledger = new ResourceLedger(map.getCapacities());
|
||||||
|
JsonNode holdersNode = root.path("holders");
|
||||||
|
if (!holdersNode.isObject()) throw new IllegalArgumentException("holders is required");
|
||||||
|
java.util.Iterator<Map.Entry<String, JsonNode>> holderFields = holdersNode.fields();
|
||||||
|
while (holderFields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> entry = holderFields.next();
|
||||||
|
ResourceKey resource = ResourceKey.fromValue(entry.getKey());
|
||||||
|
for (JsonNode holder : entry.getValue()) {
|
||||||
|
if (!ledger.evaluate(holder.asText(), Collections.singleton(resource)).isGranted()) {
|
||||||
|
throw new IllegalArgumentException("holder snapshot exceeds resource capacity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<ShadowCandidate> candidates = new ArrayList<ShadowCandidate>();
|
||||||
|
for (JsonNode candidate : root.withArray("candidates")) {
|
||||||
|
Set<ResourceKey> resources = resourceKeys(candidate.withArray("resources"));
|
||||||
|
candidates.add(candidate.path("committed").asBoolean()
|
||||||
|
? ShadowCandidate.committed(requireText(candidate, "taskId"),
|
||||||
|
requireText(candidate, "vehicleId"), resources)
|
||||||
|
: ShadowCandidate.waiting(requireText(candidate, "taskId"),
|
||||||
|
requireText(candidate, "vehicleId"), candidate.path("emergency").asBoolean(),
|
||||||
|
candidate.path("businessPriority").asInt(), candidate.path("waitAge").asLong(),
|
||||||
|
candidate.path("clearanceCost").asInt(), resources,
|
||||||
|
candidate.path("safeYieldSite").isNull() ? null
|
||||||
|
: candidate.path("safeYieldSite").asText()));
|
||||||
|
}
|
||||||
List<ShadowDecision> decisions = new ArrayList<ShadowDecision>();
|
List<ShadowDecision> decisions = new ArrayList<ShadowDecision>();
|
||||||
for (JsonNode node : root.withArray("decisions")) {
|
for (JsonNode node : root.withArray("decisions")) {
|
||||||
Set<ResourceKey> conflicts = new LinkedHashSet<ResourceKey>();
|
Set<ResourceKey> conflicts = new LinkedHashSet<ResourceKey>();
|
||||||
@@ -35,12 +106,63 @@ public final class DecisionReplayService {
|
|||||||
node.path("reasonCode").asText(), conflicts, blockers,
|
node.path("reasonCode").asText(), conflicts, blockers,
|
||||||
node.path("yieldSite").isNull() ? null : node.path("yieldSite").asText()));
|
node.path("yieldSite").isNull() ? null : node.path("yieldSite").asText()));
|
||||||
}
|
}
|
||||||
return new DecisionEvent(root.path("decisionId").asText(), root.path("tickSeq").asLong(),
|
TrafficSnapshot snapshot = new TrafficSnapshot(tickSeq, Instant.EPOCH,
|
||||||
root.path("mapCode").asText(), root.path("mapVersion").asLong(),
|
root.path("mapCode").asText(), mapVersion,
|
||||||
root.path("policyVersion").asText(), root.path("inputHash").asText(), decisions);
|
root.path("policyVersion").asText(), Collections.emptyList());
|
||||||
|
DecisionReplayInput replayInput = new DecisionReplayInput(map, ledger, snapshot, candidates);
|
||||||
|
return new DecisionEvent(root.path("decisionId").asText(), tickSeq,
|
||||||
|
root.path("mapCode").asText(), mapVersion, root.path("policyVersion").asText(),
|
||||||
|
root.path("inputHash").asText(), decisions, replayInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ShadowDecision> replay(DecisionEvent event) {
|
public List<ShadowDecision> replay(DecisionEvent event) {
|
||||||
return event.getDecisions();
|
if (event.getReplayInput() == null) {
|
||||||
|
throw new IllegalStateException("event has no replay input");
|
||||||
|
}
|
||||||
|
DecisionReplayInput input = event.getReplayInput();
|
||||||
|
String actualHash = ShadowTrafficCoordinator.computeInputHash(
|
||||||
|
input.getMap(), input.storedLedger(), input.getSnapshot(), input.getCandidates());
|
||||||
|
if (!actualHash.equals(event.getInputHash())) {
|
||||||
|
throw new IllegalStateException("input hash mismatch expected=" + actualHash
|
||||||
|
+ " actual=" + event.getInputHash());
|
||||||
|
}
|
||||||
|
List<ShadowDecision> recomputed = new RollingDispatchPolicy().decide(
|
||||||
|
input.getCandidates(), input.freshLedger(), input.getMap());
|
||||||
|
if (!recomputed.equals(event.getDecisions())) {
|
||||||
|
throw new IllegalStateException("recorded decisions do not match replay");
|
||||||
|
}
|
||||||
|
return recomputed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
if (value == null || !value.isTextual() || value.asText().trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(field + " is required");
|
||||||
|
}
|
||||||
|
return value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long requirePositive(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
if (value == null || !value.canConvertToLong() || value.asLong() <= 0) {
|
||||||
|
throw new IllegalArgumentException(field + " must be positive");
|
||||||
|
}
|
||||||
|
return value.asLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireArray(JsonNode node, String field) {
|
||||||
|
if (!node.path(field).isArray()) throw new IllegalArgumentException(field + " must be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> strings(JsonNode array) {
|
||||||
|
Set<String> result = new LinkedHashSet<String>();
|
||||||
|
for (JsonNode value : array) result.add(value.asText());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<ResourceKey> resourceKeys(JsonNode array) {
|
||||||
|
Set<ResourceKey> result = new LinkedHashSet<ResourceKey>();
|
||||||
|
for (JsonNode value : array) result.add(ResourceKey.fromValue(value.asText()));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ public final class ResourceLedger {
|
|||||||
LinkedHashSet<ResourceKey> conflicts = new LinkedHashSet<ResourceKey>();
|
LinkedHashSet<ResourceKey> conflicts = new LinkedHashSet<ResourceKey>();
|
||||||
LinkedHashSet<String> blockers = new LinkedHashSet<String>();
|
LinkedHashSet<String> blockers = new LinkedHashSet<String>();
|
||||||
for (ResourceKey key : requested) {
|
for (ResourceKey key : requested) {
|
||||||
|
if (!capacities.containsKey(key)) {
|
||||||
|
conflicts.add(key);
|
||||||
|
return new ProtectionResult(false, true, conflicts, blockers);
|
||||||
|
}
|
||||||
int capacity = capacity(key);
|
int capacity = capacity(key);
|
||||||
Set<String> current = holders.containsKey(key) ? holders.get(key) : Collections.<String>emptySet();
|
Set<String> current = holders.containsKey(key) ? holders.get(key) : Collections.<String>emptySet();
|
||||||
int occupiedByOthers = current.contains(vehicleId) ? current.size() - 1 : current.size();
|
int occupiedByOthers = current.contains(vehicleId) ? current.size() - 1 : current.size();
|
||||||
@@ -33,7 +37,7 @@ public final class ResourceLedger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!conflicts.isEmpty()) {
|
if (!conflicts.isEmpty()) {
|
||||||
return new ProtectionResult(false, conflicts, blockers);
|
return new ProtectionResult(false, false, conflicts, blockers);
|
||||||
}
|
}
|
||||||
for (ResourceKey key : requested) {
|
for (ResourceKey key : requested) {
|
||||||
if (!holders.containsKey(key)) {
|
if (!holders.containsKey(key)) {
|
||||||
@@ -41,7 +45,8 @@ public final class ResourceLedger {
|
|||||||
}
|
}
|
||||||
holders.get(key).add(vehicleId);
|
holders.get(key).add(vehicleId);
|
||||||
}
|
}
|
||||||
return new ProtectionResult(true, Collections.<ResourceKey>emptySet(), Collections.<String>emptySet());
|
return new ProtectionResult(true, false,
|
||||||
|
Collections.<ResourceKey>emptySet(), Collections.<String>emptySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void release(String vehicleId, Collection<ResourceKey> resources) {
|
public void release(String vehicleId, Collection<ResourceKey> resources) {
|
||||||
@@ -71,6 +76,19 @@ public final class ResourceLedger {
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<ResourceKey, Integer> getCapacities() {
|
||||||
|
return capacities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<ResourceKey, Set<String>> snapshotHolders() {
|
||||||
|
Map<ResourceKey, Set<String>> snapshot = new LinkedHashMap<ResourceKey, Set<String>>();
|
||||||
|
for (Map.Entry<ResourceKey, LinkedHashSet<String>> entry : holders.entrySet()) {
|
||||||
|
snapshot.put(entry.getKey(), Collections.unmodifiableSet(
|
||||||
|
new LinkedHashSet<String>(entry.getValue())));
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableMap(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
private int capacity(ResourceKey key) {
|
private int capacity(ResourceKey key) {
|
||||||
Integer capacity = capacities.get(key);
|
Integer capacity = capacities.get(key);
|
||||||
if (capacity == null) {
|
if (capacity == null) {
|
||||||
@@ -81,12 +99,15 @@ public final class ResourceLedger {
|
|||||||
|
|
||||||
public static final class ProtectionResult {
|
public static final class ProtectionResult {
|
||||||
private final boolean granted;
|
private final boolean granted;
|
||||||
|
private final boolean unknownResource;
|
||||||
private final Set<ResourceKey> conflictingResources;
|
private final Set<ResourceKey> conflictingResources;
|
||||||
private final Set<String> blockingVehicles;
|
private final Set<String> blockingVehicles;
|
||||||
|
|
||||||
private ProtectionResult(boolean granted, Set<ResourceKey> conflictingResources,
|
private ProtectionResult(boolean granted, boolean unknownResource,
|
||||||
|
Set<ResourceKey> conflictingResources,
|
||||||
Set<String> blockingVehicles) {
|
Set<String> blockingVehicles) {
|
||||||
this.granted = granted;
|
this.granted = granted;
|
||||||
|
this.unknownResource = unknownResource;
|
||||||
this.conflictingResources = Collections.unmodifiableSet(
|
this.conflictingResources = Collections.unmodifiableSet(
|
||||||
new LinkedHashSet<ResourceKey>(conflictingResources));
|
new LinkedHashSet<ResourceKey>(conflictingResources));
|
||||||
this.blockingVehicles = Collections.unmodifiableSet(
|
this.blockingVehicles = Collections.unmodifiableSet(
|
||||||
@@ -94,6 +115,7 @@ public final class ResourceLedger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isGranted() { return granted; }
|
public boolean isGranted() { return granted; }
|
||||||
|
public boolean isUnknownResource() { return unknownResource; }
|
||||||
public Set<ResourceKey> getConflictingResources() { return conflictingResources; }
|
public Set<ResourceKey> getConflictingResources() { return conflictingResources; }
|
||||||
public Set<String> getBlockingVehicles() { return blockingVehicles; }
|
public Set<String> getBlockingVehicles() { return blockingVehicles; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.ft546.wcs.shadow.snapshot;
|
||||||
|
|
||||||
|
public enum ControlMode {
|
||||||
|
LEGACY, SHADOW, ACTIVE
|
||||||
|
}
|
||||||
@@ -11,15 +11,23 @@ public final class TrafficSnapshot {
|
|||||||
private final String mapCode;
|
private final String mapCode;
|
||||||
private final long mapVersion;
|
private final long mapVersion;
|
||||||
private final String policyVersion;
|
private final String policyVersion;
|
||||||
|
private final ControlMode controlMode;
|
||||||
private final List<TrafficVehicleSnapshot> vehicles;
|
private final List<TrafficVehicleSnapshot> vehicles;
|
||||||
|
|
||||||
public TrafficSnapshot(long tickSeq, Instant decisionTime, String mapCode, long mapVersion,
|
public TrafficSnapshot(long tickSeq, Instant decisionTime, String mapCode, long mapVersion,
|
||||||
String policyVersion, List<TrafficVehicleSnapshot> vehicles) {
|
String policyVersion, List<TrafficVehicleSnapshot> vehicles) {
|
||||||
|
this(tickSeq, decisionTime, mapCode, mapVersion, policyVersion, ControlMode.SHADOW, vehicles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrafficSnapshot(long tickSeq, Instant decisionTime, String mapCode, long mapVersion,
|
||||||
|
String policyVersion, ControlMode controlMode,
|
||||||
|
List<TrafficVehicleSnapshot> vehicles) {
|
||||||
this.tickSeq = tickSeq;
|
this.tickSeq = tickSeq;
|
||||||
this.decisionTime = decisionTime;
|
this.decisionTime = decisionTime;
|
||||||
this.mapCode = mapCode;
|
this.mapCode = mapCode;
|
||||||
this.mapVersion = mapVersion;
|
this.mapVersion = mapVersion;
|
||||||
this.policyVersion = policyVersion;
|
this.policyVersion = policyVersion;
|
||||||
|
this.controlMode = controlMode;
|
||||||
this.vehicles = Collections.unmodifiableList(new ArrayList<TrafficVehicleSnapshot>(vehicles));
|
this.vehicles = Collections.unmodifiableList(new ArrayList<TrafficVehicleSnapshot>(vehicles));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,5 +36,6 @@ public final class TrafficSnapshot {
|
|||||||
public String getMapCode() { return mapCode; }
|
public String getMapCode() { return mapCode; }
|
||||||
public long getMapVersion() { return mapVersion; }
|
public long getMapVersion() { return mapVersion; }
|
||||||
public String getPolicyVersion() { return policyVersion; }
|
public String getPolicyVersion() { return policyVersion; }
|
||||||
|
public ControlMode getControlMode() { return controlMode; }
|
||||||
public List<TrafficVehicleSnapshot> getVehicles() { return vehicles; }
|
public List<TrafficVehicleSnapshot> getVehicles() { return vehicles; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ class MapAndResourceLedgerTest {
|
|||||||
() -> compiler.compile(definition("sha-546", "sha-546", false)));
|
() -> compiler.compile(definition("sha-546", "sha-546", false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsafeWaitSitesSelfEdgesAndUnknownCapacityResources() {
|
||||||
|
TrafficMapCompiler compiler = new TrafficMapCompiler();
|
||||||
|
TrafficMapCompiler.MapDefinition base = definition("sha-546", "sha-546", true);
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> compiler.compile(
|
||||||
|
base.withSafeWaitSites(Collections.singleton("A"))));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> compiler.compile(
|
||||||
|
base.withAdditionalEdge(new TrafficMapCompiler.EdgeDefinition("B", "B"))));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> compiler.compile(
|
||||||
|
base.withCapacity(ResourceKey.node("Z"), 1)));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> compiler.compile(base.withVersionId(0L)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void enforcesNodeEdgeAndConfiguredCapacity() {
|
void enforcesNodeEdgeAndConfiguredCapacity() {
|
||||||
EffectiveTrafficMap map = new TrafficMapCompiler().compile(definition("sha-546", "sha-546", true));
|
EffectiveTrafficMap map = new TrafficMapCompiler().compile(definition("sha-546", "sha-546", true));
|
||||||
@@ -72,6 +86,6 @@ class MapAndResourceLedgerTest {
|
|||||||
return new TrafficMapCompiler.MapDefinition(
|
return new TrafficMapCompiler.MapDefinition(
|
||||||
"FT546", 2L, "ZONE-1", confirmed, baseChecksum, configuredChecksum,
|
"FT546", 2L, "ZONE-1", confirmed, baseChecksum, configuredChecksum,
|
||||||
nodes, edges, capacities,
|
nodes, edges, capacities,
|
||||||
Collections.singleton("A"), Collections.singleton("C"));
|
Collections.singleton("B"), Collections.singleton("C"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class RollingDispatchPolicyTest {
|
|||||||
ShadowCandidate first = candidate("T1", "V1", 5, 20, ResourceKey.node("A"), null);
|
ShadowCandidate first = candidate("T1", "V1", 5, 20, ResourceKey.node("A"), null);
|
||||||
ShadowCandidate second = candidate("T2", "V2", 5, 10, ResourceKey.node("B"), null);
|
ShadowCandidate second = candidate("T2", "V2", 5, 10, ResourceKey.node("B"), null);
|
||||||
ShadowCandidate committed = ShadowCandidate.committed(
|
ShadowCandidate committed = ShadowCandidate.committed(
|
||||||
"T0", "V0", Collections.singleton(ResourceKey.node("A")));
|
"T0", "V0", Collections.singleton(ResourceKey.node("C")));
|
||||||
|
|
||||||
List<ShadowDecision> decisions =
|
List<ShadowDecision> decisions =
|
||||||
new RollingDispatchPolicy().decide(Arrays.asList(first, second, committed), ledger, map);
|
new RollingDispatchPolicy().decide(Arrays.asList(first, second, committed), ledger, map);
|
||||||
@@ -76,6 +76,22 @@ class RollingDispatchPolicyTest {
|
|||||||
assertEquals("WAIT_CYCLE", decisions.get(2).getReasonCode());
|
assertEquals("WAIT_CYCLE", decisions.get(2).getReasonCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void committedSegmentProtectsResourcesBeforeNewCandidatesCompete() {
|
||||||
|
EffectiveTrafficMap map = map();
|
||||||
|
ShadowCandidate committed = ShadowCandidate.committed(
|
||||||
|
"T0", "V0", Collections.singleton(ResourceKey.node("A")));
|
||||||
|
ShadowCandidate waiting = candidate(
|
||||||
|
"T1", "V1", 9, 100, ResourceKey.node("A"), null);
|
||||||
|
|
||||||
|
List<ShadowDecision> decisions = new RollingDispatchPolicy().decide(
|
||||||
|
Arrays.asList(waiting, committed), new ResourceLedger(map.getCapacities()), map);
|
||||||
|
|
||||||
|
assertEquals(DecisionType.GRANT, decisions.get(0).getType());
|
||||||
|
assertEquals(DecisionType.HOLD, decisions.get(1).getType());
|
||||||
|
assertEquals(Collections.singleton("V0"), decisions.get(1).getBlockingVehicles());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void detectsCycleBeforeAddingWaitEdges() {
|
void detectsCycleBeforeAddingWaitEdges() {
|
||||||
WaitForGraph graph = new WaitForGraph();
|
WaitForGraph graph = new WaitForGraph();
|
||||||
@@ -85,6 +101,20 @@ class RollingDispatchPolicyTest {
|
|||||||
assertFalse(graph.isAcyclic());
|
assertFalse(graph.isAcyclic());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownResourceFailsClosedAsStructuredHold() {
|
||||||
|
EffectiveTrafficMap map = map();
|
||||||
|
ShadowCandidate candidate = candidate(
|
||||||
|
"T1", "V1", 1, 1, ResourceKey.conflict("UNPUBLISHED"), null);
|
||||||
|
|
||||||
|
ShadowDecision decision = new RollingDispatchPolicy().decide(
|
||||||
|
Collections.singletonList(candidate),
|
||||||
|
new ResourceLedger(map.getCapacities()), map).get(0);
|
||||||
|
|
||||||
|
assertEquals(DecisionType.HOLD, decision.getType());
|
||||||
|
assertEquals("UNKNOWN_RESOURCE", decision.getReasonCode());
|
||||||
|
}
|
||||||
|
|
||||||
private ShadowCandidate candidate(String task, String vehicle, int priority, long age,
|
private ShadowCandidate candidate(String task, String vehicle, int priority, long age,
|
||||||
ResourceKey resource, String yieldSite) {
|
ResourceKey resource, String yieldSite) {
|
||||||
return ShadowCandidate.waiting(task, vehicle, false, priority, age, 1,
|
return ShadowCandidate.waiting(task, vehicle, false, priority, age, 1,
|
||||||
@@ -99,7 +129,7 @@ class RollingDispatchPolicyTest {
|
|||||||
edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
|
edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
|
||||||
return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
|
return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
|
||||||
"FT546", 2L, "ZONE-1", true, "sha", "sha", nodes, edges,
|
"FT546", 2L, "ZONE-1", true, "sha", "sha", nodes, edges,
|
||||||
new LinkedHashMap<ResourceKey, Integer>(), Collections.singleton("A"),
|
new LinkedHashMap<ResourceKey, Integer>(), Collections.singleton("B"),
|
||||||
Collections.singleton("C")));
|
Collections.singleton("C")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import com.ft546.wcs.shadow.replay.DecisionReplayService;
|
|||||||
import com.ft546.wcs.shadow.resource.ResourceKey;
|
import com.ft546.wcs.shadow.resource.ResourceKey;
|
||||||
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
import com.ft546.wcs.shadow.resource.ResourceLedger;
|
||||||
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
import com.ft546.wcs.shadow.snapshot.TrafficSnapshot;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.TrafficVehicleSnapshot;
|
||||||
|
import com.ft546.wcs.shadow.snapshot.ControlMode;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -45,19 +47,51 @@ class ShadowCoordinatorReplayTest {
|
|||||||
new ShadowTrafficCoordinator(map, ledger, new RollingDispatchPolicy());
|
new ShadowTrafficCoordinator(map, ledger, new RollingDispatchPolicy());
|
||||||
TrafficSnapshot snapshot =
|
TrafficSnapshot snapshot =
|
||||||
new TrafficSnapshot(9L, Instant.parse("2026-07-30T05:00:00Z"),
|
new TrafficSnapshot(9L, Instant.parse("2026-07-30T05:00:00Z"),
|
||||||
"FT546", 2L, "POLICY-1", Collections.emptyList());
|
"FT546", 2L, "POLICY-1", Collections.singletonList(vehicle(
|
||||||
|
"V1", "BOOT-1", 1L, Instant.parse("2026-07-30T04:59:59Z"))));
|
||||||
ShadowCandidate candidate = ShadowCandidate.waiting(
|
ShadowCandidate candidate = ShadowCandidate.waiting(
|
||||||
"T1", "V1", false, 5, 12, 1,
|
"T1", "V1", false, 5, 12, 1,
|
||||||
Collections.singleton(ResourceKey.node("B")), null);
|
Collections.singleton(ResourceKey.node("B")), null);
|
||||||
|
|
||||||
DecisionEvent first = coordinator.run(snapshot, Collections.singletonList(candidate));
|
DecisionEvent first = coordinator.run(snapshot, Collections.singletonList(candidate));
|
||||||
DecisionEvent second = coordinator.run(snapshot, Collections.singletonList(candidate));
|
DecisionEvent second = new ShadowTrafficCoordinator(
|
||||||
|
map, ledger, new RollingDispatchPolicy()).run(snapshot, Collections.singletonList(candidate));
|
||||||
|
|
||||||
assertEquals(first.getInputHash(), second.getInputHash());
|
assertEquals(first.getInputHash(), second.getInputHash());
|
||||||
assertEquals(DecisionType.HOLD, first.getDecisions().get(0).getType());
|
assertEquals(DecisionType.HOLD, first.getDecisions().get(0).getType());
|
||||||
assertEquals(1, ledger.protectedCount(ResourceKey.node("B")));
|
assertEquals(1, ledger.protectedCount(ResourceKey.node("B")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonShadowStaleDisconnectedAndRestartedSnapshots() {
|
||||||
|
EffectiveTrafficMap map = map();
|
||||||
|
ShadowCandidate candidate = ShadowCandidate.waiting(
|
||||||
|
"T1", "V1", false, 1, 1, 1,
|
||||||
|
Collections.singleton(ResourceKey.node("A")), null);
|
||||||
|
ShadowTrafficCoordinator coordinator = new ShadowTrafficCoordinator(
|
||||||
|
map, new ResourceLedger(map.getCapacities()), new RollingDispatchPolicy());
|
||||||
|
Instant now = Instant.parse("2026-07-30T05:00:05Z");
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> coordinator.run(
|
||||||
|
new TrafficSnapshot(1L, now, "FT546", 2L, "P", ControlMode.LEGACY,
|
||||||
|
Collections.singletonList(vehicle("V1", "BOOT-1", 1L, now))),
|
||||||
|
Collections.singletonList(candidate)));
|
||||||
|
assertThrows(IllegalStateException.class, () -> coordinator.run(
|
||||||
|
new TrafficSnapshot(2L, now, "FT546", 2L, "P",
|
||||||
|
Collections.singletonList(vehicle("V1", "BOOT-1", 2L, now.minusSeconds(3)))),
|
||||||
|
Collections.singletonList(candidate)));
|
||||||
|
|
||||||
|
ShadowTrafficCoordinator restart = new ShadowTrafficCoordinator(
|
||||||
|
map, new ResourceLedger(map.getCapacities()), new RollingDispatchPolicy());
|
||||||
|
restart.run(new TrafficSnapshot(3L, now, "FT546", 2L, "P",
|
||||||
|
Collections.singletonList(vehicle("V1", "BOOT-1", 3L, now))),
|
||||||
|
Collections.singletonList(candidate));
|
||||||
|
assertThrows(IllegalStateException.class, () -> restart.run(
|
||||||
|
new TrafficSnapshot(4L, now.plusSeconds(1), "FT546", 2L, "P",
|
||||||
|
Collections.singletonList(vehicle("V1", "BOOT-2", 4L, now.plusSeconds(1)))),
|
||||||
|
Collections.singletonList(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void readsAndReplaysGoldenDecisionEvent() throws Exception {
|
void readsAndReplaysGoldenDecisionEvent() throws Exception {
|
||||||
InputStream input = getClass().getResourceAsStream("/shadow/golden/basic-conflict.json");
|
InputStream input = getClass().getResourceAsStream("/shadow/golden/basic-conflict.json");
|
||||||
@@ -72,6 +106,21 @@ class ShadowCoordinatorReplayTest {
|
|||||||
replay.replay(event).get(0).getConflictingResources());
|
replay.replay(event).get(0).getConflictingResources());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replayRejectsTamperedHashOrRecordedDecision() throws Exception {
|
||||||
|
DecisionReplayService replay = new DecisionReplayService();
|
||||||
|
DecisionEvent event = replay.read(
|
||||||
|
getClass().getResourceAsStream("/shadow/golden/basic-conflict.json"));
|
||||||
|
DecisionEvent tamperedHash = event.withInputHash("tampered");
|
||||||
|
DecisionEvent tamperedDecision = event.withDecisions(Collections.singletonList(
|
||||||
|
new com.ft546.wcs.shadow.decision.ShadowDecision(
|
||||||
|
"T1", "V1", DecisionType.GRANT, "RESOURCES_AVAILABLE",
|
||||||
|
Collections.emptySet(), Collections.emptySet(), null)));
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> replay.replay(tamperedHash));
|
||||||
|
assertThrows(IllegalStateException.class, () -> replay.replay(tamperedDecision));
|
||||||
|
}
|
||||||
|
|
||||||
private EffectiveTrafficMap map() {
|
private EffectiveTrafficMap map() {
|
||||||
LinkedHashSet<String> nodes = new LinkedHashSet<String>(Arrays.asList("A", "B", "C"));
|
LinkedHashSet<String> nodes = new LinkedHashSet<String>(Arrays.asList("A", "B", "C"));
|
||||||
LinkedHashSet<TrafficMapCompiler.EdgeDefinition> edges =
|
LinkedHashSet<TrafficMapCompiler.EdgeDefinition> edges =
|
||||||
@@ -80,7 +129,12 @@ class ShadowCoordinatorReplayTest {
|
|||||||
edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
|
edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
|
||||||
return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
|
return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
|
||||||
"FT546", 2L, "ZONE-1", true, "sha", "sha", nodes, edges,
|
"FT546", 2L, "ZONE-1", true, "sha", "sha", nodes, edges,
|
||||||
new LinkedHashMap<ResourceKey, Integer>(), Collections.singleton("A"),
|
new LinkedHashMap<ResourceKey, Integer>(), Collections.singleton("B"),
|
||||||
Collections.singleton("C")));
|
Collections.singleton("C")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TrafficVehicleSnapshot vehicle(String id, String boot, long seq, Instant at) {
|
||||||
|
return new TrafficVehicleSnapshot(id, boot, seq, seq, at, "A", "B",
|
||||||
|
Arrays.asList("A", "B"), true, false, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class ShadowInvariantPropertyTest {
|
|||||||
edges.add(new TrafficMapCompiler.EdgeDefinition("N2", "N3"));
|
edges.add(new TrafficMapCompiler.EdgeDefinition("N2", "N3"));
|
||||||
return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
|
return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition(
|
||||||
"FT546", 2L, "ZONE-PROPERTY", true, "sha", "sha", nodes, edges,
|
"FT546", 2L, "ZONE-PROPERTY", true, "sha", "sha", nodes, edges,
|
||||||
new LinkedHashMap<ResourceKey, Integer>(), Collections.singleton("N0"),
|
new LinkedHashMap<ResourceKey, Integer>(), Collections.singleton("N1"),
|
||||||
Collections.singleton("N3")));
|
Collections.singleton("N3")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,31 @@
|
|||||||
"tickSeq": 9,
|
"tickSeq": 9,
|
||||||
"mapCode": "FT546",
|
"mapCode": "FT546",
|
||||||
"mapVersion": 2,
|
"mapVersion": 2,
|
||||||
|
"zoneCode": "ZONE-1",
|
||||||
|
"checksum": "sha",
|
||||||
"policyVersion": "POLICY-1",
|
"policyVersion": "POLICY-1",
|
||||||
"inputHash": "golden-input-sha256",
|
"inputHash": "8526c0ec7be7385b0a69662132c0c8bd31501b98d880155e9c125b51f077819a",
|
||||||
|
"nodes": ["A", "B", "C"],
|
||||||
|
"edges": [["A", "B"], ["B", "C"]],
|
||||||
|
"explicitCapacities": {},
|
||||||
|
"safeWaitSites": ["B"],
|
||||||
|
"safeYieldSites": ["C"],
|
||||||
|
"holders": {
|
||||||
|
"NODE:B": ["BLOCKER"]
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"taskId": "T1",
|
||||||
|
"vehicleId": "V1",
|
||||||
|
"committed": false,
|
||||||
|
"emergency": false,
|
||||||
|
"businessPriority": 5,
|
||||||
|
"waitAge": 12,
|
||||||
|
"clearanceCost": 1,
|
||||||
|
"resources": ["NODE:B"],
|
||||||
|
"safeYieldSite": null
|
||||||
|
}
|
||||||
|
],
|
||||||
"decisions": [
|
"decisions": [
|
||||||
{
|
{
|
||||||
"taskId": "T1",
|
"taskId": "T1",
|
||||||
|
|||||||
Reference in New Issue
Block a user