fix: enforce deterministic dispatch boundaries

This commit is contained in:
zgw
2026-07-30 11:38:57 +08:00
parent ae338b7e35
commit 677fb540c2
8 changed files with 180 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ package com.ft546.wcs.demo;
import com.ft546.wcs.demo.center.CycleResult; import com.ft546.wcs.demo.center.CycleResult;
import com.ft546.wcs.demo.center.LegacyDispatchCenter; import com.ft546.wcs.demo.center.LegacyDispatchCenter;
import com.ft546.wcs.demo.center.QueueAdmissionService; import com.ft546.wcs.demo.center.QueueAdmissionService;
import com.ft546.wcs.demo.center.VehicleAssignmentDecision;
import com.ft546.wcs.demo.center.VehicleAssignmentService; import com.ft546.wcs.demo.center.VehicleAssignmentService;
import com.ft546.wcs.demo.model.DispatchTask; import com.ft546.wcs.demo.model.DispatchTask;
import com.ft546.wcs.demo.model.Site; import com.ft546.wcs.demo.model.Site;
@@ -14,6 +15,7 @@ import com.ft546.wcs.demo.traffic.GridPathPlanner;
import com.ft546.wcs.demo.traffic.TrafficController; import com.ft546.wcs.demo.traffic.TrafficController;
import java.io.PrintStream; import java.io.PrintStream;
import java.util.Arrays;
public final class DemoApplication { public final class DemoApplication {
@@ -58,6 +60,7 @@ public final class DemoApplication {
out.println("FT546 WCS LEGACY DISPATCH AND TRAFFIC DEMO"); out.println("FT546 WCS LEGACY DISPATCH AND TRAFFIC DEMO");
out.println("MODE LEGACY / IO DISABLED / IN-MEMORY ONLY"); out.println("MODE LEGACY / IO DISABLED / IN-MEMORY ONLY");
printCrossFloorAssignmentCheck(out, map);
for (int cycle = 0; cycle < 6; cycle++) { for (int cycle = 0; cycle < 6; cycle++) {
printCycle(out, center.runCycle()); printCycle(out, center.runCycle());
@@ -87,6 +90,30 @@ public final class DemoApplication {
} }
} }
private static void printCrossFloorAssignmentCheck(PrintStream out, GridMap map) {
DispatchTask checkTask = new DispatchTask(
"CHECK-CROSS-FLOOR",
DispatchTask.TaskType.OUTBOUND,
"A",
"TARGET",
1,
1
);
VehicleAssignmentDecision decision = new VehicleAssignmentService().select(
checkTask,
Arrays.asList(
new VehicleSnapshot("1", 2, "F2-NEAR", 5, 1, 80, true, true, false),
new VehicleSnapshot("2", 1, "F1-FAR", 5, 1, 80, true, true, false)
),
map.getSites()
);
out.println(
"ASSIGN-CHECK crossFloorWinner="
+ decision.getVehicle().get().getVehicleId()
+ " reason=" + decision.getReason()
);
}
private static GridMap createMap() { private static GridMap createMap() {
GridMap map = new GridMap(); GridMap map = new GridMap();
map.addSite(new Site("A", 1, 0, 0, "START")); map.addSite(new Site("A", 1, 0, 0, "START"));
@@ -95,6 +122,8 @@ public final class DemoApplication {
map.addSite(new Site("CACHE", 1, 1, 1, "CACHE")); map.addSite(new Site("CACHE", 1, 1, 1, "CACHE"));
map.addSite(new Site("V3", 1, 0, 1, "V3-CHANNEL")); map.addSite(new Site("V3", 1, 0, 1, "V3-CHANNEL"));
map.addSite(new Site("ISOLATED", 1, 9, 9, "ISLAND")); map.addSite(new Site("ISOLATED", 1, 9, 9, "ISLAND"));
map.addSite(new Site("F2-NEAR", 2, 0, 0, "F2-CHECK"));
map.addSite(new Site("F1-FAR", 1, 9, 9, "F1-CHECK"));
map.connect("A", "B"); map.connect("A", "B");
map.connect("B", "TARGET"); map.connect("B", "TARGET");
map.connect("B", "CACHE"); map.connect("B", "CACHE");

View File

@@ -16,13 +16,13 @@ import java.util.Optional;
public final class LegacyDispatchCenter { public final class LegacyDispatchCenter {
private final InMemoryWcsState state; private final InMemoryWcsState state;
private final QueueAdmissionService admission; private final QueueAdmission admission;
private final TrafficController traffic; private final TrafficController traffic;
private final SimulatedProtocolAdapter protocol; private final SimulatedProtocolAdapter protocol;
public LegacyDispatchCenter( public LegacyDispatchCenter(
InMemoryWcsState state, InMemoryWcsState state,
QueueAdmissionService admission, QueueAdmission admission,
TrafficController traffic, TrafficController traffic,
SimulatedProtocolAdapter protocol SimulatedProtocolAdapter protocol
) { ) {
@@ -42,8 +42,6 @@ public final class LegacyDispatchCenter {
int cycleNumber = state.nextCycle(); int cycleNumber = state.nextCycle();
state.addEvent("CYCLE " + cycleNumber); state.addEvent("CYCLE " + cycleNumber);
admission.createPendingQueue(state);
List<VehicleSnapshot> vehicles = List<VehicleSnapshot> vehicles =
new ArrayList<VehicleSnapshot>(state.getVehicles()); new ArrayList<VehicleSnapshot>(state.getVehicles());
Collections.sort(vehicles, new Comparator<VehicleSnapshot>() { Collections.sort(vehicles, new Comparator<VehicleSnapshot>() {
@@ -53,6 +51,8 @@ public final class LegacyDispatchCenter {
} }
}); });
admission.createPendingQueue(state, vehicles);
List<String> processed = new ArrayList<String>(); List<String> processed = new ArrayList<String>();
for (VehicleSnapshot frozenVehicle : vehicles) { for (VehicleSnapshot frozenVehicle : vehicles) {
processed.add(frozenVehicle.getVehicleId()); processed.add(frozenVehicle.getVehicleId());

View File

@@ -0,0 +1,17 @@
package com.ft546.wcs.demo.center;
import com.ft546.wcs.demo.model.DispatchQueueEntry;
import com.ft546.wcs.demo.model.VehicleSnapshot;
import com.ft546.wcs.demo.repository.InMemoryWcsState;
import java.util.List;
import java.util.Optional;
public interface QueueAdmission {
Optional<DispatchQueueEntry> createPendingQueue(
InMemoryWcsState state,
List<VehicleSnapshot> frozenVehicles
);
int getInvocationCount();
}

View File

@@ -10,7 +10,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public final class QueueAdmissionService { public final class QueueAdmissionService implements QueueAdmission {
private final VehicleAssignmentService assignmentService; private final VehicleAssignmentService assignmentService;
private final GridMap map; private final GridMap map;
private int invocationCount; private int invocationCount;
@@ -26,9 +26,13 @@ public final class QueueAdmissionService {
this.map = map; this.map = map;
} }
public Optional<DispatchQueueEntry> createPendingQueue(InMemoryWcsState state) { @Override
if (state == null) { public Optional<DispatchQueueEntry> createPendingQueue(
throw new IllegalArgumentException("state must not be null"); InMemoryWcsState state,
List<VehicleSnapshot> frozenVehicles
) {
if (state == null || frozenVehicles == null) {
throw new IllegalArgumentException("state and frozen vehicles are required");
} }
invocationCount++; invocationCount++;
if (state.getPendingTasks().isEmpty()) { if (state.getPendingTasks().isEmpty()) {
@@ -37,7 +41,7 @@ public final class QueueAdmissionService {
DispatchTask task = state.getPendingTasks().get(0); DispatchTask task = state.getPendingTasks().get(0);
List<VehicleSnapshot> available = new ArrayList<VehicleSnapshot>(); List<VehicleSnapshot> available = new ArrayList<VehicleSnapshot>();
for (VehicleSnapshot vehicle : state.getVehicles()) { for (VehicleSnapshot vehicle : frozenVehicles) {
if (!state.findQueueByVehicle(vehicle.getVehicleId()).isPresent()) { if (!state.findQueueByVehicle(vehicle.getVehicleId()).isPresent()) {
available.add(vehicle); available.add(vehicle);
} }
@@ -65,6 +69,7 @@ public final class QueueAdmissionService {
return Optional.of(queue); return Optional.of(queue);
} }
@Override
public int getInvocationCount() { public int getInvocationCount() {
return invocationCount; return invocationCount;
} }

View File

@@ -28,6 +28,12 @@ public final class VehicleAssignmentService {
"unknown task source site: " + task.getSourceSite() "unknown task source site: " + task.getSourceSite()
); );
} }
if (!sites.containsKey(task.getTargetSite())) {
return VehicleAssignmentDecision.rejected(
DecisionCode.REJECT_INVALID_SITE,
"unknown task target site: " + task.getTargetSite()
);
}
List<Candidate> candidates = new ArrayList<Candidate>(); List<Candidate> candidates = new ArrayList<Candidate>();
boolean eligibleWithUnknownSite = false; boolean eligibleWithUnknownSite = false;

View File

@@ -18,6 +18,7 @@ class DemoScenarioTest {
DemoApplication.runScenario(new PrintStream(output, true, "UTF-8")); DemoApplication.runScenario(new PrintStream(output, true, "UTF-8"));
String text = new String(output.toByteArray(), StandardCharsets.UTF_8); String text = new String(output.toByteArray(), StandardCharsets.UTF_8);
assertTrue(text.contains("ASSIGN-CHECK crossFloorWinner=2"));
assertTrue(text.contains("CYCLE")); assertTrue(text.contains("CYCLE"));
assertTrue(text.contains("ASSIGN")); assertTrue(text.contains("ASSIGN"));
assertTrue(text.contains("PATH")); assertTrue(text.contains("PATH"));
@@ -27,6 +28,24 @@ class DemoScenarioTest {
assertTrue(text.contains("HOLD HOLD_NO_PATH")); assertTrue(text.contains("HOLD HOLD_NO_PATH"));
assertTrue(text.contains("SEND")); assertTrue(text.contains("SEND"));
assertTrue(text.contains("COMPLETE")); assertTrue(text.contains("COMPLETE"));
assertTrue(text.contains("SUMMARY cycles=7 activeQueues=1 history=2")); assertTrue(text.contains(
"SUMMARY cycles=7 activeQueues=1 history=2 protocolAttempts=3"
));
int assign = text.indexOf("ASSIGN task=TASK-OUT-1");
int avoid = text.indexOf("AVOID queue=");
int protocolHold = text.indexOf("HOLD HOLD_PROTOCOL_FAILURE");
int send = text.indexOf("SEND queue=AVOID");
int avoidanceComplete = text.indexOf("COMPLETE queue=AVOID");
int businessComplete = text.indexOf("COMPLETE queue=TASK-OUT-1");
int noPath = text.indexOf("HOLD HOLD_NO_PATH");
int summary = text.indexOf("SUMMARY");
assertTrue(assign < avoid);
assertTrue(avoid < protocolHold);
assertTrue(protocolHold < send);
assertTrue(send < avoidanceComplete);
assertTrue(avoidanceComplete < businessComplete);
assertTrue(businessComplete < noPath);
assertTrue(noPath < summary);
} }
} }

View File

@@ -2,6 +2,7 @@ package com.ft546.wcs.demo;
import com.ft546.wcs.demo.center.CycleResult; import com.ft546.wcs.demo.center.CycleResult;
import com.ft546.wcs.demo.center.LegacyDispatchCenter; import com.ft546.wcs.demo.center.LegacyDispatchCenter;
import com.ft546.wcs.demo.center.QueueAdmission;
import com.ft546.wcs.demo.center.QueueAdmissionService; import com.ft546.wcs.demo.center.QueueAdmissionService;
import com.ft546.wcs.demo.center.VehicleAssignmentService; import com.ft546.wcs.demo.center.VehicleAssignmentService;
import com.ft546.wcs.demo.model.DispatchQueueEntry; import com.ft546.wcs.demo.model.DispatchQueueEntry;
@@ -16,6 +17,8 @@ import com.ft546.wcs.demo.traffic.TrafficController;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -93,6 +96,43 @@ class LegacyDispatchCenterTest {
assertTrue(fixture.state.getEvents().toString().contains("AVOID")); assertTrue(fixture.state.getEvents().toString().contains("AVOID"));
} }
@Test
void vehicleSnapshotIsFrozenBeforeAdmissionMutatesLiveState() {
GridMap map = simpleMap();
InMemoryWcsState state = new InMemoryWcsState();
state.addVehicle(vehicle("1", "A"));
QueueAdmission admission = new QueueAdmission() {
private int invocationCount;
@Override
public Optional<DispatchQueueEntry> createPendingQueue(
InMemoryWcsState liveState,
List<VehicleSnapshot> frozenVehicles
) {
invocationCount++;
liveState.addVehicle(vehicle("9", "V3"));
return Optional.empty();
}
@Override
public int getInvocationCount() {
return invocationCount;
}
};
LegacyDispatchCenter center = new LegacyDispatchCenter(
state,
admission,
new TrafficController(map, new GridPathPlanner()),
new SimulatedProtocolAdapter()
);
CycleResult result = center.runCycle();
assertEquals(Arrays.asList("1"), result.getProcessedVehicleIds());
assertEquals(2, state.getVehicles().size());
assertEquals(1, admission.getInvocationCount());
}
private Fixture fixtureWithThreeVehicles() { private Fixture fixtureWithThreeVehicles() {
GridMap map = simpleMap(); GridMap map = simpleMap();
InMemoryWcsState state = new InMemoryWcsState(); InMemoryWcsState state = new InMemoryWcsState();

View File

@@ -28,6 +28,10 @@ class VehicleAssignmentServiceTest {
sites.put("SAME", new Site("SAME", 1, 1, 1, "AISLE-1")); sites.put("SAME", new Site("SAME", 1, 1, 1, "AISLE-1"));
sites.put("NEAR", new Site("NEAR", 2, 10, 9, "AISLE-2")); sites.put("NEAR", new Site("NEAR", 2, 10, 9, "AISLE-2"));
sites.put("TIE", new Site("TIE", 1, 1, 1, "AISLE-1")); sites.put("TIE", new Site("TIE", 1, 1, 1, "AISLE-1"));
sites.put("Y_NEAR_X_FAR", new Site("Y_NEAR_X_FAR", 1, 0, 9, "AISLE-3"));
sites.put("Y_FAR_X_NEAR", new Site("Y_FAR_X_NEAR", 1, 9, 0, "AISLE-4"));
sites.put("X_NEAR", new Site("X_NEAR", 1, 9, 9, "AISLE-5"));
sites.put("X_FAR", new Site("X_FAR", 1, 0, 9, "AISLE-6"));
} }
@Test @Test
@@ -85,6 +89,56 @@ class VehicleAssignmentServiceTest {
assertTrue(decision.getReason().contains("UNKNOWN")); assertTrue(decision.getReason().contains("UNKNOWN"));
} }
@Test
void missingTargetSiteIsRejectedBeforeAQueueCanBeCreated() {
DispatchTask invalidTarget = new DispatchTask(
"TASK-TARGET",
DispatchTask.TaskType.OUTBOUND,
"SOURCE",
"UNKNOWN",
1,
2
);
VehicleAssignmentDecision decision = service.select(
invalidTarget,
Arrays.asList(vehicle("1", 1, "SAME", 80, true, true, 5, 1)),
sites
);
assertEquals(DecisionCode.REJECT_INVALID_SITE, decision.getCode());
assertFalse(decision.getVehicle().isPresent());
assertTrue(decision.getReason().contains("UNKNOWN"));
}
@Test
void legacyDistanceUsesYBeforeX() {
VehicleSnapshot yNear =
vehicle("2", 1, "Y_NEAR_X_FAR", 80, true, true, 5, 1);
VehicleSnapshot xNear =
vehicle("1", 1, "Y_FAR_X_NEAR", 80, true, true, 5, 1);
VehicleAssignmentDecision decision =
service.select(task("SOURCE"), Arrays.asList(xNear, yNear), sites);
assertEquals("2", decision.getVehicle().get().getVehicleId());
assertTrue(decision.getReason().contains("yDistance=1"));
}
@Test
void legacyDistanceUsesXWhenYIsTied() {
VehicleSnapshot xNear =
vehicle("2", 1, "X_NEAR", 80, true, true, 5, 1);
VehicleSnapshot xFar =
vehicle("1", 1, "X_FAR", 80, true, true, 5, 1);
VehicleAssignmentDecision decision =
service.select(task("SOURCE"), Arrays.asList(xFar, xNear), sites);
assertEquals("2", decision.getVehicle().get().getVehicleId());
assertTrue(decision.getReason().contains("xDistance=1"));
}
private DispatchTask task(String sourceSite) { private DispatchTask task(String sourceSite) {
return new DispatchTask( return new DispatchTask(
"TASK-1", "TASK-1",