Skip to content

Commit b30df7f

Browse files
CopilotbedaHovorkaclaude
authored
SP0.12: A3 determinism re-validation + vyhybna integration gate for the lifted seam (#747)
* SP0.12: add conflict-event assertion to A3 gate + VyhybnaLiftedDriverIntegrationTest * SP0.12: fix VyhybnaLiftedDriverIntegrationTest to use lock-step handshake * SP0.12: address code review - fix comment style and simplify conflict events list * SP0.12: rename Vyhybna -> ShuntingLoop in test class/method names (keep vyhybna.xml) * Fix 6 findings from Goal 10 code review (safety gate, tearing, dedup) Addresses correctness bugs and a test-coverage gap surfaced by a full-branch code review of the Goal 10 SP0.x dispatcher-agent series: - Train.setTargetSpeed's "cleared path ahead" gate checked any block ownership instead of a reserved-but-not-occupied block, making it effectively non-functional once a train started moving. Added PathReservationService.getOccupiedBlocks() and tightened the check. - AgentLoopDriver read three separate ShuntingLoop observation fields per cycle, which could tear across sim ticks. Collapsed to a single getLatestObservation() call. - ShuntingLoopSmokeTest and TrainMovementIntegrationTest never wired wireSynchronousDispatcher, so their admission/movement assertions passed vacuously. Wired the dispatcher and replaced loose assertions with real admission/exit/capacity checks (manually verified they now fail without the wiring). - DefaultNetworkActuatorPort.setSwitchPosition checked lock state before the already-in-position check, violating its own idempotent-success contract for a locked switch already in the requested position. - DispatchDecisionApplier's duplicate-reservation guard never evicted entries, which could permanently block a legitimate re-reservation after a train reversal. Added evictReservationsFor(), wired to BLOCK_RELEASED. - DefaultNetworkPerceptionPort/DefaultNetworkActuatorPort each hand-rolled the same grid-scan loop. Extracted RailwayNetGrid.cellsOfType(). Also documents RuleBasedDispatcher.DEFAULT_MAX_CONCURRENT_TRAINS / ShuntingLoop.getMaxConcurrentTrains() as a station-topology safety invariant (not a tunable knob) and asserts it across the touched tests. Co-authored-by: Bedrich Hovorka <bedrich.hovorka@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 75f3208 commit b30df7f

20 files changed

Lines changed: 953 additions & 137 deletions

File tree

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/navigation/DefaultPathReservationService.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,11 @@ class DefaultPathReservationService(
869869
*/
870870
override fun getReservedBlocks(trainId: String): List<DynamicTrackBlock> = registry.getBlocks(trainId)
871871

872+
/**
873+
* Get all blocks currently physically occupied by a train.
874+
*/
875+
override fun getOccupiedBlocks(trainId: String): List<DynamicTrackBlock> = registry.getOccupiedBlocks(trainId)
876+
872877
/**
873878
* Implementation of reservePathToAny with intelligent target prioritization.
874879
*

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/navigation/PathReservationService.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,18 @@ interface PathReservationService {
242242
*/
243243
fun getReservedBlocks(trainId: String): List<DynamicTrackBlock>
244244

245+
/**
246+
* Get all blocks currently physically occupied by a train.
247+
*
248+
* This is a subset of [getReservedBlocks]: every occupied block is also reserved for
249+
* the occupying train, but a train may hold additional blocks that are reserved-but-not-yet-occupied
250+
* (the cleared path ahead of it).
251+
*
252+
* @param trainId Unique identifier for the train
253+
* @return List of blocks currently occupied by this train (empty if none)
254+
*/
255+
fun getOccupiedBlocks(trainId: String): List<DynamicTrackBlock>
256+
245257
/**
246258
* Find and reserve path from separator to any next semaphore via specific track section.
247259
*

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/ports/DefaultNetworkActuatorPort.kt

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import cz.vutbr.fit.interlockSim.objects.cells.DynamicRailSemaphore
1616
import cz.vutbr.fit.interlockSim.objects.cells.DynamicRailSwitch
1717
import cz.vutbr.fit.interlockSim.objects.cells.RailSwitch
1818
import cz.vutbr.fit.interlockSim.objects.cells.Signal
19-
import cz.vutbr.fit.interlockSim.objects.core.Cell
2019
import cz.vutbr.fit.interlockSim.objects.core.DynamicPathSeparator
20+
import cz.vutbr.fit.interlockSim.util.cellsOfType
2121
import io.github.oshai.kotlinlogging.KotlinLogging
2222

2323
private val logger = KotlinLogging.logger {}
@@ -141,12 +141,12 @@ class DefaultNetworkActuatorPort(
141141
logger.debug { "setSwitchPosition: unknown switch '$switchName'" }
142142
return false
143143
}
144+
if (sw.conf == position) return true
144145
if (sw.locked) {
145146
// Switch is locked by a route reservation — cannot be moved while a path is active.
146147
logger.debug { "setSwitchPosition: switch '$switchName' is locked by route reservation" }
147148
return false
148149
}
149-
if (sw.conf == position) return true
150150
return try {
151151
sw.changeConf()
152152
sw.conf == position
@@ -197,34 +197,20 @@ class DefaultNetworkActuatorPort(
197197
* Scans the grid once to build a name→semaphore index. Mirrors the strategy in
198198
* [DefaultNetworkPerceptionPort.buildSemaphoreCache].
199199
*/
200-
private fun buildSemaphoreCache(): Map<String, DynamicRailSemaphore> {
201-
val grid = env.getRailWayNetGrid()
202-
val result = mutableMapOf<String, DynamicRailSemaphore>()
203-
for (x in 0 until grid.cols) {
204-
for (y in 0 until grid.rows) {
205-
val cell: Cell? = grid.getCellAt(x, y)
206-
if (cell is DynamicRailSemaphore && cell.name.isNotBlank()) {
207-
result[cell.name] = cell
208-
}
209-
}
210-
}
211-
return result
212-
}
200+
private fun buildSemaphoreCache(): Map<String, DynamicRailSemaphore> =
201+
env
202+
.getRailWayNetGrid()
203+
.cellsOfType<DynamicRailSemaphore>()
204+
.filter { it.name.isNotBlank() }
205+
.associateBy { it.name }
213206

214207
/**
215208
* Scans the grid once to build a name→switch index.
216209
*/
217-
private fun buildSwitchCache(): Map<String, DynamicRailSwitch> {
218-
val grid = env.getRailWayNetGrid()
219-
val result = mutableMapOf<String, DynamicRailSwitch>()
220-
for (x in 0 until grid.cols) {
221-
for (y in 0 until grid.rows) {
222-
val cell: Cell? = grid.getCellAt(x, y)
223-
if (cell is DynamicRailSwitch && cell.name.isNotBlank()) {
224-
result[cell.name] = cell
225-
}
226-
}
227-
}
228-
return result
229-
}
210+
private fun buildSwitchCache(): Map<String, DynamicRailSwitch> =
211+
env
212+
.getRailWayNetGrid()
213+
.cellsOfType<DynamicRailSwitch>()
214+
.filter { it.name.isNotBlank() }
215+
.associateBy { it.name }
230216
}

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/ports/DefaultNetworkPerceptionPort.kt

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import cz.vutbr.fit.interlockSim.objects.core.TrackFacility
1818
import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrackBlock
1919
import cz.vutbr.fit.interlockSim.sim.Train
2020
import cz.vutbr.fit.interlockSim.util.DynamicWrapperUtils
21+
import cz.vutbr.fit.interlockSim.util.cellsOfType
2122

2223
/**
2324
* Simulation-backed implementation of [NetworkPerceptionPort].
@@ -298,17 +299,5 @@ class DefaultNetworkPerceptionPort(
298299
* Scans the grid once at construction time to build the semaphore list.
299300
* Matches the strategy used by the animation controller.
300301
*/
301-
private fun buildSemaphoreCache(): List<DynamicRailSemaphore> {
302-
val grid = env.getRailWayNetGrid()
303-
val result = mutableListOf<DynamicRailSemaphore>()
304-
for (x in 0 until grid.cols) {
305-
for (y in 0 until grid.rows) {
306-
val cell = grid.getCellAt(x, y)
307-
if (cell is DynamicRailSemaphore) {
308-
result.add(cell)
309-
}
310-
}
311-
}
312-
return result.toList()
313-
}
302+
private fun buildSemaphoreCache(): List<DynamicRailSemaphore> = env.getRailWayNetGrid().cellsOfType()
314303
}

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/RuleBasedDispatcher.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,18 @@ class RuleBasedDispatcher(
8585
val maxConcurrentTrains: Int = DEFAULT_MAX_CONCURRENT_TRAINS
8686
) : Dispatcher {
8787
companion object {
88-
/** Physical capacity of the vyhybna shunting loop: k1 and k2. */
88+
/**
89+
* Physical capacity of the `vyhybna.xml` shunting loop station: 2 parallel
90+
* tracks (k1, k2).
91+
*
92+
* This is a station-topology constant, not a tunable performance knob: a real
93+
* interlocking's concurrent-train capacity is determined by railway-civil-engineering
94+
* expertise at station design time (how many parallel tracks/platforms a given
95+
* station has), the same way [maxConcurrentTrains] is fixed per [RuleBasedDispatcher]
96+
* instance rather than adjusted at runtime. Changing this default only makes sense
97+
* if the bundled `vyhybna.xml` topology itself changes; a different station layout
98+
* should pass its own capacity via the [maxConcurrentTrains] constructor parameter.
99+
*/
89100
const val DEFAULT_MAX_CONCURRENT_TRAINS: Int = 2
90101
private val logger = KotlinLogging.logger {}
91102
}

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,15 @@ class ShuntingLoop(
198198
* [latestObservation] reference write, so a reader either sees all three from tick N
199199
* or all three from tick N-1 — never a mix.
200200
*
201+
* Public so off-kernel callers (e.g. [cz.vutbr.fit.interlockSim.dispatcher.AgentLoopDriver])
202+
* can read all three fields with a single [getLatestObservation] call instead of three
203+
* separate accessor calls, which would defeat the single-tick atomicity this type exists
204+
* to guarantee.
205+
*
201206
* @since Issue #733 (SP0.11 — Goal 10); single-snapshot publication added by the
202-
* SP0.11 review follow-up
207+
* SP0.11 review follow-up; made public by the tearing-fix follow-up
203208
*/
204-
private data class TickObservation(
209+
data class TickObservation(
205210
val queuedTrains: List<QueuedTrainObservation>,
206211
val innerBlockInputs: List<BlockInputObservation>,
207212
val outerBlockInputs: List<BlockInputObservation>
@@ -441,6 +446,21 @@ class ShuntingLoop(
441446
*/
442447
fun getOuterBlockInputs(): List<BlockInputObservation> = latestObservation.outerBlockInputs
443448

449+
/**
450+
* Returns the full per-tick observation bundle (queued trains plus inner/outer block
451+
* inputs) as published at the start of the most recent [iteration], in a single
452+
* @Volatile read. Safe to call from off-kernel threads.
453+
*
454+
* Prefer this over calling [getQueuedTrains]/[getInnerBlockInputs]/[getOuterBlockInputs]
455+
* separately when a caller needs more than one of the three fields together — three
456+
* independent calls can observe different sim ticks if the sim thread republishes
457+
* [latestObservation] in between, tearing the single-tick guarantee [TickObservation]
458+
* exists to provide.
459+
*
460+
* @since Issue #733 (SP0.11 — Goal 10); added by the tearing-fix follow-up
461+
*/
462+
fun getLatestObservation(): TickObservation = latestObservation
463+
444464
/**
445465
* Returns a snapshot of the currently approved (active) trains. Used by the external
446466
* [DefaultNetworkPerceptionPort][cz.vutbr.fit.interlockSim.ports.DefaultNetworkPerceptionPort]
@@ -539,6 +559,19 @@ class ShuntingLoop(
539559

540560
fun getTrainsExited(): Int = trainsExitedCount
541561

562+
/**
563+
* Peak number of trains simultaneously approved (holding an admitted slot) at any
564+
* observed tick during this run.
565+
*
566+
* **Safety invariant:** this value must never exceed the station's fixed topology
567+
* capacity ([RuleBasedDispatcher.DEFAULT_MAX_CONCURRENT_TRAINS], or whatever
568+
* `maxConcurrentTrains` the wired dispatcher was constructed with) — that capacity is
569+
* not a tunable performance knob, it is how many parallel tracks the physical station
570+
* actually has (2 for `vyhybna.xml`: k1, k2). If a dispatcher ever let a 3rd train in
571+
* concurrently on this topology, that train would have no physical track left to occupy —
572+
* an unresolvable state, not a recoverable "wait and retry" condition, since every
573+
* available parallel track is already held by the other two approved trains.
574+
*/
542575
fun getMaxConcurrentTrains(): Int = maxConcurrentTrainsCount
543576

544577
fun getBlockTransitions(trainId: String): Int = blockTransitionsByTrain[trainId] ?: 0

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Train.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,9 +1360,11 @@ class Train :
13601360
fun setTargetSpeed(speed: Double) {
13611361
require(speed >= 0.0) { "Target speed must be >= 0, got $speed" }
13621362
if (speed > 0.0) {
1363-
val reservedBlocks = env.getRoutingServices().getPathReservationService().getReservedBlocks(name)
1364-
if (reservedBlocks.isEmpty()) {
1365-
logger.warn { "Train $number: setTargetSpeed($speed) ignored — no route reserved for $name" }
1363+
val pathReservationService = env.getRoutingServices().getPathReservationService()
1364+
val reservedBlocks = pathReservationService.getReservedBlocks(name)
1365+
val occupiedBlocks = pathReservationService.getOccupiedBlocks(name).toSet()
1366+
if (reservedBlocks.all { it in occupiedBlocks }) {
1367+
logger.warn { "Train $number: setTargetSpeed($speed) ignored — no cleared block ahead for $name" }
13661368
return
13671369
}
13681370
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* Brno University of Technology
2+
* Faculty of Information Technology
3+
*
4+
* BSc Thesis 2006/2007
5+
*
6+
* Railway Interlocking Simulator
7+
*
8+
* Bedrich Hovorka
9+
*/
10+
package cz.vutbr.fit.interlockSim.util
11+
12+
import cz.vutbr.fit.interlockSim.context.RailwayNetGrid
13+
import cz.vutbr.fit.interlockSim.objects.core.Cell
14+
15+
/**
16+
* Returns every cell of type [R] currently placed in this grid, scanning each `x`/`y`
17+
* coordinate via [RailwayNetGrid.getCellAt].
18+
*
19+
* @since Goal 10 code-review fix — replaces the near-identical grid-scan loops previously
20+
* duplicated across [cz.vutbr.fit.interlockSim.ports.DefaultNetworkPerceptionPort] and
21+
* [cz.vutbr.fit.interlockSim.ports.DefaultNetworkActuatorPort]
22+
*/
23+
inline fun <reified R : Cell> RailwayNetGrid<Cell>.cellsOfType(): List<R> {
24+
val result = mutableListOf<R>()
25+
for (x in 0 until cols) {
26+
for (y in 0 until rows) {
27+
val cell = getCellAt(x, y)
28+
if (cell is R) result.add(cell)
29+
}
30+
}
31+
return result
32+
}

core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/ports/DefaultNetworkActuatorPortTest.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,14 @@ class DefaultNetworkActuatorPortTest {
388388
val p = port(cells = mapOf((0 to 0) to sw))
389389
assertThat(p.setSwitchPosition("vA", RailSwitch.Conf.MAIN)).isTrue()
390390
}
391+
392+
@Test
393+
@DisplayName("returns true when switch is locked but already in requested position (idempotent no-op)")
394+
fun lockedButAlreadyInPositionReturnsTrue() {
395+
val sw = switch("vA", conf = RailSwitch.Conf.MAIN, locked = true)
396+
val p = port(cells = mapOf((0 to 0) to sw))
397+
assertThat(p.setSwitchPosition("vA", RailSwitch.Conf.MAIN)).isTrue()
398+
}
391399
}
392400

393401
// ── setSignalAspect ─────────────────────────────────────────────────────

0 commit comments

Comments
 (0)