fix(shadow): protect observed vehicle traffic

This commit is contained in:
zgw
2026-09-09 15:42:09 +08:00
parent edb13450a6
commit 4408958443
12 changed files with 545 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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