|
| 1 | +# Design: Network Perception Port (#541) |
| 2 | + |
| 3 | +**Issue:** [bedaHovorka/interlockSim#541](https://github.com/bedaHovorka/interlockSim/issues/541) |
| 4 | +**Parent:** [bedaHovorka/interlockSim#532](https://github.com/bedaHovorka/interlockSim/issues/532) — Goal 10 DISPATCHER agent |
| 5 | +**Canonical authority:** [#532](https://github.com/bedaHovorka/interlockSim/issues/532) issue body. Where this |
| 6 | +document conflicts with it, the #532 body wins. |
| 7 | +**Status:** Final design record (2026-07-07), retro-documenting the perception port shipped in PR #722. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 1. Summary |
| 12 | + |
| 13 | +This slice introduces a **read-only sensor layer** between the simulation and future |
| 14 | +perception-driven dispatchers. `cz.vutbr.fit.interlockSim.ports.NetworkPerceptionPort` is a |
| 15 | +KMP-clean interface in `:core` `commonMain` whose methods each return an immutable snapshot |
| 16 | +(`*Reading` value type) of one facet of the network: signal aspects, block occupancies, train |
| 17 | +positions, and train timetables. The default implementation, |
| 18 | +`DefaultNetworkPerceptionPort`, is backed by the live `SimulationEnvironment` plus a supplier of |
| 19 | +currently active trains. |
| 20 | + |
| 21 | +The port is **wired into `DispatcherTickContext`** but **not yet consumed** by |
| 22 | +`RuleBasedDispatcher` — the current dispatcher is a plain FIFO/topology dispatcher that needs no |
| 23 | +perception. The port exists so that the Goal 10 LLM dispatcher track (SP1.6, Koog tools) can observe |
| 24 | +the network through a stable, decoupled, value-typed surface instead of reaching into `sim/` |
| 25 | +internals. Each interface method maps one-to-one to a future Koog tool. |
| 26 | + |
| 27 | +No actuator behavior, dispatch logic, or simulation state changes belong in this slice. The port |
| 28 | +is purely observational. |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## 2. Goals and Non-Goals |
| 33 | + |
| 34 | +### Goals |
| 35 | + |
| 36 | +1. Give future dispatchers a read-only, string-keyed view of the network. |
| 37 | +2. Return **immutable snapshots only** — no live mutable references out to callers. |
| 38 | +3. Stay KMP-clean: no `java.*`/`javax.*` in `commonMain` (enforced by |
| 39 | + `:core:checkCoreCommonMainPurity`). |
| 40 | +4. Introduce the abstraction **without changing dispatch behavior** — `RuleBasedDispatcher` keeps |
| 41 | + its current semantics. |
| 42 | +5. Keep the wiring conservative for `sim/`: no new Koin bindings, no refactoring of working sim |
| 43 | + logic. |
| 44 | + |
| 45 | +### Non-Goals |
| 46 | + |
| 47 | +1. Not a dispatch API — the port observes; it does not decide or act. |
| 48 | +2. Not consumed by `RuleBasedDispatcher` in this slice. |
| 49 | +3. No thread-safety contract beyond single-threaded kDisco access (documented on the class). |
| 50 | +4. No block-occupancy or signal change events/streaming — synchronous pull queries only. |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## 3. Package and Source Location |
| 55 | + |
| 56 | +All types live in `:core` `commonMain`, package `cz.vutbr.fit.interlockSim.ports`: |
| 57 | + |
| 58 | +- `NetworkPerceptionPort.kt` — the interface. |
| 59 | +- `DefaultNetworkPerceptionPort.kt` — the `SimulationEnvironment`-backed implementation. |
| 60 | +- `SemaphoreReading.kt`, `BlockOccupancyReading.kt`, `TrainPositionReading.kt`, |
| 61 | + `TimetableReading.kt` — immutable `data class` value types (all `val`, primitives/`String`/ |
| 62 | + `enum`/`Signal` only). |
| 63 | + |
| 64 | +`commonMain` placement keeps the port available to both the JVM dispatcher and any future native |
| 65 | +target. The purity gate guarantees no JVM-only API leaks in. |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## 4. Public API Surface |
| 70 | + |
| 71 | +`NetworkPerceptionPort` exposes, per facet: |
| 72 | + |
| 73 | +- **Signal aspects:** `signalAspect(name): SemaphoreReading?`, `allSignalAspects(): List<SemaphoreReading>`. |
| 74 | +- **Block occupancies:** `blockOccupancy(blockId): BlockOccupancyReading?`, |
| 75 | + `allBlockOccupancies(): List<BlockOccupancyReading>`. |
| 76 | +- **Train positions:** `trainPosition(trainId): TrainPositionReading?`, |
| 77 | + `allTrainPositions(): List<TrainPositionReading>`. |
| 78 | +- **Train timetables:** `trainTimetable(trainId): TimetableReading?`, |
| 79 | + `allTrainTimetables(): List<TimetableReading>`. |
| 80 | + |
| 81 | +Each `*Reading` is a `data class` of immutable fields; `trainId`/`blockId` are `String?` or `String`. |
| 82 | +The interface KDoc notes the intended one-method-per-Koog-tool mapping for SP1.6. |
| 83 | + |
| 84 | +--- |
| 85 | + |
| 86 | +## 5. Key Decisions |
| 87 | + |
| 88 | +**D1 — Backed by `SimulationEnvironment`, not a parallel state copy.** |
| 89 | +The implementation reads the grid (`getRailWayNetGrid()`) and the graph (`getGraph()`) directly |
| 90 | +from the simulation environment. There is no duplicate shadow state to keep in sync; the |
| 91 | +environment is the single source of truth and the port is a read lens over it. |
| 92 | + |
| 93 | +**D2 — Eager, lifetime-of-port caches; live state read at query time.** |
| 94 | +Both semaphores (`semaphoreCache` / `semaphoreByName`) and blocks (`blockCache` / `blockById`) |
| 95 | +are scanned once at construction and indexed by name/id. Cells and graph edges are stable for the |
| 96 | +lifetime of a simulation context (no cells/edges are added or removed at runtime), so the caches |
| 97 | +store **references** to the dynamic objects and are never invalidated. Live state |
| 98 | +(`sem.signal`, `block.getState()` / `trainName` / `occupant`) is read on those cached references at |
| 99 | +each query. This makes `signalAspect` / `blockOccupancy` O(1) lookups and `allSignalAspects` / |
| 100 | +`allBlockOccupancies` O(n) with no per-call graph re-scan. (The block cache was added after the |
| 101 | +initial PR in response to code review — see §7.) |
| 102 | + |
| 103 | +**D3 — Direct construction in `ShuntingLoop.createTickContext()`, not Koin.** |
| 104 | +`perceptionPort` is built by direct construction inside `ShuntingLoop.createTickContext()`, with |
| 105 | +`activeTrains = { approvedTrains.toList() }` supplied as a lambda so it always reflects the |
| 106 | +currently approved trains at query time. This deliberately avoids a Koin binding, consistent with |
| 107 | +the conservative, minimal-change policy for the `sim/` package: the port is rebuilt per tick |
| 108 | +context, owns no global state, and introduces no DI lifecycle complexity. The Koin restriction for |
| 109 | +`sim/` was lifted 2026-03-20, but the conservative choice to *not* use it here is intentional and |
| 110 | +recorded. |
| 111 | + |
| 112 | +**D4 — `Train` exposes narrow read-only scalar properties, not the live `Timetable`.** |
| 113 | +The port needs four timetable-derived values (origin name, destination name, scheduled departure |
| 114 | +time, scheduled arrival time). Rather than expose `fun getTimetable(): Timetable` — which would |
| 115 | +hand callers a live reference to the mutable `Timetable` (its `in`/`out`/`length` are `var`, and its |
| 116 | +`DynamicInOut` endpoints expose mutable semaphore state) — `Train` exposes four `val` properties |
| 117 | +(`timetableOriginName`, `timetableDestinationName`, `scheduledDepartureTime`, |
| 118 | +`scheduledArrivalTime`) delegating to the private `timetable` field. This keeps the perception |
| 119 | +data flow strictly one-directional: snapshots out, nothing mutable back in. (This was tightened |
| 120 | +after the initial PR in response to code review — see §7.) |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | +## 6. Traceability to Goal 10 |
| 125 | + |
| 126 | +- **Parent:** #532 (Goal 10 DISPATCHER agent). |
| 127 | +- **Downstream consumers:** SP1.6 (Koog tool wiring — one port method → one tool, per the |
| 128 | + `NetworkPerceptionPort` KDoc), and the SP3.1 LLM dispatcher track documented in |
| 129 | + `docs/GOAL_10_SP3_1_LLM_MODEL_EVALUATION.md`. |
| 130 | +- **Sibling design:** `docs/superpowers/specs/2026-07-04-570-operating-vocabulary-dsl-design.md` |
| 131 | + (the typed vocabulary the dispatcher will emit/consume; the perception port is the *input* side). |
| 132 | +- **Paramount example:** `vyhybna.xml` — the port must fully describe this network's signals, |
| 133 | + blocks, and the single train's timetable. |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## 7. Implementation Checklist |
| 138 | + |
| 139 | +This slice shipped in PR #722 ("Define sensor ports for perception interface", closes #541). This |
| 140 | +document is the retro-record. The two post-review tightenings are: |
| 141 | + |
| 142 | +- [x] D4: replaced `Train.getTimetable()` with four narrow `val` properties (encapsulation fix). |
| 143 | +- [x] D2: added the `blockCache` / `blockById` index so `blockOccupancy(id)` is an O(1) map lookup |
| 144 | + instead of an O(n) graph re-scan with recomputed `blockId` per block. |
| 145 | + |
| 146 | +Tests: `DefaultNetworkPerceptionPortTest` (20 unit tests, MockK) covers the port contract; |
| 147 | +`RuleBasedDispatcherDeterminismTest` (10 real `vyhybna.xml` runs through kDisco) is the |
| 148 | +end-to-end guard that the `ShuntingLoop` rewrite + port wiring did not alter simulation behavior. |
0 commit comments