fix: enforce deterministic dispatch boundaries
This commit is contained in:
@@ -3,6 +3,7 @@ package com.ft546.wcs.demo;
|
||||
import com.ft546.wcs.demo.center.CycleResult;
|
||||
import com.ft546.wcs.demo.center.LegacyDispatchCenter;
|
||||
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.model.DispatchTask;
|
||||
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 java.io.PrintStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class DemoApplication {
|
||||
|
||||
@@ -58,6 +60,7 @@ public final class DemoApplication {
|
||||
|
||||
out.println("FT546 WCS LEGACY DISPATCH AND TRAFFIC DEMO");
|
||||
out.println("MODE LEGACY / IO DISABLED / IN-MEMORY ONLY");
|
||||
printCrossFloorAssignmentCheck(out, map);
|
||||
|
||||
for (int cycle = 0; cycle < 6; cycle++) {
|
||||
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() {
|
||||
GridMap map = new GridMap();
|
||||
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("V3", 1, 0, 1, "V3-CHANNEL"));
|
||||
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("B", "TARGET");
|
||||
map.connect("B", "CACHE");
|
||||
|
||||
@@ -16,13 +16,13 @@ import java.util.Optional;
|
||||
|
||||
public final class LegacyDispatchCenter {
|
||||
private final InMemoryWcsState state;
|
||||
private final QueueAdmissionService admission;
|
||||
private final QueueAdmission admission;
|
||||
private final TrafficController traffic;
|
||||
private final SimulatedProtocolAdapter protocol;
|
||||
|
||||
public LegacyDispatchCenter(
|
||||
InMemoryWcsState state,
|
||||
QueueAdmissionService admission,
|
||||
QueueAdmission admission,
|
||||
TrafficController traffic,
|
||||
SimulatedProtocolAdapter protocol
|
||||
) {
|
||||
@@ -42,8 +42,6 @@ public final class LegacyDispatchCenter {
|
||||
int cycleNumber = state.nextCycle();
|
||||
state.addEvent("CYCLE " + cycleNumber);
|
||||
|
||||
admission.createPendingQueue(state);
|
||||
|
||||
List<VehicleSnapshot> vehicles =
|
||||
new ArrayList<VehicleSnapshot>(state.getVehicles());
|
||||
Collections.sort(vehicles, new Comparator<VehicleSnapshot>() {
|
||||
@@ -53,6 +51,8 @@ public final class LegacyDispatchCenter {
|
||||
}
|
||||
});
|
||||
|
||||
admission.createPendingQueue(state, vehicles);
|
||||
|
||||
List<String> processed = new ArrayList<String>();
|
||||
for (VehicleSnapshot frozenVehicle : vehicles) {
|
||||
processed.add(frozenVehicle.getVehicleId());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class QueueAdmissionService {
|
||||
public final class QueueAdmissionService implements QueueAdmission {
|
||||
private final VehicleAssignmentService assignmentService;
|
||||
private final GridMap map;
|
||||
private int invocationCount;
|
||||
@@ -26,9 +26,13 @@ public final class QueueAdmissionService {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
public Optional<DispatchQueueEntry> createPendingQueue(InMemoryWcsState state) {
|
||||
if (state == null) {
|
||||
throw new IllegalArgumentException("state must not be null");
|
||||
@Override
|
||||
public Optional<DispatchQueueEntry> createPendingQueue(
|
||||
InMemoryWcsState state,
|
||||
List<VehicleSnapshot> frozenVehicles
|
||||
) {
|
||||
if (state == null || frozenVehicles == null) {
|
||||
throw new IllegalArgumentException("state and frozen vehicles are required");
|
||||
}
|
||||
invocationCount++;
|
||||
if (state.getPendingTasks().isEmpty()) {
|
||||
@@ -37,7 +41,7 @@ public final class QueueAdmissionService {
|
||||
|
||||
DispatchTask task = state.getPendingTasks().get(0);
|
||||
List<VehicleSnapshot> available = new ArrayList<VehicleSnapshot>();
|
||||
for (VehicleSnapshot vehicle : state.getVehicles()) {
|
||||
for (VehicleSnapshot vehicle : frozenVehicles) {
|
||||
if (!state.findQueueByVehicle(vehicle.getVehicleId()).isPresent()) {
|
||||
available.add(vehicle);
|
||||
}
|
||||
@@ -65,6 +69,7 @@ public final class QueueAdmissionService {
|
||||
return Optional.of(queue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInvocationCount() {
|
||||
return invocationCount;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ public final class VehicleAssignmentService {
|
||||
"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>();
|
||||
boolean eligibleWithUnknownSite = false;
|
||||
|
||||
@@ -18,6 +18,7 @@ class DemoScenarioTest {
|
||||
DemoApplication.runScenario(new PrintStream(output, true, "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("ASSIGN"));
|
||||
assertTrue(text.contains("PATH"));
|
||||
@@ -27,6 +28,24 @@ class DemoScenarioTest {
|
||||
assertTrue(text.contains("HOLD HOLD_NO_PATH"));
|
||||
assertTrue(text.contains("SEND"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ft546.wcs.demo;
|
||||
|
||||
import com.ft546.wcs.demo.center.CycleResult;
|
||||
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.VehicleAssignmentService;
|
||||
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 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.assertFalse;
|
||||
@@ -93,6 +96,43 @@ class LegacyDispatchCenterTest {
|
||||
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() {
|
||||
GridMap map = simpleMap();
|
||||
InMemoryWcsState state = new InMemoryWcsState();
|
||||
|
||||
@@ -28,6 +28,10 @@ class VehicleAssignmentServiceTest {
|
||||
sites.put("SAME", new Site("SAME", 1, 1, 1, "AISLE-1"));
|
||||
sites.put("NEAR", new Site("NEAR", 2, 10, 9, "AISLE-2"));
|
||||
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
|
||||
@@ -85,6 +89,56 @@ class VehicleAssignmentServiceTest {
|
||||
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) {
|
||||
return new DispatchTask(
|
||||
"TASK-1",
|
||||
|
||||
Reference in New Issue
Block a user