fix(shadow): protect observed vehicle traffic
This commit is contained in:
@@ -139,7 +139,7 @@ class Ft546ShadowApplicationTest {
|
||||
.andExpect(jsonPath("$.status").value("STARTING"))
|
||||
.andExpect(jsonPath("$.checkedAt").exists())
|
||||
.andExpect(jsonPath("$.evidenceBacklog").value(0))
|
||||
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260909-recovery-pairing-v3"))
|
||||
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260909-observed-traffic-v1"))
|
||||
.andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists())
|
||||
.andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED"))
|
||||
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
|
||||
|
||||
@@ -74,7 +74,6 @@ class ShadowDeferredComparisonTest {
|
||||
builder(1, "70501,70502").taskType("1").build(),
|
||||
builder(1, "70501,70502").taskNo("30002").build(),
|
||||
builder(1, "70501,70502").createdAt(NOW.minusSeconds(1)).build(),
|
||||
builder(1, "70501,70502").agvId("VEH-8").build(),
|
||||
builder(1, "70501,70502").startSite("70503").build(),
|
||||
builder(1, "70501,70502").default02("1").build(),
|
||||
builder(1, "70501,70502").default08("2").build());
|
||||
@@ -86,6 +85,19 @@ class ShadowDeferredComparisonTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuedQueueWithUnobservedVehicleCannotBePairedOrDispatched() {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowQueueFact unknownOwner = builder(1, "70501,70502").agvId("VEH-8").build();
|
||||
assertEquals("INSUFFICIENT_FACTS", h.runBatch(h.batch(5,
|
||||
Collections.singletonList(unknownOwner), true)).getStatusCode());
|
||||
ShadowCycleEvidence evidence = h.repository.saved.get(h.repository.saved.size() - 1);
|
||||
assertTrue(evidence.getEncodedWrites().isEmpty());
|
||||
assertNull(deferredOrNull(evidence));
|
||||
assertEquals(1, h.gateway.getRecorded().size(), "only the earlier valid pending prediction was encoded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrStaleLegacyPathRemainsUnavailable() {
|
||||
for (String path : new String[] {null, "70502,70501"}) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidence;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Private evidence replay; no production connection and no invented historical ledger. */
|
||||
@EnabledIfSystemProperty(named = "shadow.replay.occupancy-directory", matches = ".+")
|
||||
class ShadowObservedTrafficFieldReplayTest {
|
||||
@Test void capturedConflictingRoutesAreHeldAndEveryRawInputHashIsPreserved() throws Exception {
|
||||
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
Map<String, String> risks = new LinkedHashMap<String, String>();
|
||||
risks.put("8023c472-5e3b-4883-a37c-3985398a1f92", "501");
|
||||
risks.put("12e15b0b-b9cb-45bb-9f86-10ee217234f5", "60935");
|
||||
risks.put("84366e8d-8658-4a4f-b95c-e4041ea1685c", "31372");
|
||||
Set<String> checked = new HashSet<String>();
|
||||
int count = 0, grants = 0, holds = 0;
|
||||
try (DirectoryStream<Path> paths = Files.newDirectoryStream(Paths.get(
|
||||
System.getProperty("shadow.replay.occupancy-directory")), "input-*.json")) {
|
||||
for (Path path : paths) {
|
||||
JsonNode export = json.readTree(path.toFile());
|
||||
String raw = export.get("capturedJson").asText();
|
||||
ShadowFactBatch batch = ShadowCapturedInputReplayTest.batch(json.readTree(raw));
|
||||
ShadowCycleEvidence input = new ShadowCycleEvidenceCollector(UUID.randomUUID(), 1L, json)
|
||||
.insufficient(batch, "INDEPENDENT_INPUT_REPLAY", batch.getDatabaseCapturedAt()).get(0);
|
||||
assertEquals(raw, input.getInputJson(), path.toString());
|
||||
assertEquals(export.get("contentHash").asText(), input.getInputBatchHash(), path.toString());
|
||||
DispatchSnapshot snapshot = new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(),
|
||||
0L, Collections.emptyMap()), Clock.fixed(batch.getDatabaseCapturedAt(), ZoneOffset.UTC)).assemble(batch);
|
||||
AStarRoutePlanner routes = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(new ShadowVehicleAssignmentPolicy(
|
||||
new DefaultVehicleAssignmentPolicy(routes, DispatchPolicy.NONE)),
|
||||
new DeadlockGuard(new WaitForGraph(), routes), commit -> true, () -> UUID.randomUUID().toString());
|
||||
DispatchCycleResult result = coordinator.dispatch(snapshot);
|
||||
String id = export.get("cycleId").asText();
|
||||
if (risks.containsKey(id)) {
|
||||
String task = risks.get(id);
|
||||
// Counterfactual isolates this repair from eligibility and historical ledger changes.
|
||||
DispatchCycleResult withoutObservation = coordinator.dispatch(new DispatchSnapshot(snapshot.getReadySteps(),
|
||||
snapshot.getVehicleSnapshots(), snapshot.getTopology(), snapshot.workingLedger(), 0L));
|
||||
assertTrue(withoutObservation.getGranted().stream().anyMatch(g -> task.equals(g.getOrderId())), task);
|
||||
assertFalse(result.getGranted().stream().anyMatch(g -> task.equals(g.getOrderId())), task);
|
||||
HeldDispatch held = result.getHeld().stream().filter(h -> task.equals(h.getOrderId())).findFirst().get();
|
||||
assertEquals("OBSERVED_TRAFFIC_CONFLICT", held.getReasonCode(), task);
|
||||
assertTrue(held.getBlockingVehicleIds().contains("3"), task + " must account for observed vehicle 3");
|
||||
checked.add(task);
|
||||
System.out.println("OCCUPANCY_REPLAY task=" + task + " blockers=" + held.getBlockingVehicleIds());
|
||||
}
|
||||
grants += result.getGranted().size(); holds += result.getHeld().size(); count++;
|
||||
}
|
||||
}
|
||||
assertEquals(new HashSet<String>(risks.values()), checked);
|
||||
assertEquals(50, count, "the fixed 2026-09-09 export contains 50 independent observations");
|
||||
System.out.println("OCCUPANCY_REPLAY inputs=" + count + " grants=" + grants + " holds=" + holds
|
||||
+ " rawHashesVerified=50 historicalLedgerReconstructed=false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.wcs.application.dispatch.*;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowObservedTrafficTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-09T07:00:00Z");
|
||||
private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue";
|
||||
|
||||
@Test void anotherIdleVehicleOnTheRouteMustNotBeGrantedOver() {
|
||||
DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030101", "", 1)), Collections.emptyList()));
|
||||
assertTrue(result.getGranted().isEmpty(), "a reported occupied point is not free road");
|
||||
assertEquals("OBSERVED_TRAFFIC_CONFLICT", result.getHeld().get(0).getReasonCode());
|
||||
assertEquals(Collections.singleton("2"), result.getHeld().get(0).getBlockingVehicleIds());
|
||||
}
|
||||
|
||||
@Test void executingPathProtectsTheEntireStraightSegmentIncludingUnreportedIntermediateNodes() {
|
||||
DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "030201,030001", 2)), Collections.emptyList()));
|
||||
assertTrue(result.getGranted().isEmpty(), "crossing a live path is blocked, not just its current point");
|
||||
assertEquals("OBSERVED_TRAFFIC_CONFLICT", result.getHeld().get(0).getReasonCode());
|
||||
}
|
||||
|
||||
@Test void issuedQueueProtectsACommittedPathNotPresentInTheVehicleSegment() {
|
||||
ShadowQueueFact running = queue("RUNNING", "2", "030201", "030001")
|
||||
.issued(1).executionStatus(3).pathInfo("030201,030101,030001,").build();
|
||||
DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "030201", 2)), Collections.singletonList(running)));
|
||||
assertTrue(result.getGranted().isEmpty(), "an issued route remains an external constraint");
|
||||
}
|
||||
|
||||
@Test void ownCurrentPositionDoesNotBlockOwnDepartureAndDoesNotEnterThePersistentLedger() {
|
||||
DispatchSnapshot snapshot = snapshot(Collections.singletonList(vehicle("1", "010101", "", 1)), Collections.emptyList());
|
||||
assertEquals(0, snapshot.workingLedger().usage("NODE:010101"));
|
||||
AtomicReference<DispatchCommit> committed = new AtomicReference<>();
|
||||
DispatchCycleResult result = coordinator(commit -> { committed.set(commit); return true; }).dispatch(snapshot);
|
||||
assertEquals(1, result.getGranted().size());
|
||||
assertEquals(1, committed.get().getResultingLedger().snapshotState().getReservations().size());
|
||||
}
|
||||
|
||||
@Test void aMovingVehicleWithMissingOrBrokenPathMustNotDisappearFromTrafficProtection() {
|
||||
for (String path : Arrays.asList("", "UNKNOWN", "030201,010101")) {
|
||||
DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", path, 2)), Collections.emptyList()));
|
||||
assertTrue(result.getGranted().isEmpty(), "uncertain live path: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void idleVehiclesDoNotKeepAnOldCompletedPathButKeepTheirActualPosition() {
|
||||
DispatchCycleResult result = dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "030201,030101,030001", 1)), Collections.emptyList()));
|
||||
assertEquals(1, result.getGranted().size());
|
||||
}
|
||||
|
||||
@Test void finishedQueueRowsDoNotCreateFreshExternalReservations() {
|
||||
ShadowQueueFact finished = ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue_finish", "OLD")
|
||||
.taskNo("2").taskType("1").issued(1).executionStatus(3).agvId("2")
|
||||
.pathInfo("030201,030101,030001").startSite("030201").endSite("030001")
|
||||
.default02("4").theFloor("1").createdAt(NOW.minusSeconds(10)).build();
|
||||
assertEquals(1, dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "", 1)), Collections.singletonList(finished))).getGranted().size());
|
||||
}
|
||||
|
||||
@Test void aNewCompleteObservationCanShowTheRouteFreeWithoutReleasingAnyPersistedReservation() {
|
||||
assertTrue(dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030101", "", 1)), Collections.emptyList())).getGranted().isEmpty());
|
||||
assertEquals(1, dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "", 1)), Collections.emptyList())).getGranted().size());
|
||||
}
|
||||
|
||||
@Test void missingPreviouslySeenVehicleRejectsTheSnapshotUntilItReturns() {
|
||||
ShadowDispatchSnapshotAssembler assembler = assembler();
|
||||
assembler.assemble(batch(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030101", "", 1)), Collections.emptyList()));
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () ->
|
||||
assembler.assemble(batch(Collections.singletonList(vehicle("1", "010101", "", 1)), Collections.emptyList())));
|
||||
assertTrue(failure.getMessage().startsWith("OBSERVED_VEHICLE_MISSING:"));
|
||||
assertEquals(1, dispatch(assembler.assemble(batch(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "", 1)), Collections.emptyList()))).getGranted().size());
|
||||
}
|
||||
|
||||
@Test void duplicateVehicleIdentityMustNotHideOneOfItsPositions() {
|
||||
assertThrows(IllegalArgumentException.class, () -> snapshot(Arrays.asList(
|
||||
vehicle("1", "010101", "", 1), vehicle("2", "030101", "", 1),
|
||||
vehicle("2", "030201", "", 1)), Collections.emptyList()));
|
||||
}
|
||||
|
||||
@Test void aPathDetachedFromTheMovingVehiclesCurrentPointIsUncertain() {
|
||||
assertTrue(dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "030001", 2)), Collections.emptyList())).getGranted().isEmpty());
|
||||
}
|
||||
|
||||
@Test void unsendableVehicleMustNotBeMistakenForConfirmedIdle() {
|
||||
ShadowVehicleFact transitioning = ShadowVehicleFact.builder("2", "2").currentSite("030201")
|
||||
.currentFloor(1).workMode(5).status(1).allowSend(0).finish(0).battery(80)
|
||||
.path("030201,030001").capturedAt(NOW).build();
|
||||
assertTrue(dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), transitioning),
|
||||
Collections.emptyList())).getGranted().isEmpty());
|
||||
}
|
||||
|
||||
@Test void clearedFinishHandshakeDoesNotTurnASendableIdleVehicleIntoAMovingOne() {
|
||||
ShadowVehicleFact idle = ShadowVehicleFact.builder("2", "2").currentSite("030201")
|
||||
.currentFloor(1).workMode(5).status(1).allowSend(1).finish(0).battery(80)
|
||||
.path("").capturedAt(NOW).build();
|
||||
assertEquals(1, dispatch(snapshot(Arrays.asList(vehicle("1", "010101", "", 1), idle),
|
||||
Collections.emptyList())).getGranted().size());
|
||||
}
|
||||
|
||||
@Test void inconsistentReportedFloorCannotLeaveTheActualFloorUnprotected() {
|
||||
ShadowVehicleFact inconsistent = ShadowVehicleFact.builder("2", "2").currentSite("030201")
|
||||
.currentFloor(2).workMode(5).status(2).allowSend(0).battery(80).path("").capturedAt(NOW).build();
|
||||
assertThrows(IllegalArgumentException.class, () -> snapshot(
|
||||
Arrays.asList(vehicle("1", "010101", "", 1), inconsistent), Collections.emptyList()));
|
||||
}
|
||||
|
||||
@Test void liveSegmentIncludesBothDirectionsOfTheEdgeAndDoesNotInventAnOffRouteBlocker() {
|
||||
DispatchSnapshot observed = snapshot(Arrays.asList(vehicle("1", "010101", "", 1),
|
||||
vehicle("2", "030201", "030201,030001", 2)), Collections.emptyList());
|
||||
assertEquals(Collections.singleton("2"), observed.observedBlockers(Collections.singletonList(
|
||||
com.ruoyi.wcs.domain.topology.TopologyEdge.canonicalResourceKey("030101", "030201")), "1"));
|
||||
assertTrue(observed.observedBlockers(Collections.singletonList("NODE:050101"), "1").isEmpty());
|
||||
}
|
||||
|
||||
private static DispatchCycleResult dispatch(DispatchSnapshot snapshot) { return coordinator(commit -> true).dispatch(snapshot); }
|
||||
private static DispatchCoordinator coordinator(com.ruoyi.wcs.port.out.TrafficStateRepository repository) {
|
||||
RoutePlanner planner = new AStarRoutePlanner();
|
||||
return new DispatchCoordinator(new ShadowVehicleAssignmentPolicy(new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE)),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), repository, () -> UUID.randomUUID().toString());
|
||||
}
|
||||
private static DispatchSnapshot snapshot(List<ShadowVehicleFact> vehicles, List<ShadowQueueFact> extra) {
|
||||
return assembler().assemble(batch(vehicles, extra));
|
||||
}
|
||||
private static ShadowFactBatch batch(List<ShadowVehicleFact> vehicles, List<ShadowQueueFact> extra) {
|
||||
List<ShadowQueueFact> queues = new ArrayList<>(extra);
|
||||
queues.add(queue("PENDING", "1", "010101", "050101").build());
|
||||
List<ShadowMapFact> maps = new ArrayList<>();
|
||||
for (int x=1; x<=5; x++) maps.add(new ShadowMapFact("M"+x, String.format("%02d0101",x),x,1,1,1));
|
||||
maps.add(new ShadowMapFact("UP","030201",3,2,1,1));
|
||||
maps.add(new ShadowMapFact("DOWN","030001",3,0,1,1));
|
||||
return ShadowFactBatch.create(NOW,NOW,queues,vehicles,maps);
|
||||
}
|
||||
private static ShadowDispatchSnapshotAssembler assembler() {
|
||||
return new ShadowDispatchSnapshotAssembler("FT546",1L, topology -> new ShadowTrafficState(
|
||||
new ResourceLedger(topology),new WaitForGraph(),0L,Collections.emptyMap()),Clock.fixed(NOW,ZoneOffset.UTC));
|
||||
}
|
||||
private static ShadowVehicleFact vehicle(String id,String site,String path,int status) {
|
||||
return ShadowVehicleFact.builder(id,id).currentSite(site).currentFloor(1).workMode(5).status(status)
|
||||
.allowSend(status==1 ? 1 : 0).battery(80).alarm(0).errorCode(0).cargoFlag(0).path(path).capturedAt(NOW).build();
|
||||
}
|
||||
private static ShadowQueueFact.Builder queue(String guid,String agv,String source,String target) {
|
||||
return ShadowQueueFact.builder(ACTIVE,guid).taskNo("PENDING".equals(guid)?"1":"2").taskType("1")
|
||||
.priority(2L).issued(0).executionStatus(1).startSite(source).endSite(target).cargoPickSite(target)
|
||||
.cargoPlaceSite(target).theFloor("1").agvId(agv).createdAt(NOW.minusSeconds(5)).default02("4");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user