Goal 10 parent PR - #721
Draft
bedaHovorka wants to merge 56 commits into
Draft
Conversation
bedaHovorka
force-pushed
the
goal-10
branch
2 times, most recently
from
July 7, 2026 16:52
5df3642 to
03f884f
Compare
bedaHovorka
marked this pull request as ready for review
July 7, 2026 17:01
bedaHovorka
marked this pull request as draft
July 7, 2026 17:01
8 tasks
* Add operating vocabulary DSL design in .md
* Add SP3.1 LLM model evaluation doc for Goal 10 agents * Finalize SP3.1 doc per rewritten #532 canonical description .md file
…tcher-agent module (#720) * SP0.1: Extract ShuntingLoop dispatch to RuleBasedDispatcher; add :dispatcher-agent module * desktop-ui module after agent to settings.gradle.kts * SP0.1 review fixes: split Dispatcher seam, widen tick context, add unit tests Address PR #720 code-review findings (Important #1-#4, Minor #6-#7): - #1 Restore original approve -> hold -> check ordering: split Dispatcher.tick() into approve() (before polling hold) and advancePaths() (after it) so ShuntingLoop can place hold(1.0) between admission and path-advancement, matching the d8780c7 baseline. Re-add the "Train entry events align with polling" comment. - #2 Remove FIFO bias from the seam: replace pollUnapproved() + unapprovedTrainCount with unapprovedTrains: List<Train> so a future LLM dispatcher can select by priority, not just pop head. approveTrain(train) now takes the specific train; the shell removes that exact train from the queue. - #7 Drop leaky toDynamic callback: replace with high-level isPathSetUp(block, to): Boolean. The getSecondEnd + env.toDynamic + block.isSetUpPath conversion stays inside the shell; dispatchers reason in blocks and semaphores, not internal wrappers. - #3 Add branch-level unit tests for RuleBasedDispatcher (core/jvmTest) with a fake DispatcherTickContext + MockK stubs covering approve cap/empty, all checkOneEnd branches, checkBothEnds short-circuit, and the constructor guard. - #4 Fix RuleBasedDispatcherDeterminismTest docstring: per-train transition count is 2 (verified: [2,2,2,2,2]), not 7. - #6 Correct PR #720 module graph: :core <- :dispatcher-agent at SP0.1; :fast-sim depends only on :core; the :dispatcher-agent <- :desktop-ui edge is planned for Stage B. Verification: ./gradlew build green (core/desktop-ui/dispatcher-agent detekt+ktlint+test+integrationTest, fast-sim native compile).
Define NetworkPerceptionPort with read-only sensor readings (block occupancy, semaphore state, train position, timetable) so a future LLM dispatcher can observe network state without depending on mutable domain internals. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Changes before error encountered Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/eb261862-33e9-46b3-a285-160ebfd5c5b7 * SP0.3: fix ktlint violations in ActuatorPortsTest * SP0.3: apply actuator-port review fixes (#723) Address code-review findings on PR #723 (Important #1-#5, Minor #1-#3): - Move TrainActuatorPort / NetworkActuatorPort / ActuatorPortsTest from cz.vutbr.fit.interlockSim.sim to cz.vutbr.fit.interlockSim.ports so the act-side ports sit beside their #722 sense-side siblings. Test stays in commonTest so it still runs on linuxX64 for :fast-sim. - Add NetworkActuatorPort.releaseRoute(trainName): Boolean — the symmetric counterpart to requestRoute (idempotent; throws on blank name). Mirrors PathReservationService.releasePath so the "stable" seam ships with a release command before SP1.4 binds real impls. - Tighten setSignalAspect contract: false also covers interlocking safety refusal (no compatible reserved route, conflicting switch state), aligning with setSwitchPosition's two failure modes and the class-level guarantee. - Specify requestRoute invalid-input: blank trainName and unknown InOut names throw IllegalArgumentException (unknown names must fail fast); NoRouteExists is reserved for valid-but-disconnected endpoints. - Document AllPathsBlocked.attemptedPaths >= 0. - Tests: add ContractValidatingTrainActuator (negative-speed throw) and ContractValidatingNetworkActuator (blank/unknown-name throws) stubs with assertFailsWith tests; add releaseRoute forwarding + return tests; rewrite exhaustive-when test with real per-case assertions (compile-time exhaustiveness preserved via else-less when); replace redundant as cast with assertk reified isInstanceOf<T>() chaining. Verification: :core:detekt, :core:ktlintCheck, :core:checkCoreCommonMainPurity green; ActuatorPortsTest 21/21 on JVM and linuxX64; ./gradlew build green.
* SP0.4: observable simulation state at a control step (#543) * SP0.4 follow-up: narrow snapshot simTime fallback + add frozen-snapshot test Address review Minor #2 and #3 from PR #725 (#543): - DefaultNetworkPerceptionPort.snapshot(): replace broad runCatching { Process.time() }.getOrDefault(0.0) with a try/catch narrowed to DiscoException, so the "not inside a kDisco simulation" fallback still returns 0.0 but genuine unexpected exceptions (NPE, etc.) propagate instead of being silently masked as simTime = 0.0. - Add snapshotIsFrozenFromLaterSourceMutation test: captures a snapshot, mutates the underlying mock source state and clears the active-trains list, then asserts the captured readings are unchanged — locking the frozen/isolated-snapshot contract. No sim/ logic, RuleBasedDispatcher, or public API behavior changed.
* SP0.6: Implement DefaultNetworkActuatorPort, DefaultTrainActuatorPort, and wire networkActuator into DispatcherTickContext (#545) * SP0.6: Add safety guards and partial-route support per review feedback * SP0.6: Rename requestRoute parameters from InOut to endpoint names per review * SP0.6: Refine actuator port contract per review (I1/I2/Q1) Code-review corrections for PR #726: * setSignalAspect: remove the isAllowing() refusal guard. Both upgrades and downgrades on a dynamic semaphore are now permitted (downgrades are physically hard for a train to reach due to braking, but allowed at the command surface). Constant semaphores (predzvest/naraznik/rychlostnik) stay constant via a post-condition `return sem.signal == signal`, which also fixes the false-success reported for constant semaphores whose setter is a no-op (I1). KDoc on NetworkActuatorPort.setSignalAspect updated to the new contract. * requestRoute: surface reservation conflicts as a distinct RouteRequestResult.Conflict(blockName, existingOwner) instead of collapsing them to AllPathsBlocked(0), which discarded the owning train and block an LLM dispatcher could reason about (I2). String-only fields honour the port's no-domain-object-leak contract. KDoc + usage example updated; ActuatorPortsTest exhaustiveness guard extended. Q2 (setSwitchPosition sw.locked guard) and Q3 (Train.setTargetSpeed backstop) are left unchanged per owner decision -- Q2 is correct as-is, Q3 deferred to a later SP. Tests: SetSignalAspect tests rewritten to use real DynamicRailSemaphore instances (constant vs dynamic) so the post-condition is exercised end-to-end, not just mocks; Conflict-mapping tests assert the new RouteRequestResult.Conflict carrying block/owner (named and unnamed).
* SP0.8: Add ActuatorCommandQueue with tests * SP0.8: Address review feedback on ActuatorCommandQueue * SP0.8: Document approximateSize race and use getCount() * SP0.8: Use dedicated lock object and clarify postAll KDoc * SP0.8: Expand KDoc and stabilize consumer test * SP0.8: Keep addAll under lock and batch size decrement * SP0.8: Fail-fast capacity check and stable consumer termination * SP0.8: Clarify capacity error and extract test timeout constant * SP0.8: Extract magic numbers and fix error message grammar * SP0.8: Fix approximateSize in unlimited mode, postAll KDoc, and consumer test determinism Track the size counter unconditionally so approximateSize() is correct in unlimited (negative-capacity) mode, not silently 0; gate only the capacity check on capacity > 0 to preserve the lock-free unlimited path. Reword the postAll KDoc to drop the inaccurate 'non-blocking' claim in favor of an accurate description of the capacity-accounting critical section. Replace the consumer stress-test's getCount()+empty-drains termination heuristic with await()+final-drain, whose happens-before guarantee makes the test provably deterministic; remove the now-unused EMPTY_DRAINS_THRESHOLD.
…on (#735) * SP0.7: Move DispatchDecision into :core, add DispatchObservation Additive, zero-behavior-change step for Issue #729: introduces the new DispatchObservation/QueuedTrainObservation/BlockEndObservation types the pure Dispatcher.decide() seam will consume, and relocates DispatchDecision from :dispatcher-agent (SP0.1 placeholder) into :core, since Dispatcher and RuleBasedDispatcher live in :core and :dispatcher-agent depends on :core, never the reverse. Updates ActuatorCommandQueue's import accordingly (its PR landed on goal-10 after this branch was cut). * SP0.7: Reshape Dispatcher to a pure decide() seam Replaces the imperative Dispatcher.approve(context)/advancePaths(context) pair (DispatcherTickContext callbacks) with a single pure decide(observed: DispatchObservation): List<DispatchDecision>. This is the seam both RuleBasedDispatcher and the future LLM dispatcher (SP2b) sit behind. RuleBasedDispatcher now reads DispatchObservation/BlockEndObservation value data instead of live simulation objects, and returns decisions instead of calling callbacks. Both ends of an inner block are evaluated independently (the old checkBothEnds short-circuit depended on a live actuation return value that a pure decide() cannot have; behaviorally equivalent on real data since a block's occupant/reservedFrom are single-valued per block — see RuleBasedDispatcher KDoc). ShuntingLoop remains the single, synchronous, in-kernel caller for this slice: it builds a DispatchObservation each phase and applies the returned decisions inline via new private methods that reproduce exactly what DispatcherTickContext's callbacks did (admitting a train, resolving a semaphore by name and reserving a path through the existing tryReservePathFrom). No ActuatorCommandQueue, no second thread, no :dispatcher-agent involvement here — that lifted, cross-thread driver is SP0.8-SP0.10 (already landed/in flight separately). RuleBasedDispatcherTest is rewritten against DispatchObservation value classes (no more mocks/fake context). RuleBasedDispatcherDeterminismTest needed no source changes; 10 consecutive vyhybna.xml runs still produce identical trainsExited/maxConcurrentTrains/block-transition counts, confirming the refactor is behavior-preserving. * SP0.7: Make ReservePath explicit from→to, rename ends→inputs (#735 follow-up) Address PR #735 review: the dispatcher decision must carry an explicit from→to path, and the seam's vocabulary is "inputs" (a train entering the next section at a block entry point), not ShuntingLoop-specific "ends". DispatchDecision.ReservePath is now (trainId, fromSemaphoreName, toSeparatorName) — one section, `to` = next separator toward the destination (a semaphore, or the destination InOut for the final section). The shell pre-computes `to` as the first FREE next separator and the applier calls PathReservationService.reservePath(train, from, to) directly, reproducing the pre-#729 reservePathToAnyNextSemaphore outcome. Behavior preservation: rather than reimplementing the next-semaphore selection heuristic in the shell (a first attempt broke the 3 ShuntingLoop integration tests — the forward-facing direction() check was wrong), expose a read-only PathReservationService.findNextReservationTarget that reuses the existing findNextSemaphoresVia + isPathAvailable logic — the read-only twin of reservePathToAnyNextSemaphore. The determinism gate (RuleBasedDispatcherDeterminismTest, 10x vyhybna.xml) stays 10/10 green. Rename (type + fields + methods + KDoc + tests): BlockEndObservation -> BlockInputObservation innerBlockEnds/outerBlockEnds -> innerBlockInputs/outerBlockInputs isApproachingThisEnd/pathSetUpTowardThisEnd -> ...ThisInput decideForEnd -> checkInput, decidePathAdvancements -> checkAllInputs BlockInputObservation.toSeparatorName (new, nullable) drives NoAction when no FREE next separator exists (train waits, retries). Stale checkBothEnds/checkOneEnd comments in operational/execution tests updated to checkAllInputs/checkInput + applyReservePath. Tests: RuleBasedDispatcherTest rewritten for 3-arg ReservePath + the null-toSeparatorName deferral; adds multi-block frozen-observation test (C1), destinationInOutName preservation (C2), same-block unreachable edge case (C3), and ShuntingLoop.failedReservationsCount counter (C4). ActuatorCommandQueueTest updated for 3-arg ReservePath. Verification: :core jvmTest 2177, :core integrationTest 721, :desktop-ui 650, :dispatcher-agent 11 + determinism 10/10 — all green; :core detekt/ktlint/checkCoreCommonMainPurity + :dispatcher-agent detekt/ktlint clean.
…ports (#737) * feat(SP0.9): sim-thread applier draining ActuatorCommandQueue via actuator ports - Add ControlStepListener fun interface to :core/sim — generic hook called on the kDisco sim thread at the start of each ShuntingLoop iteration; no dispatcher logic in :core (satisfies option-2 gate criterion). - Modify ShuntingLoop: - `var controlStepListener: ControlStepListener?` — set by wiring layer before context.run(); null by default so all existing callers are unaffected. - `fun approveQueuedTrain(trainId)` — public delegate to applyApproveTrain, used by DispatchDecisionApplier's onApproveTrain callback. - `controlStepListener?.onControlStep()` called at top of iteration() to drain and apply any pending queue decisions before the inline dispatcher runs. - Add DispatchDecisionApplier to :dispatcher-agent — implements ControlStepListener; drains ActuatorCommandQueue and routes each DispatchDecision: ApproveTrain → onApproveTrain callback, ReservePath → NetworkActuatorPort.requestRoute, NoAction → no-op. - Add DispatchDecisionApplierTest (14 tests) covering decision routing, FIFO order, empty-queue no-ops, all RouteRequestResult outcomes, and four thread-identity assertions asserting actuator calls and the approve callback always execute on the onControlStep thread (not the posting/driver thread). - Add io.mockk:mockk to :dispatcher-agent test dependencies. Closes #731. * fix(SP0.9): PR #737 review follow-up — seam test + exhaustive when Addresses the two Important issues from the PR #737 code review: * Add ShuntingLoopControlStepListenerTest (:core jvmTest, integration-tagged): - proves controlStepListener.onControlStep() is invoked exactly once per iteration, before the admission Dispatcher.decide() call (the load-bearing ordering invariant the SP0.9 design rests on) — endTime=0L gives one iteration; a custom Dispatcher records that the listener already ran. - proves approveQueuedTrain delegates to applyApproveTrain and moves a queued train into the approved set — a NoAction-only dispatcher makes the callback the only approval path; the listener consumes the captured id once (getAndSet(null)) so re-approval cannot throw in applyApproveTrain's requireNotNull and crash the kDisco process mid-iteration. Closes the conservative-sim test-coverage gap (the iteration() seam had no direct test; DispatchDecisionApplierTest only exercised onControlStep on the test thread). * Make the two `when` constructs in DispatchDecisionApplier compile-enforced exhaustive over the sealed DispatchDecision / RouteRequestResult types (expression-body `when`), matching DefaultNetworkActuatorPort.requestRoute. A future subtype (e.g. HoldTrain in SP2b, #556) is now a compile error instead of a silently-dropped decision. Also records the Minor #4 coupling observation (ApproveTrain-via-callback) as a code comment in DispatchDecisionApplier's onApproveTrain KDoc and as a comment on GitHub issue #556, recommending a TrainAdmissionPort extraction for SP2b train-lifecycle commands. Verified: :core:integrationTest 2/2, :dispatcher-agent:test + :core:jvmTest 2177/2177, detekt + ktlintCheck clean.
…ontroller (#739) * SP0.10: Add AgentLoopDriver — paced sense-decide-act loop via SimulationController * SP0.10: fix review — off-thread-safe snapshot + contract KDoc Address Important + Minor findings from PR #739 review (#732 SP0.10). Issue 2 — DefaultNetworkPerceptionPort.snapshot() was not off-thread-safe: it read plain non-volatile mutable sim fields and iterated the live approwedTrains list, so an off-thread driver call could throw a ConcurrentModificationException. Fix follows the spec's capture-on-sim-thread model (never blocks/interrupts the kDisco kernel — no sim-thread lock): - Add @volatile latestCaptured cache; captureSnapshot() does the fresh on-thread read and publishes; snapshot() returns latestCaptured or SimulationSnapshot.EMPTY — a pure cached read that never touches live state, never throws, never blocks the kernel. - Add SimulationSnapshot.EMPTY companion (default before first capture). - Migrate ShuntingLoop's two on-thread snapshot() calls (admission + path-advancement) to captureSnapshot() — behavior-preserving (still fresh reads, plus the cache publish side-effect). - AgentLoopDriver keeps calling snapshot(), now genuinely off-thread-safe. Issue 1 — contract drift: the :core KDoc on Dispatcher, SimulationController and NetworkPerceptionPort still claimed single-kDisco-thread exclusivity, contradicting the SP0.5 spec which runs the drive loop on the driver's own thread. Updated to reflect that the kDisco kernel runs on its own single thread while outside callers (the SP0.10 driver, future LLM) run in their own threads; implementations must not rely on kDisco-thread exclusivity. Minor 4 — rewrote the "mirrors DefaultSimulationContext" comment in AgentLoopDriver.runCycle() without exact numbers and without the misleading call-order claim (DefaultSimulationContext does throttle→awaitIfPaused; the driver does awaitIfPaused→throttle); kept an accurate initial-delta convention reference. Tests: added captureSnapshot() to the two SimulationSnapshotTest stubs; migrated the six fresh-read assertions in DefaultNetworkPerceptionPortTest to captureSnapshot() and added two cached-snapshot() tests (returns EMPTY before first capture; returns last capture and stays frozen through later mutations). AgentLoopDriverTest unchanged (driver uses the mocked snapshot()).
…tcher (#740) * feat(SP0.11): thin ShuntingLoop shell + Koin/DI wiring for lifted dispatcher (#733) * fix(SP0.11): suppress duplicate ReservePath race + CI green-up The async decoupling between AgentLoopDriver and the kDisco sim thread let the driver decide an identical ReservePath twice before the first application was reflected back (pathAlreadyExtendedBeyond only updates post-application). Re-applying it corrupted the train's registered path via a false-positive "legitimate circular route" match in PathReservationRegistry, permanently stalling the train. DispatchDecisionApplier now suppresses re-applying an already-reserved (trainId, from, to) triple, without touching shared navigation code. Raises RuleBasedDispatcherDeterminismTest from 0/10 to ~6/10 passing. The remaining intermittent failures are a second, pre-existing bug in PathReservationRegistry.mergePathInfo (fork-overlap vs. genuine circular-loop revisit are structurally indistinguishable by path position alone) — tracked as a follow-up, sub-issue #742 of #535. Also: add @timeout to ActuatorCommandQueueTest, AgentLoopDriverTest, DispatchDecisionApplierTest, RuleBasedDispatcherTest (none had one); cap dispatcher-agent test logging at WARN (was defaulting to a much more verbose level, producing multi-GB test-output artifacts); fix 4 ktlint violations blocking CI (import ordering in ExampleRegistry.kt and RuleBasedDispatcherDeterminismTest.kt, Koin module formatting in DispatcherAgentModule.kt, backing-property naming in ShuntingLoop.kt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#742): reject physically impossible switch routes + SP0.11 green-up (#743) * Add issue 742 path merge analysis * fix(#742): reject reservation of physically impossible switch routes Trace capture from failing RuleBasedDispatcherDeterminismTest runs showed the remaining ~40% failures were NOT the suspected mergePathInfo fork-overlap corruption (all 250 observed merges were clean continuations). The real defect: when a train's through-exit was momentarily blocked, findNextReservationTarget offered the sibling parallel branch's semaphore (e.g. doB1→doB2), reservePath reserved it, and configureSwitchesInPath swallowed the FATAL PathSeparatorChangeException ("switch doesn't join this segments") — returning Success for a route no configuration of switch vB can join. The train committed to an untraversable route, isPathExtendedBeyond then suppressed the corrective decision forever, and the network gridlocked (trainsExited 1 instead of 5). Fix (railway-conservative, service layer only): - configureSwitchesInPath returns Boolean: a genuinely traversed switch (both segments known) that no configuration joins now fails the candidate; null-segment cases keep the historical lenient skip (Issue #300) - configureAndRegisterSwitches orders configuration BEFORE registerSwitches - rollbackUnconfigurableCandidate undoes only the candidate's blocks and newly locked switches — the train's pre-existing path state survives, so it simply waits and reserves the through route at a future simulation time - registerPathInfo moved AFTER switch+signal configuration so no rollback can leave a poisoned PathInfo Circular-route merging is untouched: mergePathInfo/addElementWithCycleDetection unchanged; Issue316RegressionTest and PathReservationRegistryMergingTest pass unchanged. New Issue742RegressionTest (@timeout): impossible diversion rejected without leaking blocks/locks/PathInfo (failed pre-fix), wait-then-extend partial-path semantics, legitimate parallel alternate zB→doA2 still succeeds. Determinism evidence on the dev machine: pre-fix 55/100 failed repetitions (0/10 suites green); post-fix 8/210 failed repetitions, all matching the pre-existing headless driver-pacing race (trainsExited=0 occurred 22×/100 pre-fix too) — that residue is AgentLoopDriver pacing, tracked separately. Closes #742 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(SP0.11): green-up clean build integrationTest — KMP break, dispatcher wiring, pacing tests Four SP0.11 (thin ShuntingLoop shell, #733) regressions fixed; none were covered by CI because this branch's workflow run was never approved (goal-10's last green CI is SP0.10). 1. KMP metadata compile break: ShuntingLoop.startAction used JVM-only Thread/runBlocking in commonMain. New expect/actual platformStartDaemonThread (util/PlatformThread.kt): JVM = daemon Thread + runBlocking; native (linuxX64) = CoroutineScope(newSingleThreadContext). Local gemma4 review suggested kotlin.concurrent.thread(isDaemon=true) — rejected: that API is JVM-only, does not exist on Kotlin/Native. 2. Zero-train runs everywhere the async :dispatcher-agent stack isn't wired: SP0.11 removed inline admission/reservation policy but only desktop-ui's ExampleRegistry got the replacement wiring. New sim/SynchronousDispatcherWiring.wireSynchronousDispatcher(env, loop) — production RuleBasedDispatcher + core ports, deciding and applying synchronously on the sim thread each iteration (pre-SP0.11 semantics; deterministic by construction, immune to the PR #740/#742 async races). Used by :fast-sim production (Main, NativeExampleRegistry — fixes 7 native integration tests + JVM/native parity) and by the bare-ShuntingLoop test suites (JvmParity, ShuntingLoopRegression, TrainReporter, Metrics, TrainCoverage, KoinGoldenOutput, SimulationSpeedGolden). Golden baselines pass unchanged — the synchronous wiring reproduces pre-SP0.11 outputs. 3. AgentLoopDriverTest pacing tests updated to the SP0.11 stagnant-snapshot contract runCycle gained (skip decide/throttle on a re-read of the same sim tick, pause still honoured): multi-cycle test now advances simTime per cycle; zero-delta test replaced by stagnantSimTimeSkipsCycle pinning the skip behaviour (no re-decide — that re-decide was the #740 race source). 4. RuleBasedDispatcherDeterminismTest flaked (~1 in 3 suite runs) because the free-running driver thread races the unthrottled headless sim — admission timing drifted with OS scheduling. The Goal 10 A3 gate asserts determinism of the DISPATCHER, so executeRun now runs a lock-step handshake: the sim thread grants the driver exactly one cycle per tick and drains its decisions in the same tick, all on production components. 5 consecutive suite runs green (50/50 repetitions). Verified: ./gradlew clean build integrationTest --no-build-cache (122 tasks, 120 executed) BUILD SUCCESSFUL — includes :core jvm+native tests, :core purity gate, :desktop-ui, :dispatcher-agent, :fast-sim linuxX64 tests, detekt, ktlint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(docker): copy dispatcher-agent build script and sources into image settings.gradle.kts includes :dispatcher-agent, but the Dockerfile's layered COPY steps never picked it up alongside core/core-test/desktop-ui. Gradle saw an included project with no build script or sources, giving it zero variants and breaking desktop-ui's runtimeClasspath resolution ("Could not resolve project :dispatcher-agent ... No variants exist."). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Bedrich Hovorka <bedrich.hovorka@gmail.com> * Delete docs/analysis/ANALYSIS-742.md * Add PR #743 new-code coverage tests for Issue #742 fix (4 new tests covering uncovered branches) * fix(SP0.11): address PR #740 review findings #2-#7 Code review of PR #740 (vs origin/goal-10) found 7 Important issues. #8 (pre-existing ~4% AgentLoopDriver pacing determinism residual) is tracked separately as SP0.11c (#746), not fixed here. #2 ShuntingLoop: consolidate three @volatile observation fields into one atomic TickObservation snapshot, so the off-kernel driver thread always reads a consistent same-tick set instead of a cross-tick mix that could trip a stale pathAlreadyExtendedBeyond and a duplicate re-decide. #3 ExampleRegistry.wireDispatcherAgent: resolve Dispatcher + ActuatorCommandQueue from context.scope via Koin (Goal 10 seam) instead of constructing RuleBasedDispatcher/ActuatorCommandQueue directly; add dispatcherAgentModule to testModuleLightweight + integrationTestModule so the resolution works in every test harness. #4/#5 KDoc: replace stale "two-phase tick" / "pre-SP0.11 timing" docs with the SP0.11 single-observation-per-tick model (DispatchObservation, Dispatcher, RuleBasedDispatcher, SynchronousDispatcherWiring, ShuntingLoop). #6 DefaultPathReservationService: signal-config failure now uses the scoped rollbackUnconfigurableCandidate instead of a full registry.unregister that would nuke a mid-journey train's pre-existing path; add a per-switch PathReservationRegistry.unregisterSwitch primitive; thread priorSwitches so earlier-hop switches survive; remove the now-dead rollbackCompleteReservation. #7 DefaultPathReservationService: unconfigurable-switch failure now `continue`s to the next candidate like the other failure modes (was `return AllPathsBlocked`). Extracted the Step 2g signal-config `when` into a configureStartSignal helper to keep reservePath under the detekt cyclomatic-complexity threshold. Tests: new unregisterSwitchReleasesOneSwitchAndKeepsTheRest unit test in Issue742RegressionTest; refreshed stale SignalConfigurationRollbackTest comments pointing at the removed rollbackCompleteReservation. Gates green: :core:checkCoreCommonMainPurity, :core:jvmTest (2187/2187), :dispatcher-agent:test, :desktop-ui:test, :desktop-ui:integrationTest (244 passed / 3 skipped / 0 failed), detekt, ktlintCheck. Co-Authored-By: Claude <noreply@anthropic.com> * docs(SP0.11): Update comment on Dispatcher binding to mention future Agentic/LLM variant
…he 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>
…/CD and SonarQube configuration (#748) * SP1.1: Add dispatcher-agent to test, integration test, and SonarQube configuration * SP1.1: Configure JaCoCo XML report generation for dispatcher-agent
…lity (~9% fast-sim win) (#754) * fix(#749): gate findNextReservationTarget on reservation-eligibility ShuntingLoop.toBlockInputObservation resolved BlockInputObservation.toSeparatorName by calling PathReservationService.findNextReservationTarget for every block input on every tick, including inputs that provably cannot take a forward reservation. The call is a BFS (findNextSemaphoresVia) plus a full topological-path enumeration per candidate (isPathAvailable -> findAllTopologicalPaths). Measured on vyhybna: 906 calls per `example shuntingLoop 300`, of which 892 (98.5%) are discarded unread by RuleBasedDispatcher.checkInput, which already returns null for FREE, not-approaching and already-extended inputs. Cost: 30.3 ms of 342 ms wall (~9%). Gate the search on eligibility. The three facts it needs (isApproachingThisInput, pathSetUpTowardThisInput, pathAlreadyExtendedBeyond) are already computed in the same function, so the gate is free. This narrows the sensor-port contract: toSeparatorName is now populated only where a forward reservation is possible, and is null elsewhere without the search having run. RuleBasedDispatcher behaviour is unchanged by construction. Documented on BlockInputObservation so a future LLM dispatcher does not read null as "track ahead is occupied". Native linuxX64 release, example shuntingLoop 300, median of 7: before 341 ms after 318 ms Golden output byte-for-byte identical (90/90 event lines, 5 trains, 211.1 s sim time). ShuntingLoopReservationTargetLazinessTest locks the contract and fails 2/2 without the gate (a FREE input resolves k1->doA1=A). Gates: core:jvmTest 2194, core:integrationTest 725, core:heavyTest 1001, dispatcher-agent:test 51, desktop-ui:test 650, desktop-ui:integrationTest 247 — all green. purity/detekt/ktlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Extract reservation-target helper --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
… JMH `:core:integrationTest` failed on run 29165254178 (725 tests, 1 failed). The failure was DefaultSimulationContextControllerTest.controlledLoopOverheadIsNegligible, a wall-clock microbenchmark living inside the CI correctness gate: it timed one 300s ShuntingLoop run per arm and asserted the ratio was < 5%. It measured 5.33%. It could not have held up. One sample per arm, no JIT warmup (the baseline arm ran first and absorbed it, biasing the ratio negative, so it passed for a reason unrelated to the property it claimed to test), and a 5% threshold on a shared runner whose CPU-steal variance far exceeds 5%. Note TwoTrainConcurrencyTest passed 150/150 in that run and is not implicated; the simulation is a single-threaded kDisco discrete-event loop with a seeded RNG, so there is no race here to serialize. - Delete the wall-clock assertion. The class keeps its four deterministic tests (throttle invoked per event, non-negative deltas, StopSimulation propagates, awaitIfPaused called), which is what a correctness gate should assert. - Add ControlledLoopOverheadBenchmark to the existing :desktop-ui JMH harness, with two independently reported arms instead of one fragile ratio. Only ctx.run(controller) is timed; context construction sits in @setup(Level.Invocation). Mode is SingleShotTime: the payload is a single-use, millisecond-scale run, and under AverageTime the resulting context churn collides Koin scope ids, since DefaultSimulationContext derives its scope id from a (non-unique) identity hash code. Measured: baseline 0.699 +/- 0.103 ms/op, with-controller 0.685 +/- 0.062 ms/op. Indistinguishable -- the hook overhead is below the noise floor, so the original claim was true; only the instrument was broken. - Fix a latent order-dependent bug in TwoTrainConcurrencyTest found en route. It matched train names by substring, and Train.countValue is a process-global counter that is never reset, so once names reach two digits "Train #1" matches "Train #12". Now compares extracted whole tokens and indexes by identity rather than List.indexOf. Verified: :core:integrationTest 724/724 (was 725/1 failed), :core:jvmTest 2194/2194, :desktop-ui:test + integrationTest green, detekt + ktlintCheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix SonarQube double-indexing: move dispatcher-agent sonar config to subproject
The SonarQube Gradle plugin v6+ auto-detects JVM subproject source sets.
Having dispatcher-agent paths in BOTH the root sonar {} block AND the
subproject's auto-detected sources caused:
"File ... can't be indexed twice" (Issue #762)
Fix:
- Remove dispatcher-agent/src/{main,test}/kotlin from root sonar.sources/
sonar.tests and java binary paths in build.gradle.kts
- Add explicit sonar {} block in dispatcher-agent/build.gradle.kts that
claims src/main/kotlin and src/test/kotlin for this module
- Update sonar-project.properties to include dispatcher-agent paths for
consistency with CLI sonar-scanner runs
… 10 dispatcher runtime (#755) * SP1.2: Add Koog dependency + AIAgent/AgentService skeleton (Issue #547) * SP1.2: Add Koog dependency + AIAgent/AgentService skeleton - Fix ktlint formatting * SP1.2: Update Koog to 1.0.0 and fix test model names to use tool-capable models - Upgrade Koog from 0.3.0 to 1.0.0 (first stable release with tool calling) - Fix test model selection: reject llama2 (no tool support), use verified tool-capable models - Convert test to @ParametrizedTest with qwen2.5:7b-instruct, llama3.1:8b, gemma3:4b per GOAL_10_SP3_1_LLM_MODEL_EVALUATION.md - Update SP1_2_AGENT_SKELETON.md with Koog 1.0.0 info and model selection rationale Addresses reviewer feedback: - comment 3564919391: Check latest Koog version (now 1.0.0) - comment 3564922994: Check chosen model name in docs (qwen2.5:7b-instruct) - comment 3564923716: Use @ParametrizedTest for better coverage - comment 3564924505: llama2 cannot use tools; fixed * SP1.2: Apply review fixes to Koog agent skeleton (Issue #547, PR #755) Address code-review findings on the SP1.2 skeleton: - DomainTool: add Koog-agnostic parameter schema (DomainToolParameter + DomainToolParameterType) so SP1.6 can build a real Koog ToolDescriptor without retrofitting; keeps the domain seam free of ai.koog.* imports. - AgentService KDoc: use tool-capable model examples (qwen2.5:7b-instruct, llama3.1:8b, gemma3:4b) and reject bare "mistral"/"llama2" (no native tool-calling). SP1_2_AGENT_SKELETON.md wording corrected with the mistral vs mistral-nemo distinction. - Add Koin -> createDispatchAgent integration test (closes the DI loop). - KoogDispatchAgentImplTest: use a tool-capable model. - Fix stale @SInCE tags on SP1.2 skeleton symbols (skeleton in #547, full implementation in #551). Verified: :dispatcher-agent:test, ktlintCheck, detekt all green.
Assembles the Koog+Ollama runtime as Koin definitions in DispatcherAgentModule and finishes it with a real local-Ollama proof-of-connection check, closing out Issue #548 (SP1.3, Goal 10 sub-issue of #536). Koin bindings (all in dispatcher-agent's DispatcherAgentModule, included via interlockSimModule's single startKoin() call): - OllamaExecutorConfig: singleton, endpoint/model/sampling config for the local Ollama executor (default model qwen2.5:7b-instruct per GOAL_10_SP3_1_LLM_MODEL_EVALUATION.md); validateToolCapableModel() rejects bare pre-function-calling tags (mistral, llama2). - ToolGroupRegistry: singleton, perception/actuator tool assembly seam (assemble*Tools() intentionally return empty lists here — populated in SP1.4/#549, not this issue). - KoogAgentFactory: scoped per DefaultSimulationContext, builds a KoogDispatchAgent from the registry/config/AgentService singletons. createAgent() stays a plain suspend method rather than a Koin scoped<KoogDispatchAgent> binding because Koin's DSL isn't a suspend context; callers (AgentLoopDriver, SP1.4+) invoke it directly. Proof-of-connection (this session's finalization — the skeleton above was built by a remote Copilot agent with no Ollama access, so its one ollama-tagged test never touched the network): - OllamaConnectivityChecker: evaluate() is a network-free decision function (model missing / present-without-tool-support / available) with its own always-on unit tests, independent of Ollama availability; check() is the live suspend call through Koog's own OllamaClient (not raw HTTP), passing pullIfMissing = false so a connectivity check can never trigger a multi-GB background model download as a side effect. - OllamaExecutorConfigTest's ollama-test-tagged test now asserts a real Available result instead of just constructing a config object. - dispatcher-agent's integrationTest Gradle task probes localhost:11434 live when it runs and decides tag inclusion itself (no manual flag): unreachable Ollama logs a WARN and excludes ollama-test in CI (CI stays intentionally Ollama-free — live LLM-output benchmarking is SP3.5/SP2b.9's job), but fails the build outright on a local run, since integrationTest must not silently skip its proof-of-connection coverage on a dev machine. - docker-compose.yml gains an ollama service (alternative to a native install, same port 11434) that pulls qwen2.5:7b-instruct on first start only if not already present in its volume. - docs/KOTLIN_STYLE_GUIDE.md documents the native-vs-docker-compose workflow and the CI/local behavior asymmetry. Out of scope (left exactly as this skeleton leaves it, deferred to SP1.4+): NetworkPerceptionPort/NetworkActuatorPort tool implementations, populating ToolGroupRegistry's tool lists, a scoped<KoogDispatchAgent> Koin binding, and wiring KoogAgentFactory into AgentLoopDriver. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…765) * feat(#571): SP3.3 — inter-agent message protocol (sealed Message + 8 speech acts) Also implements SP3.2 vocab package (interlocksim.lang.vocab) since it was only documented but not coded before. Changes: - settings.gradle.kts: add kotlin("plugin.serialization") to pluginManagement - dispatcher-agent/build.gradle.kts: add serialization plugin + explicit kotlinx-serialization-core/json dependencies - gradle.properties: add serializationVersion=1.10.0 - interlocksim.lang.vocab (SP3.2): - SwitchPosition.kt — PLUS/MINUS enum with @LLMDescription - Aspect.kt — sealed interface with 8 Czech speed-signalling aspects - Identifiers.kt — SignalId, SwitchId, BlockId, TrackId @JvmInline value classes - SwitchSetting.kt — (switch, position) data class - TrainRoute.kt — vlaková cesta from/to signal with blocks and flank protection - MovementAuthority.kt — oprávnění k jízdě (ETCS MA concept) - interlocksim.lang.proto (SP3.3): - AgentRef.kt — sender/receiver identity (role + id) - Message.kt — sealed interface with common envelope fields (messageId, sender, receiver, simTime, trainNumber), humanReadable(), and 8 speech acts: RouteRequest, RouteGrant, RouteDenial, MovementAuthority, PositionReport, OccupancyReport, HoldOrder, ConflictNotification - Tests: - AspectTest: serialisation round-trips + stable @SerialName + exhaustive when - VocabTypesTest: SwitchPosition, identifiers, SwitchSetting, TrainRoute, MA - MessageTest: round-trips + humanReadable for all 8 speech acts All 49 new tests pass. Existing dispatcher-agent tests unaffected. Closes #571. * fix(#571): address code review — add Aspect.humanLabel() and fix RouteGrant.humanReadable() - Aspect.kt: add humanLabel() method returning proper Czech text for each aspect (e.g. "Stůj", "Volno", "Rychlost 40 km/h", "Přivolávací návěst") - Message.kt: use aspect.humanLabel() in RouteGrant.humanReadable() instead of implicit toString() which would produce non-Czech class names - AspectTest: add humanLabelReturnsProperCzechText() covering all 8 aspects - MessageTest: rename humanReadableContainsTrainAndVolno → humanReadableContainsTrainAndAspect and add humanReadableContainsVolnoAspect to properly verify aspect text in output * docs: clarify interstation Dispatcher↔Dispatcher communication capability in ConflictNotification * fix(#571): address code review — move vocab to :core, add Signal↔Aspect premapping Address all 6 Important issues, Minors M-1/2/3/4/6/7, and the 4 recommendations from the PR #765 code review, plus add a bidirectional Signal↔Aspect premapping. Module split (I-4 / Rec#3): move the shared domain contract `lang.vocab` (Aspect, identifiers, TrainRoute, MovementAuthority, switch types) from :dispatcher-agent to :core commonMain so the linuxX64 native target (:fast-sim) can reach the operating vocabulary; :core carries no Koog dependency, so @LLMDescription is dropped from vocab. The wire-format `lang.proto` (Message + 8 speech acts, AgentRef) stays in :dispatcher-agent and keeps @LLMDescription. Repackage both `interlocksim.lang.*` → `cz.vutbr.fit.interlockSim.lang.*` to match the core convention. Speech-act / envelope fixes: - I-1: MovementAuthority.humanReadable() uses genitive "vlaku" (SŽ D1). - I-2: null trainNumber renders as "(neznámý vlak)" via a shared trainLabel() helper instead of the literal "null". - I-6: ConflictNotification rejects an empty competing list at construction. - M-3: simTime documented as kDisco ticks (Long) vs core's Double seconds. - M-6: AgentRole typed enum (DISPATCHER/TRAIN/INTERLOCKING) with lowercase @SerialName discriminators; KDoc notes DISPATCHER collapses dispečer + výpravčí (DOZ paradigm) for single-station Stage A. Vocab validation (M-4): require(kmh > 0) on Rychlost/Ocekavejte; require(speedLimitKmh >= 0) on MovementAuthority. M-1: S30 row in the Aspect correspondence table. Shared serialisation (I-5 / Rec#2): LangSerialization.json with classDiscriminator="type", ignoreUnknownKeys (forward-compat), encodeDefaults. Signal↔Aspect premapping: Signal.toAspect() (total, exhaustive — a new Signal value is a compile error) and Aspect.toSignal() (partial — null for distant, calling-on, shunting, and unmodelled-speed aspects that have no Signal equivalent). Tests: - Move vocab tests to :core jvmTest; add M-2 SerialName tests (vystraha, posun_dovolen, posun_zakazan) and M-7 empty-running route round-trip. - Add AspectMappingTest covering both directions and round-trip. - Extend MessageTest with a SchemaStability pack (Rec#1) pinning all 8 @SerialName discriminators, the "type" class discriminator, AgentRole wire format, and I-2 null-trainNumber coverage. Czech domain review (Rec#4): railway-civil-engineer sign-off APPROVED-WITH-NITS against SŽ D1/D2 and AŽD ESA 11; applied the two KDoc nits (indikace kolejového obvodu; D1 čl. 2958 citation).
…Koin DI (#766) * SP1.4: Bind SP0 sensor/actuator ports to simulation-backed impls via Koin DI * Fix SP1.4 test: use mockk to create mock ports for ToolGroupRegistry test * SP1.4 (#549): address PR #766 code-review fixes - Add DispatcherAgentPortBindingTest: resolve NetworkPerceptionPort, NetworkActuatorPort, and KoogAgentFactory from a real DefaultSimulationContext Koin scope (positive), and assert the getSource null-guard throws IllegalStateException when no context source is present (negative). The SP1.4 scoped bindings were previously unverified — DispatcherAgentModuleSp13Test only exercised the singleton bindings and never created a context scope. - Replace the tautological isInstanceOf(List::class) assertions in DispatcherAgentModuleSp13Test with isEmpty(); the assemble* methods return emptyList() today, so isInstanceOf(List::class) could never fail. - Track the deferred activeTrains supplier as SP1.4b #768 (filed and registered as a sub-issue of #536). The prior "// TODO: SP1.5" tag was stale — SP1.5 #550 is the Ollama executor, not this supplier.
…/MultiTrainLoop) (#769) * SP1.4b: Bind activeTrains supplier to main process type (ShuntingLoop/MultiTrainLoop) (#768) * SP1.4b follow-up: replace reflective activeTrains lookup with ApprovesTrains interface PR #769 review found the reflective getApprovedTrains() lookup untested on its success path, silently swallowing failures, paying per-call reflection cost on the perception hot path, and duplicated verbatim across two Koin modules. ShuntingLoop and MultiTrainLoop now implement a small ApprovesTrains interface; both modules share one mainProcessActiveTrains() helper via a compile-time-safe cast, with a debug log when the main process doesn't approve trains. Adds test coverage proving the Koin-resolved port reports real trains during a live run, plus the no-main-process and non-approving-main-process empty-list cases.
* feat(SP2a.1): Add train perception layer for reactive train agent (#552) * fix(SP2a.1): Fix import order and unsafe !! in Train.kt currentSpeedLimitMps * fix(SP2a.1): Update DispatcherAgentModuleSp13Test for 13-tool registry toolGroupRegistryAssemblesFullToolSetViaKoinSingleton() still asserted the pre-SP2a.1 counts (12 total / 8 perception tools), so CI failed once TrainPerceptionTool became the 9th perception tool. Update the assertions and the stale "12 tools" comment in ToolArgs.kt to match. * fix(#552): update stale SP4.1 regression-guard tool count after rebase SP4.1's `assembleAllTools still returns exactly N tools after SP4 additions` regression guard hardcoded the pre-SP2a.1 baseline of 12. SP2a.1 legitimately adds a 13th tool (train_perception), already reflected in the two sibling "total tools" tests updated by this branch's own commit, but this guard test didn't exist on the branch before the rebase onto origin/goal-10 picked it up. * feat(SP2a.1): expose up to 2 signal aspects ahead in train perception (#552) Address PR #777 review findings (C1, I1, I2, I3, M1-M3, Sonar CPD). C1 — second signal ahead (aspect/name only, no distance): - TrainNavigationService.reservedSeparatorsAhead(trainId, separator, limit): read-only index-walk of PathInfo.reservedPath returning the oriented separators after the anchor (no PathReservationService interface change). - Train.secondSemaphoreAhead(firstSep) + nextSignalAheadName/nextSignalAheadAspect properties; TrainPerceptionReading gains the two fields (defaulted null so all existing call sites are unchanged). I1 — Výstraha/předvěst is ENCODED by the (immediate, second) aspect pair, not a new enum value (per SŽ D1 a předvěst encodes the next main signal's aspect). Full driver-intent mapping documented in TrainPerceptionReading KDoc; SP2b-scope gaps (signal type distinction, sight/braking-distance context, LS) recorded there too. I2 — Train→TrainPerceptionReading derivation tests (11 cases) in DefaultNetworkPerceptionPortTest + ReservedSeparatorsAheadTests in TrainNavigationServiceTest (ordered walk, limit 0, no PathInfo, terminal anchor). I3 — DynamicInOut.outSemaphore non-null invariant documented on signalAheadAspect and locked by the DynamicInOut-destination test. M1 — single nextSemaphore() call per perception capture: shared internal separatorName/separatorAspect mapping in sim/PerceptionMapping.kt; the port reads nextSemaphore() once and derives the second separator from it. M2/M3 — KDoc rewording of signalAheadName / currentSpeedLimitMps. Sonar CPD — de-dup the trainId DomainTool skeleton shared by 4 tools: TRAIN_ID_PARAM / TRAIN_ID_REQUIRED_MSG / trainIdParameter(description) in ToolArgs.kt. Verified: clean build + integrationTest green; detekt/ktlint/purity clean. Targeted tests pass (131 core, dispatcher-agent tool suite).
…NetworkActuatorPort (#779) * SP3.5: wire InterlockingFacade as single chokepoint in DefaultNetworkActuatorPort * SP3.5 follow-up: fix blocksCount undercount in requestRouteByEndpoints, add direct test coverage DefaultInterlockingFacade.requestRouteByEndpoints used mapNotNull{it.name} to build the granted TrainRoute's block list, silently dropping any reserved block that lacks a name. This undercounted RouteRequestResult.Reserved.blocksCount (the value DefaultNetworkActuatorPort reports to the dispatcher/tool caller) whenever a reserved path included an unnamed block. Now every reserved block is represented (falling back to a positional "unnamed-N" id), so the reported count always matches the number of blocks actually locked. Also adds direct unit coverage for requestRouteByEndpoints itself (previously only exercised indirectly through a mocked facade in DefaultNetworkActuatorPortTest): unknown-endpoint denial, conflict denial, and the unnamed-block regression case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP3.5 follow-up: English-only logs/reasons in requestRouteByEndpoints + close SonarQube coverage gap Address PR #779 review comments (Czech logs + 74.2% new-code coverage < 80% gate). DefaultInterlockingFacade.requestRouteByEndpoints emitted Czech log messages and RouteResponse.Denied reason strings ("Neznámý bod trasy", "Trasa neexistuje", "všechny cesty obsazeny", "konflikt na úseku", "Úsek ... obsazen vlakem"). Mixed- language logs make runtime diagnostics and external review unreadable. Translated all runtime log lines and denial reasons in this method to English; the existing requestRouteByEndpoints tests assert only language-neutral name fragments ("NOPE", "T-other"), so no assertion changes were needed. Pre-existing Czech strings in the SP3.4 requestRoute method are out of this PR's scope and left for a follow-up. Added three missing-branch unit tests for requestRouteByEndpoints (unknown toEndpointName, NoPathExists, AllPathsBlocked) so every `when` branch plus both endpoint-resolution denials are now directly covered — closes the SonarQube new-code coverage gap (was 74.2%, gate ≥80%). CLAUDE.md gains a top-level "Language: English Only (Critical)" section codifying the rule for all runtime logs, output, denial reasons, comments, KDoc, PRs, and review, with Czech permitted only as inline untranslatable railway technical terms, enforced at code review.
…LLM) (#780) * SP3.6: initial plan — create planner package directories * SP3.6: add DispatcherPlanner interface, PlannerCapabilities, RuleBasedPlanAdapter; update AgentLoopDriver and tests * SP3.6: fix compile errors in pluggable planner interface (verify/fail imports, ExampleRegistry wiring) - AgentLoopDriverTest.kt: add missing io.mockk.verify import for the non-suspend perceptionPort.snapshot() verification (converting plan() calls to coEvery/coVerify left one plain verify() call without its import) - DispatcherPlannerTest.kt: add missing assertk.fail import used by the custom Assert<Double>.isLessThan() helper (matches the existing CellTest.kt/ArrayPathTest.kt convention in this codebase) - ExampleRegistry.kt: AgentLoopDriver's constructor param was renamed from dispatcher: Dispatcher to planner: DispatcherPlanner but this call site was never updated; resolve DispatcherPlanner directly from the Koin scope (already bound as a singleton in DispatcherAgentModule) instead of reintroducing a duplicate Dispatcher lookup + manual RuleBasedPlanAdapter wrap, keeping the Goal 10 planner-swap seam in one place # Conflicts: # desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt * SP3.6: enforce Issue #187 planner pacing guard; rename DI test class (#780 review) Address the two Important code-review findings on PR #780: - Add assertPlannerPacingCompatible(planner, controller) in the planner package and call it from ExampleRegistry.wireDispatcherAgent. An async planner bound to NoOpSimulationController now fails fast at startup instead of silently violating the 2x real-time cap (Issue #187). Actual SimulationRunner speed-capping remains deferred to SP1.4 (#549). - Rewrite the "Speed constraint" KDoc on DispatcherPlanner and PlannerCapabilities to describe the deferred enforcement instead of claiming the wiring layer MUST cap speed (no production code read maxSpeedMultiplier). - Add AssertPlannerPacingCompatibleTests covering the async/sync x NoOp/pacing-controller matrix. - Rename DispatcherAgentModuleSp13Test -> DispatcherAgentModuleTest (now spans SP1.3 / SP1.4 / SP3.6 bindings) and update the KDoc reference in DispatcherAgentPortBindingTest.
…/brake) (#782) * SP2a.2: add ReactiveTrainDecider acceleration-target decision + tests * SP2a.2: fix Výstraha KDoc framing + add edge-case tests (#782 review) Correct the Výstraha bullet in ReactiveTrainDecider's class KDoc: - State that the 2-signal Výstraha case *extends* the kernel's one-signal Motor.onWarning "start on caution" brake (enabled by the SP2a.1 perception aspect pair), rather than "mirrors" it — the kernel has no 2-signal-lookahead Výstraha case. - Fix the inverted fail-safe justification: the second signal is at least as far as the immediate (d_second >= d_immediate), so sqrt(2·a·d_immediate) is a conservative lower bound on the true sqrt(2·a·d_second); the train must stop at the second signal, not the immediate (which is allowing). Add four commonTest edge cases for ReactiveTrainDecider: - zero / negative distance-to-immediate-signal (exercises the brakingSpeedLimit guard branch). - speed-restricted Výstraha (immediate S30, next STOP) — the "očekávej rychlost X + Výstraha" case, distinct from the existing FREE+STOP tests. - velocity below the braking cap re-accelerates to it (not stuck in BRAKE).
* Add CandidatePathRuleEngine (SP2b.2 rule engine) + tests * Cache comparator in CandidatePathRuleEngine (review nit) * Fix RuleBasedDispatcher KDoc: CandidatePathRuleEngine consumes, not enumerates, candidates The SP2b.2 KDoc claimed the engine "enumerates candidates via Goal 2 pathfinding"; the engine is stateless and only consumes PathCandidates produced by TopologyNavigator.findCandidatePaths (its own KDoc states this correctly). Reword to "consumes ... optionally attaches a conflict-risk weight". KDoc-only, no behaviour change. Review follow-up to PR #783 (Minor #1); #2/#3 tracked in #787.
* Add canvas-side TrainHeadingResolver suppressing spurious 180° nose flips (#719) * Split over-length line in TrainHeadingResolver snap function
…#784) * SP2b.4: Add DispatcherMode AUTO/SEMI_AUTO/MANUAL with persistent override Implements Issue #559 (SP2b.4 — Goal 10): - New enum `DispatcherMode` (AUTO / SEMI_AUTO / MANUAL) in :core/sim/, with `DEFAULT = AUTO`. - New `DispatcherModeState` holder in :core/sim/ tracking a default mode plus an optional human override that persists until explicitly cleared (as required by #559). Thread-safe via SynchronizedObject, matching the existing atomicfu pattern used by DefaultDispatcherPreferenceStore. - Wired `DispatchDecisionApplier` (dispatcher-agent) to consult `DispatcherModeState` (optional, backward-compatible) when draining decisions: * AUTO → apply directly (pre-SP2b.4 behaviour). * SEMI_AUTO → forward to a `semiAutoApprover: (DispatchDecision) -> Boolean` callback; apply only if it returns true. If no approver is wired, drop with a warning. NoAction bypasses the gate. * MANUAL → drop every actuating decision; NoAction still no-ops. - Unit tests for DispatcherMode/DispatcherModeState (override persistence, clearing, custom defaults, independence between instances). - Unit tests for DispatchDecisionApplier mode gating (AUTO/MANUAL/SEMI_AUTO behaviour, approver callback, mode transitions, backward compatibility). The Issue #187 slow-speed cap for async agents is already enforced by `PlannerCapabilities.AGENT_MAX_SPEED_MULTIPLIER = 2.0`; the DispatcherMode KDoc cross-references it. No wiring changes needed for that cap in SP2b.4. Closes #559 * style: fix ktlint statement-wrapping violation in DispatchDecisionApplierModeGatingTest * SP2b.4: Address PR #784 review findings (concurrency test + NoAction symmetry) Add the concurrency test promised in DispatcherModeStateTest's KDoc (bullet 7): 16 Dispatchers.Default writers repeatedly setOverride SEMI_AUTO/MANUAL, then assert hasOverride and the effective mode is one the writers wrote (a lost/corrupted override would fall back to AUTO and fail). Runs green on both JVM and linuxX64 native. Make NoAction-bypasses-mode-gate symmetry explicit across all three modes with a new auto_allowsNoAction test mirroring the MANUAL/SEMI_AUTO cases. Remove a trivially-true isNotSameAs assertion from the instance-independence test, and align the DispatchDecisionApplier modeState KDoc table wording with the DispatcherMode.MANUAL enum KDoc ("passes through as a no-op").
…owns port 11434 (#771) * docker: skip starting the ollama container if a local Ollama already owns port 11434 Adds a one-shot ollama-port-check service that probes localhost:11434 via nc -z (network_mode: host). Its exit code gates the ollama service via depends_on: condition: service_completed_successfully, so `docker compose up -d ollama` no-ops when a local Ollama (native or another container) is already up, instead of failing on the port publish step. Known limitation on Windows/macOS Docker Desktop: network_mode: host shares the Docker Desktop VM's network namespace, not the real host, so a native ollama serve process there is invisible to the check. Tracked for follow-up verification in #770. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * dispatcher-agent: make Ollama endpoint configurable via OLLAMA_BASE_URL (#770) OllamaExecutorConfig.default() now reads OLLAMA_BASE_URL (falling back to the existing http://localhost:11434 default), so Stage B wiring of dispatcher-agent into the app container isn't blocked by a hardcoded endpoint. The env var is read through an injectable map parameter so the override/fallback behavior is unit-testable without mutating real process environment variables. Verified empirically on Windows 11 + Docker Desktop 29.5.3 (WSL2 backend): a network_mode: host container cannot reach a native Ollama on localhost:11434 (confirms the existing ollama-port-check blind spot), but host.docker.internal:11434 IS reachable even under network_mode: host -- matching the app service's actual config. Documented both findings in docker-compose.yml and docs/SP1_5_OLLAMA_EXECUTOR_SETUP.md, and closed out the "environment variable override" item that doc had listed as planned for SP1.6+.
…via SimulationRunner (#781) * SP4.2: close agent loop — sense via DispatchLoopSensorPort, pace via SimulationRunner delegate * style: fix ktlint multiline-expression-wrapping violation in AgentLoopDriver * fix: guard DelegatingSimulationController lookup against ClassCastException SimulationController.start() unconditionally resolves the optional pacing seam via Koin's reified getOrNull<T>(). Because that call is inline, it executes against whatever Scope instance the caller holds, including a relaxed-mocked Scope in tests with no real Koin registry behind it — which returns a plain Object that fails the compiler-generated cast to DelegatingSimulationController. Treat that the same as "no dispatcher agent wired", which is already a documented, legitimate outcome of this lookup. * fix: address PR #781 review findings (lifecycle tests, observability, KDoc, Sonar) - Add SimulationControllerAgentPacingLifecycleTest (real Koin) covering delegate attach on start, detach to NoOp on stop and natural completion, and no-leak across stop+start on a fresh context. - Log a warning when the DelegatingSimulationController Koin lookup throws ClassCastException, so a genuine misbinding is observable instead of silently leaving the agent loop unpaced. - Refresh PlannerCapabilities KDoc: binary reject (NoOp) vs speed-clamp status after SP4.2; correct DelegatingSimulationController throttle and awaitIfPaused KDoc (driver exits via isSimActive(), not the interrupt; Thread.sleep is intentional for the dedicated daemon thread). - @volatile on the cross-thread throttleThrowsInterrupted test flag. - Suppress Sonar S6307 and S6514 on DelegatingSimulationController with justification in the class KDoc (mutable @volatile delegate + deliberate non-forwarding overrides make `by` delegation unsound; suspend-forced blocking on a dedicated thread makes delay() semantically wrong).
… signal commands (#794) * SP2b.3: Add PathCommandTranslator to generate switch+signal commands (#558) * Fix PathCommandTranslatorTest: assert signal present in emptySectionsEmptyCommands; use isLessThan for ordering assertion * Fix ktlint trailing-comma violations in PathCommandTranslator CI's ktlint check failed with 4 standard:trailing-comma-on-declaration-site/ standard:trailing-comma-on-call-site violations in PathCommandTranslator.kt (unnecessary trailing commas before closing parens in the translate() signature and three DispatchDecision constructor calls). Ran ktlintFormat to auto-fix; it also normalized formatting in PathCommandTranslatorTest.kt (import order, comment spacing, multi-line parameter/call formatting) under the same rule set. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Add missing assertk imports to PathCommandTranslatorTest commit b46b651 changed three assertions to isGreaterThanOrEqualTo(), isLessThan(), and hasSize() without adding the corresponding assertk imports, leaving PathCommandTranslatorTest.kt unable to compile (:core:compileTestKotlinJvm failed with "Unresolved reference" for all three). This predates the ktlint fix in the previous commit — it was already broken on this branch. Add the three missing imports. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix PathCommandTranslator not emitting switch commands (missing toDynamic conversion) Root cause (confirmed via runtime instrumentation, no speculation): candidate.sections comes from DefaultTopologyNavigator, which per its own KDoc operates on "static topology only... no Dynamic wrappers." section.getSecondEnd() (AbstractTrack.getSecondEnd) therefore always returns a static PathSeparator, never a DynamicRailSwitch — confirmed by temporarily adding a DEBUG logger and observing zero log lines from either branch of translate()'s switch-handling if/else (the "no conf for switch" warn and "segments undeterminable" debug lines never fired), proving the `nextSeparator is DynamicRailSwitch` check at that line was never true, so the whole switch-command branch was dead code for every real candidate path. This is why 8 of PathCommandTranslatorTest's 10 tests failed with NoSuchElementException in findCandidateWithConf: translate() never returned a SetSwitchPosition for switch vA on any zA→doA1/doA2 candidate, so `.first { ... }` found no match. The test's expectations (MAIN for zA→doA1, BRANCH for zA→doA2) are correct per vyhybna.xml's switch confs — the bug was in production code, not the test. PathInfoBuilder.buildFullPath already solves the same problem correctly: it converts getSecondEnd()'s static result via context.toDynamic() before using it further. Apply the same one-line fix in PathCommandTranslator.translate() so context.getSegment() and the DynamicRailSwitch check operate on a properly Dynamic-wrapped separator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address PR #794 review: clarify start-switch handling, close coverage gap Code review of SP2b.3 raised two Important issues plus the SonarQube quality-gate failure (63.2% coverage on new code, required >= 80%). Start-switch handling (production, no behavior change): - PathCommandTranslator never emits a SetSwitchPosition for the start separator, but its KDoc claimed "the same traversal as countSwitchMovements" — which counts the start, while configureSwitchesInPath configures it. Skipping the start is intentional: this is the dispatcher's permissive request and it assumes the train already occupies start, so aligning a switch the train sits on is the interlocking's responsibility. Document that explicitly and correct the traversal claim. - Add a debug log when start resolves to a RailSwitch, so the skip is visible at runtime instead of silent. Test coverage (63.2% -> 97.3% combined; 100% line, 92.3% branch): - Static RailSemaphore start — covers the assertNodeCell fallback branch. - Terminal separator is a switch — covers the nextSection == null skip. - DynamicRailSwitch start — pins the start-switch behavior above. - zA -> zB upper route — asserts two switch commands precede the signal, exercising the ESA-11 C2 ordering guarantee with more than one switch. - Mocked SimulationContext cases for conf == null, from == null, to == null and both-null: the real getSegment throws rather than returning null, and no real vyhybna route joins an impossible segment pair, so these defensive branches are unreachable via the fixture. Enable DEBUG for the translator's logger in logback-test.xml. The debug lambda bodies never executed under the WARN root level, which was the actual cause of the missing line coverage; evaluating them also means a broken string interpolation now fails the build instead of hiding behind a disabled log level. Also fix a stale class-level KDoc that contradicted the emptySectionsEmptyCommands assertion, and document that findCandidateWithConf disambiguates by translator output property. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* SP2a.3: add ReactiveTrainActuator (act step) + holdAtStation to TrainActuatorPort + Train - TrainActuatorPort: add holdAtStation(dwellDurationSeconds) for station dwell - DefaultTrainActuatorPort: implement holdAtStation → Train.holdAtStation - Train: add holdAtStation + inner StationDwellProcess (fire-and-forget kDisco process) - ReactiveTrainActuator: new object — SP2a.3 act step (applyDecision + holdAtStation) - ReactiveTrainActuatorTest: commonTest coverage for all act-step paths - DefaultTrainActuatorPortTest: add holdAtStation coverage - ActuatorPortsTest: update stubs to implement new holdAtStation method Closes #554 * Rename ReactiveTrainActuatorTest names containing parentheses (illegal on Kotlin/Native) Kotlin/Native mangles backtick-quoted @test display names into symbol names and rejects literal `()` characters, causing ":core:compileTestKotlinLinuxX64" (CI's "Run linuxX64 native tests" step) to fail to compile on this branch. The JVM target has no such restriction, which is why only the native step failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP2a.3: enforce stopped-train precondition for holdAtStation + cover the dwell Code review of PR #792 found that Train.holdAtStation did not do what its KDoc claimed. motor.cancelAccelerating() only clears the accelerate flag; the Motor's iteration() zeroes acceleration.state but never velocity.state, so a moving train kept coasting for the whole dwell while the KDoc promised it stopped "immediately at its current position". Rather than braking inside the act step (which would break the fire-and-forget contract SP2a.3 is built on), holdAtStation now requires the train to already be at rest, mirroring reverseDirection. It is a dwell timer, not a brake: the agent brakes via setTargetSpeed(0.0) and commands the dwell once the train has stopped. A second guard rejects an overlapping dwell so a duplicate StationDwellProcess cannot be spawned. cancelAccelerating() is kept as a defensive no-op so a pending acceleration ramp cannot resume mid-dwell. Expose the dwell as a new Train.isStationDwelling flag, set by holdAtStation and cleared by StationDwellProcess, projected into a defaulted TrainPerceptionReading.isStationDwelling. isDwelling is deliberately left alone: SP2a.1 defines it as "velocity == 0 for any reason, disambiguated via signalAheadAspect", and it stays true after a dwell expires, so it cannot signal that the minimum dwell has elapsed. The two flags are independent by design. Add TrainHoldAtStationIntegrationTest, which drives the real kDisco engine so Train.holdAtStation and StationDwellProcess.actions() actually execute — they had no real-execution coverage at all (DefaultTrainActuatorPortTest mocks Train), which is what failed the SonarQube new-code coverage gate and the sim/ package coverage rule. It asserts the dwell consumes the requested simulation time via the dwell start/end report timestamps, the flag sets and clears, and that moving, overlapping, and non-positive-duration calls are all rejected. Also rename the ReactiveTrainActuatorTest case that claimed to test a zero duration throwing while actually passing 1.0 and asserting no throw; the ≤ 0 contract lives in the port and the kernel, both now covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nPolicy Implements Issue #555 (SP2a.4 — Goal 10 reactive train agent): - TrainDirective sealed interface (core/sim): core-layer typed directives from the dispatcher to a train policy (RouteGranted, RouteDenied, HoldAt, HoldImmediately), mirroring the SP3.3 Message speech acts without depending on :dispatcher-agent or kotlinx-serialization. - TrainDecisionPolicy interface (core/sim): pluggable decide seam for train agents — decide(TrainPerceptionReading) → TrainAccelerationDecision, plus optional acceptDirective(TrainDirective). Analogous to Dispatcher for the dispatch side. - AlgorithmicTrainDecisionPolicy class (core/sim): default LS-inspired implementation. Delegates to ReactiveTrainDecider (ecological: 2-aspect předvěst/Výstraha braking, no abrupt stops before green signals). Overlays a @volatile holdActive flag activated by HoldAt/HoldImmediately directives, cleared by RouteGranted. - AlgorithmicTrainDecisionPolicyTest (commonTest): 16 pure KMP tests covering all directive types, hold/clear sequences, and invariants. Closes #555 Fix ktlint multiline-expression-wrapping violation and unresolved Volatile reference Ran ktlintFormat to fix "A multiline expression should start on a new line" in AlgorithmicTrainDecisionPolicyTest.kt (and the same rule on TrainDirective.kt's RouteDenied/HoldAt data class parameter lists). Also fixed AlgorithmicTrainDecisionPolicy.kt's `@Volatile` on holdActive, which does not resolve in KMP commonMain (bare @volatile is a JVM-only default import). Changed to `@kotlin.concurrent.Volatile` to match the fully-qualified form used everywhere else in commonMain (BaseContext, ShuntingLoop, DynamicRailSwitch, etc.). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> docs(SP2a.4): align TrainDirective/TrainDecisionPolicy KDoc with shipped behavior Address PR #793 review findings (KDoc-vs-implementation honesty): - TrainDirective.HoldAt: document that the default policy applies the hold unconditionally — signalName is recorded for logging/observability only, not yet compared to the train's next signal (HoldAt is behaviourally equivalent to HoldImmediately until per-signal enforcement lands in a follow-up). - TrainDirective.RouteGranted: document that speedLimitKmh and movementAuthority are carried for protocol completeness but not yet consumed by AlgorithmicTrainDecisionPolicy.decide, and flag the km/h vs m/s unit mismatch any future wiring must handle (divide by 3.6). - TrainDecisionPolicy: drop the meaningless "non-null" from the decide contract (the reading parameter is non-nullable); soften the acceptDirective thread-safety wording to forward-looking form since no caller invokes it yet (SP3 directive-delivery wiring pending). - AlgorithmicTrainDecisionPolicy.acceptDirective: mirror the HoldAt per-signal caveat in the directive bullet. No behavior change; KDoc only. Co-Authored-By: Claude <noreply@anthropic.com>
…ale + applier logging (#795) * SP2b.5: rationale as List<String>, selectWithRationale, logging suffix, updated tests * Fix ktlint multiline-expression-wrapping violation in CandidatePathRuleEngine The `val rationale = buildList { ... }` assignment in selectWithRationale violated ktlint's standard:multiline-expression-wrapping rule because the multiline lambda body started on the same line as the assignment. Ran ./gradlew ktlintFormat to auto-fix, which moved `buildList { ... }` onto its own line and reflowed two nearby test files to match the same rule. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(SP2b.5): adapt PathCommandTranslator to List<String> rationale goal-10 added PathCommandTranslator in SP2b.3 (#794), which constructs SetSwitchPosition and SetSignalAspect with a single String rationale. This branch widens DispatchDecision.rationale to List<String>, so those call sites no longer compiled against the rebased base. Wraps each rationale in a one-element list, preserving the existing meaning, and updates the corresponding assertions in PathCommandTranslatorTest. * fix(SP2b.5): log rationale on tool-driven decisions + pin log-line integration Code review of #795 found the rationale feature shipped dormant: the suffix was appended only on ApproveTrain/HoldTrain, whose only production caller (RuleBasedDispatcher) leaves rationale empty, while the four tool-driven subtypes that PathCommandTranslator populates with rationale go through applyToolDrivenToActuator, which logged no rationale at all. - Move toRationaleLogSuffix to a top-level public extension in :core (DispatchDecision.kt), co-located with the rationale property, so the synchronous wiring path and the async DispatchDecisionApplier share one formatter and cannot drift apart. - applyToolDrivenToActuator now appends rationale.toRationaleLogSuffix() to the four tool-driven "applying ..." DEBUG lines, so PathCommandTranslator rationale is visible in logs on both the sync and async paths. - Add a Logback ListAppender log-capture test pinning that applyDecision appends the suffix to the emitted DEBUG line (mutation-verified: removing the call fails Test A, passes Test B). - KDoc: selectWithRationale documents the optional 3rd "Ranked N" entry; DispatchDecision.rationale documents the logging-path asymmetry (tool-driven on both paths; ApproveTrain/HoldTrain async-only, deliberately); fix a stale helper-location pointer in DispatchDecisionSp2b5Test.
…s dispatcher (#791) * SP4.3: Wire reactive train acceleration through synchronous dispatcher (Issue #565) Adds a per-train reactive acceleration step to wireSynchronousDispatcher: after each tick's dispatcher decisions (admission, path reservation), the live first-person TrainPerceptionReading is read for every approved train and applied via the trainDecisionPolicy parameter (defaults to AlgorithmicTrainDecisionPolicy, added by #793), so acceleration reflects the signal aspects the dispatcher just set this tick. Closes the sense -> decide -> act loop for a single reactive train end-to-end (SingleReactiveTrainEndToEndTest), the smallest proof before the dispatcher agent is added. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix Train.currentSpeedLimitMps collapsing to 0 and permanently latching trains at a stop (Issue #565 regression) Root cause: currentSpeedLimitMps delegated to pathToSemaphore.maxSpeed(pathToSemaphore.getFirst()), which seeds its running minimum from the FIRST separator's own live allowedSpeed(). That first separator is the semaphore the train most recently departed, and in this bidirectional network such a semaphore routinely reports STOP for the direction it no longer faces (see the "SEMAPHORE_SIGNAL_NOT_UPDATED ... reverse direction" warning in DynamicRailSemaphore). Signal.STOP.allowedSpeed() is 0.0, and the fold in AbstractPath.maxSpeed()/contributeToPathMaxSpeed only ever takes a running minimum, so once seeded at 0.0 the result can never recover for the remainder of that leg — even though the property's own KDoc documents it as "the physical track constraint, independent of the signal aspect". This was latent since the property was introduced (Issue #552): nothing read it during a live simulation before SP4.3 wired AlgorithmicTrainDecisionPolicy into wireSynchronousDispatcher's per-tick loop. AlgorithmicTrainDecisionPolicy calls actuator.setTargetSpeed(decision.targetSpeedMps) every tick, and Train.setTargetSpeed accepts 0.0 unconditionally (only positive speeds are gated on a cleared block ahead) — so the spurious 0.0 reading gets applied for real, braking the train to a stand. A train at velocity 0 never crosses another separator, so pathToSemaphore never advances past the stale STOP reading: the train is stuck forever, matching the observed "trains never exit" failures across the ShuntingLoop-based integration suite (14 tests). Fix: currentSpeedLimitMps now folds pathToSemaphore's intermediate elements itself (same forward iteration and skip-endpoints logic as AbstractPath.maxSpeed()), seeded from ABSOLUTE_MAX_SPEED instead of the departed semaphore's own signal-derived speed. This restores the documented "independent of signal aspect" contract without touching the shared Path.maxSpeed()/AbstractPath.maxSpeed() used by its other, legitimate caller (semaphore path setup, where seeding from the entry signal is correct). Verified: SingleReactiveTrainEndToEndTest passes (4 repeated runs), and the full suite of 14 previously-failing tests (ShuntingLoop regression/ smoke tests, KoinGoldenOutputTest, MetricsIntegrationTest, etc.) now pass with zero concurrent load. Confirmed the failure reproduces identically on the pre-fix code via git stash before implementing this fix, ruling out the ktlint formatting change as a cause. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(SP4.3): focused unit test for Train.currentSpeedLimitMps + strengthen always-brake e2e assertion (#791 review) Addresses the two Important findings from the PR #791 code review. 1. Add TrainSpeedLimitTest (closes #800) — focused unit tests pinning the currentSpeedLimitMps seed invariant fixed in SP4.3. Asserts the fold returns the intermediate track limit (40 m/s, NOT 0.0) when the first separator reports STOP (allowedSpeed 0.0), plus the null-path ABSOLUTE_MAX_SPEED fallback and the <3-element edge case. pathToSemaphore is private with no seam, so the field is injected reflectively (no sim/ production code touched, per the conservative rule); the path uses real ArrayPath + SimpleTrackBlock so the real contributeToPathMaxSpeed fold runs. Verified the 40.0 assertion goes red against the reverted buggy getter, then green on the fixed one. 2. Strengthen SingleReactiveTrainEndToEndTest's always-brake test with getTrainsExited() == 0 — pins the setTargetSpeed actuator effect, not just that decide() was invoked.
…ins divert to the free track The canonical shuntingLoop run under the Goal 10 agent dispatcher (example/exampleGui shuntingLoop) deadlocked non-deterministically: two opposing trains met at a switch throat, each holding a block the other needed, both stopped at red, and no train was ever routed onto the free parallel track to let them pass. Observed with trains #11/#12 at vA and #17/#18 at vB; the pair varies with agent-driver pacing but the failure mode is identical. The distinguishing symptom from #797 is that both trains sit at STOP and the run ends in ReservationConflict severity=CRITICAL — a genuine circular wait, not the parked-at-green coroutine of #797. Root cause: findNextReservationTarget picks the first target whose isPathAvailable holds, but isPathAvailable enumerated candidate routes with the switch-BLIND findAllTopologicalPaths. In vyhybna.xml doB1 sits on a separate connector block between k1 and switch vB, so there is an all-free PHANTOM route to doB1 that reverses through vB from the free k2 leg onto the k1 leg while bypassing the occupied k1: zA -> vA -> doA2 -> k2(FREE) -> doB2 -> vB -> [connector] -> doB1 That route is physically impossible but topologically enumerable when switch constraints are ignored, so isPathAvailable(zA, doB1) returned a false positive. doB1 (the occupied k1 track) was therefore judged available and selected; doB2 (the free k2 track) was never tried. Across a full run doB2 was targeted 0 times. Two changes fix it: 1. isPathAvailable now enumerates via findAllSwitchConstrainedPaths (promoted from an internal DefaultTopologyNavigator method to the TopologyNavigator interface), which expands switches only through RailSwitch.possibleFollowers. The phantom vB reversal is never enumerated, so doB1 is reachable only via the occupied k1 -> correctly unavailable -> the target falls through to doB2/k2. 2. getSegment now compares graph edges and the current block on their static refs. In a simulation context the graph edges are DynamicTrackBlock wrappers while the query block is already normalized to its static ref, so the previous identity comparison never matched and the switch constraint silently degraded to the unconstrained candidate list — i.e. findAllSwitchConstrainedPaths was a no-op in every simulation context (which also left the only prior caller, DefaultAutomaticPathFindingService, switch-blind there). Normalizing both sides makes the constraint actually apply. The #742 reservation-time guard stays as defense-in-depth. Verified: SwitchBlindTargetSelectionTest (new) asserts doB1 unavailable and the target is doB2 when k1 is occupied and k2 free; Issue742RegressionTest unchanged; five headless agent-dispatcher runs and a full 1024s GUI run produce zero CRITICAL conflicts with doB2 now used; :core suite (2398 unit + 734 integration) and :fast-sim native (40) green; heavy determinism 28/23 and 60s parity 2/1 unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add DispatcherControlPanel integration into Frame and wire DispatcherModeState from Koin * Fix DispatcherControlPanel integration: expose SimulationRunner.simulationContext property * Address code review feedback: improve error handling, fix null assertion, clarify scope decisions * Address remaining code review feedback: fix issue reference, improve logging, clarify documentation * Simplify imports and clarify preparatory infrastructure for PropertyChangeEvents * Move test helper class and improve debug logging specificity * Remove unnecessary @Suppress and optimize property access in wireDispatcherControlPanel * Add missing closing brace for DispatcherControlPanel class * Fix documentation method reference and wire modeState in rationale test * fix(#561): make DispatcherControlPanel compile and pass its own tests DispatcherModeState lives in commonMain (KMP) and cannot depend on java.beans types, so it has no PropertyChangeListener support. The modeState setter called addPropertyChangeListener/ removePropertyChangeListener on it anyway, which never compiled; strip that dead listener wiring and document that external mode changes are picked up the next time modeState is (re-)assigned, not live. Also fixes several bugs that were only reachable once the module finally compiled and its tests actually ran: - DispatcherControlPanel's init block left the combo box, button, and indicator in their Swing-default enabled state instead of the intended initial-disabled state (only the modeState setter's null-branch disabled them, which Kotlin never invokes for a property's own default initializer). - DispatcherControlPanelTest illegally extended the sealed DispatchDecision class from another module; use DispatchDecision.ApproveTrain instead. - findComboBox() had a raw-type mismatch between JComboBox::class.java and the declared JComboBox<DispatcherMode> return type. - Two rationale tests clicked the "Why this route?" button without first enabling the panel via modeState, so the click was a no-op against the (correctly) disabled button. - FrameTest asserted the north panel had 3 children, but this PR adds DispatcherControlPanel as a 4th. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#561): feed applied dispatcher decisions to a GUI-visible hub PR #801 left the DispatcherControlPanel's "Why this route?" button inert: DispatchDecisionApplier applied decisions on the sim thread but nothing forwarded them to the GUI, so updateDecisionRationale was never called in production. Add a cross-module decision bridge in :core commonMain (KMP-clean, no java.beans): a fun interface DispatchDecisionListener and a thread-safe DispatchDecisionListenerHub (SynchronizedObject+synchronized, mirroring DispatcherModeState) holding a nullable sink that is a no-op by default. DispatchDecisionApplier gains an optional onDecisionApplied constructor param (default null — backward compatible for headless/console runs) that fires once per gated, non-NoAction applied decision on the sim thread. DispatcherAgentModule binds the hub scoped per DefaultSimulationContext so concurrent simulations never cross-wire; headless runs leave the sink null. The GUI side is wired in a follow-up commit. The hub lives in :core so :dispatcher-agent never depends on :desktop-ui. Tests: - DispatchDecisionListenerHubTest (core commonTest, runs on JVM + native): no-sink no-op, setSink forwards, replaces, detaches, concurrent-safe. - DispatchDecisionApplierDecisionListenerTest (dispatcher-agent): fires per applied ApproveTrain/ReservePath in order, NoAction and MANUAL-dropped do not fire, null listener is a no-op. Co-Authored-By: Claude <noreply@anthropic.com> * fix(#561): wire DispatcherControlPanel into Frame and remove dead Koin module PR #801's two Critical review findings both lived in the unwired seam between Frame and DispatcherControlPanel: - Critical 1 (display): onRationale was never assigned and updateDecisionRationale was never called in production, so the "Why this route?" button could never show anything. - Critical 2 (mode): onModeChanged was never connected to DispatcherModeState.setOverride, so selecting a mode did not propagate and the indicator color went stale (the combo action listener never refreshed it). Frame.wireDispatcherControlPanel now (on SimulationStatus.RUNNING): - sets onModeChanged = { mode -> modeState.setOverride(mode) }; - sets onRationale to show the last decision's rationale in a modal JOptionPane (formatRationale helper for the empty case); - pulls the scoped DispatchDecisionListenerHub and points its sink at a lambda that marshals updateDecisionRationale onto the EDT (the sink fires on the sim thread). On STOPPED the sink is detached (setSink(null)) and the panel's modeState and rationale are cleared, so the sim thread cannot push into a stale panel. ExampleRegistry.wireDispatcherAgent now passes the scoped hub to DispatchDecisionApplier as onDecisionApplied, completing the bridge. DispatcherControlPanel extracts updateIndicator(mode) from syncUiToMode and calls it inside the combo action listener so the indicator color updates immediately on operator selection. The class KDoc is corrected: the panel is direct-constructed by Frame (not wired via a Koin module) and bound to the per-context scoped DispatcherModeState; a note records that SEMI_AUTO operator-approval UI and applier mode enforcement are deferred to a follow-up. Remove the dead guiDispatcherModule from InterlockSimModule: it was never loaded in Main.kt (only interlockSimModule + guiModule) and never injected. Also raise the Frame window height from 768 to 818 so the DispatcherControlPanel line does not steal vertical space from the scrollable RailwayNetGridCanvas (PR #801 issue comment): station tracks stay visible in simulation mode. Tests: - DispatcherControlPanelTest: regression that selecting a mode in the combo updates the indicator color immediately. - FrameDispatcherControlPanelIntegrationTest (integration-test, needs a display): builds a real DefaultSimulationContext + ShuntingLoop and verifies the full seam — setContext shows the panel, startSimulation wires modeState and enables the controls, combo selection propagates to setOverride, the hub feeds an applied decision's rationale to the panel, stopSimulation clears panel state, setContext(EditingContext) hides the panel. - FrameTest: height assertion updated to 818.
…nt start (#802) * SP2b.8: load static station topology into dispatcher system prompt at agent start * SP2b.8: add tests for BlockIdentity and StationTopologySerializer * fix(#654): wrap chained call in StationTopologySerializer for ktlint Multi-line method-chain formatting on inOutNames was flagged by ktlint (each chained call must start its own line); reformat to match the existing style already used for signals/switches in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#695): address SP2b.8 review findings in StationTopologySerializer - Use structural equality (==) for the InOut self-pair skip so distinct wrappers around the same static InOut are still treated as identical. - Filter blank-named InOuts once and feed both the listed names and the route-endpoint list from it, so a route never references an entry/exit point the prompt omitted. - Note the per-pair route cap in the rendered topology so the agent cannot mistake a capped route list for an exhaustive one. - Add a BlockIdentityTest case covering the runCatching guard when block.ends() throws, and a StationTopologySerializerTest case for the route-cap note.
…804) * Initial plan * test(#654): tighten RouteFinderIntegrationTest lowest-cost-route assertion Replace the loose `isLessThanOrEqualTo(maxBlocksForCheapest + 3)` upper-bound with two exact assertions: 1. `assertThat(success.reservedBlocks.size).isEqualTo(cheapestRoute.segments.size)` – exact block count, no +1 or +3 fudge factors. 2. `assertThat(totalReservedLength).isEqualTo(cheapestRoute.totalLength)` – for vyhybna.xml k1=320.0 m vs k2=510.0 m; a wrong route would fail this check even though both routes share the same 7-segment count. Also: - Replace `import assertk.assertions.isLessThanOrEqualTo` with `import assertk.assertions.isEqualTo` - Update KDoc to explain the 320 m / 510 m distinction - Rename local `cheapestCost` to `cheapestRoute` for clarity * test(#654): document single-visit-per-block assumption in RouteFinderIntegrationTest Address code-review feedback on PR #804: the tightened reservedBlocks.size == segments.size and totalLength equalities hold only because k1 visits each DynamicTrackBlock exactly once. The service deduplicates sections mapping to the same block, so a future fixture where k1 revisits a block would fail both assertions on a correct reservation. Document the assumption so a fixture-edit failure is not misread as a reservation-logic regression. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Bedrich Hovorka <bedrich.hovorka@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
…dule editing scope (#805) * core: add RouteFinder/AutomaticPathFindingService bindings to CommonCoreTestModule editing scope (#655) The editing-context scope in CommonCoreTestModule was missing scoped<AutomaticPathFindingService> and scoped<RouteFinder>, while CoreTestModule (JVM) had all three in scope<DefaultEditingContext>. Add both bindings to mirror CoreTestModule for symmetry and future- proofing: any commonTest/native test resolving RouteFinder or AutomaticPathFindingService from an editing-context scope will now resolve correctly instead of failing at runtime with a Koin error.
#807) * SP2b.7: add DispatcherCollisionValidationTest — zero Goal 3 warnings safety-net gate * SP2b.7: extract LiftedStackFixture — dedup lifted-stack wiring + lock-step Both DispatcherCollisionValidationTest (Goal 3 safety-net gate) and ShuntingLoopLiftedDriverIntegrationTest (Goal 9 single-run gate) drove vyhybna.xml with identical lifted-stack wiring and the same lock-step handshake — ~75 lines duplicated. A change to the stack or handshake had to be applied in two places, so the pairing the new test's KDoc promises ("identical to the sibling") could silently drift. Centralise the wiring + handshake in LiftedStackFixture so the two gates share one source of truth. Each test keeps its own pre-run subscriptions (Goal 3 subscribes to CollisionWarning, Goal 9 only to ConflictDetectedEvent) and post-run assertions. Also document the deliberate assertion ordering (liveness → Goal 3 → Goal 9) in the new test, and the pre-condition that dispatcherAgentTestModule binds the concrete DefaultCollisionDetectionService so the cast is safe under the current binding.
…c distinction (#810) * fix(#787): O(n) select() optimization + stateless/pure purity wording in CandidatePathRuleEngine - Replace rank()+first() with minWithOrNull(comparator) in selectWithRationale() for O(n) best-candidate selection instead of O(n log n) full sort. ranked.size replaced with candidates.size (identical value, no behaviour change). - Expand Determinism section in class KDoc: class is stateless; rank is pure; select/selectWithRationale are not strictly pure due to logger.debug side effect. - Add Side effect and Performance notes to selectWithRationale() KDoc. Closes #787
…cement (#808) * SP2b.6 follow-up (#806): SEMI_AUTO approval UI + applier mode enforcement - Add SemiAutoApprovalGateway (core/commonMain): thread-safe mutable holder for the SEMI_AUTO blocking approver; mirrors DispatchDecisionListenerHub pattern; returns false (drop) when no approver is installed. - Add SemiAutoApprovalGatewayTest (core/commonTest): 7 unit tests covering no-approver default, true/false callbacks, replacement, null-detach, and concurrent setApprover+approve. - Register SemiAutoApprovalGateway in Koin scope<DefaultSimulationContext> in DispatcherAgentModule (one per context, same scope as DispatcherModeState). - Update ExampleRegistry.wireDispatcherAgent: resolve DispatcherModeState and SemiAutoApprovalGateway from scope; pass both to DispatchDecisionApplier so MANUAL drops and SEMI_AUTO prompts are enforced at the applier level. - Add SemiAutoApprovalDialog (desktop-ui): modal Swing dialog with Approve / Dismiss buttons; shows decision type, train ID, route, rationale; promptOnEdt() companion blocks the sim thread via invokeAndWait. - Update Frame.wireDispatcherControlPanel: installs SemiAutoApprovalDialog as the gateway approver after simulation starts; stores ref in wiredSemiAutoGateway for cleanup; STOPPED handler and switchToEditingMode both call setApprover(null). - Update DispatcherControlPanel KDoc: remove "deferred" note; describe the now-wired approval dialog. - Add DispatchDecisionApplierSemiAutoGatewayTest (dispatcher-agent/test): 6 integration tests for the applier+gateway end-to-end wiring pattern used by ExampleRegistry. - Add 2 integration tests to FrameDispatcherControlPanelIntegrationTest: startSimulationInstallsSemiAutoApprover and stopSimulationDetachesSemiAutoApprover (Issue #806, SP2b.6 follow-up). * fix: split long single-line logger.debug lambda in Frame.kt to satisfy ktlint function-literal/wrapping rules * fix: ktlint formatting in SemiAutoApprovalDialog.kt and related test files * fix: PR #808 review findings — dialog resource leak, EDT guard, auto-dismiss timeout Addresses code review of the SEMI_AUTO approval UI (Issue #806): - SemiAutoApprovalDialog now disposes on every close path (Approve, Dismiss, X-close) instead of only hiding, preventing native window handles from accumulating across long SEMI_AUTO sessions. - promptOnEdt fails fast with a check() guard if ever called on the EDT, instead of throwing Swing's opaque invokeAndWait error. - The dialog now auto-dismisses (drop, fail-safe) after a configurable timeout with a live countdown, so an absent operator cannot stall the simulation thread indefinitely; timeoutSeconds <= 0 restores the original indefinite-block behavior. - Strengthened the gateway's concurrency test to assert the linearizability contract (approve returns exactly what the invoked approver returned, or false when none is installed) instead of only checking for no corruption. --------- Co-authored-by: Bedrich Hovorka <bedrich.hovorka@gmail.com>
…d fallback (#811) * SP2b.9: add KoogAgentPlanAdapter, shuntingLoopAI GUI example, runExampleAIGui Gradle task * SP2b.9: fix race condition in KoogAgentPlanAdapter, improve test assertions * SP2b.9: real Ollama dispatch-loop wiring + bidirectional block-boundary signals Wires the Koog dispatch-loop tools (queued_trains/block_inputs/approve_train) to a real Ollama model via KoogToolAdapter/OllamaModelFactory/MainProcessDispatchLoopSensorPort, and bumps Koog 1.0.0 -> 1.1.1. The dispatcher-agent system/user prompts never told the LLM that a granted request_route reservation is separate from approve_train admitting a queued train onto the network — so a route could sit reserved forever with the train never actually dispatched. Both prompts now say so explicitly. Investigating why an approved train still wouldn't move on some routes led to a real interlocking defect: DynamicRailSemaphore.checkPathSegments only cleared a block-boundary signal for one of the two directions a train could legitimately cross it (the "reverse" pairing silently left the signal at STOP), even though vyhybna.xml's shunting loop requires both directions to work. A granted, fully block/switch-reserved route could report Success while the train stalled forever at that one semaphore. Signals now clear for either direction; existing tests locking in the old one-directional behavior were updated, and a throughput-timing assertion in TrainReporterIntegrationTest was recalibrated since trains no longer stall mid-network. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP2b.9 follow-up: fix semaphore ownership gap, restrict dispatcher to high-level actuators A user report of "all signals green including the opposite direction" during a shuntingLoopAI run turned out not to be a reservation-safety hole: block-level exclusivity (DynamicTrackBlock's state machine, PathReservationRegistry's ownership checks) already makes real double-occupation of a boundary from opposite directions impossible, regardless of the semaphore's displayed aspect. The actual defect was narrower: DynamicRailSemaphore's shared signal field is not keyed by direction or by train, so Front.allowingSignal's wait condition could be satisfied by an unrelated train's grant, waking a train with no reservation of its own and crashing it via requireSimulationNotNull a few lines later. Fixed by also requiring trainNavService.findReservedPathForTrain(...) is PathResult.Available before waking, reusing existing ownership-tracking machinery instead of adding new state to DynamicRailSemaphore. Added a coverage-lock test proving the reservation layer correctly rejects a reverse route through blocks already held by another train. Separately, restrict the LLM dispatcher to high-level actuators only: removed the set_signal_aspect/set_switch_position tools, which let the agent mutate switch/signal state outside the interlocking's reservation flow entirely, and added a concurrency cap to approve_train (default 2, matching RuleBasedDispatcher's existing station-capacity constant) since it had none. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP2b.9 follow-up: clarify Block IDs vs endpoint names in dispatcher prompt The LLM dispatcher occasionally called request_route with a Block ID (e.g. "kB") instead of a valid InOut/Signal endpoint name. The tool already rejects this safely, but the mistake recurred because the system prompt and topology text never explicitly distinguished the two ID families, and the Routes section interleaved block IDs with endpoint names (e.g. "A->B: kA, k1, kB"). Add explicit "Block IDs are not valid request_route arguments" annotations to the system prompt, the topology's Blocks/Routes headers, and the request_route/block_occupancy tool descriptions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP2b.9 follow-up: fix LLM dispatcher never calling approve_train Root cause: the system prompt buried the approve_train instruction in a single trailing sentence with no priority ordering and no concrete concurrency cap, unlike RuleBasedDispatcher's unconditional admission-first policy. Restructure the prompt into an explicit admission-first-then-routing procedure that interpolates RuleBasedDispatcher.DEFAULT_MAX_CONCURRENT_TRAINS, and list the actual actuator tool inventory to counter the LLM hallucinating a removed set_signal_aspect-shaped request_route call. Verified against a live Ollama run: the LLM now calls approve_train immediately after request_route and Train #1 completes a full physical journey, with no rule-based fallback invoked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP2b.9 follow-up: fix redundant-route PathInfo corruption + admission stall Root causes identified via traffic-simulation-expert and agent-architect analysis of a live Ollama dispatcher run: - A stateless LLM dispatch cycle has no memory of prior cycles, so it can redundantly re-request a route for a train it already granted one to. DefaultPathReservationService.reservePath's already-owned-blocks shortcut unconditionally re-merged the identical PathInfo, duplicating every separator; a further redundant call hit PathReservationRegistry's 3rd-occurrence cycle-abort, silently reverting the merge while still reporting Success. Fixed with an idempotency guard: skip the merge entirely when the train already holds a route to the same target. - Admission (approve_train) worked for the first train then stopped for many subsequent cycles despite free capacity, because nothing forces the LLM to act on its own stated precondition and learning the active count required an extra perception round-trip. Added a deterministic admission safety net in KoogAgentPlanAdapter (force-approves the oldest queued train(s) up to capacity after a completed LLM cycle, safe because approve_train is documented idempotent) and surfaced the active count/cap directly in the per-cycle prompt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * SP2b.9 PR #811 review follow-up: dispatcher fallback race fix + coverage Addresses the traffic-simulation-expert and agent-architect review of PR #811. Fix A (ShuntingLoop): hard gate in approveQueuedTrain refusing admission beyond maxConcurrentTrains (new ctor param, default RuleBasedDispatcher.DEFAULT_MAX_CONCURRENT_TRAINS, require > 0). approwedTrains is pruned of terminated trains each iteration so the gate cannot permanently block; the rule-based path never hits it (produces exactly cap - approvedCount ApproveTrain decisions). Fix C (tests): 4 buildUserPrompt-contract tests in KoogDispatchAgentImplTest + 3 MainProcessDispatchLoopSensorPort query-method assertions in DispatcherAgentPortBindingTest (closed the 3 missed lines behind the SonarQube new-code coverage gap). Fix D (KoogDispatchAgentImpl.buildUserPrompt): reworded so switches/signals are stated to change as a side effect of requesting and canceling routes, with no direct tool to set them. Fix E (OllamaExecutorConfig.noOpSettingWarnings + OllamaSimpleExecutor): surface at startup that maxTokens/topP are not forwarded to Ollama under pinned Koog 1.1.1; +4 tests. Fix F (KoogAgentPlanAdapter): reversed fallback semantics — empty+no-tools falls back; empty+tools does not (runs the admission safety net). The original approximateSize before/after delta had a false-negative window under the production decoupled driver/sim threading model (the kDisco sim thread drains the queue mid-decideAsync, no strict handshake), so fallback could fire on top of already-acted LLM tool calls. Replaced with a per-cycle postAll-CALL counter inside ActuatorCommandQueue (resetCycleActuatorCount / actedViaToolsThisCycle) that counts post calls, not queue contents — immune to the sim draining. Also corrected the KDoc's overstated "lock-stepped handshake" / "fresh session per plan" claims (per the traffic-simulation-expert follow-up) and documented the downstream-idempotency backstop. 5 adapter tests rewritten for the new semantics + 1 dedicated race regression test. Verification: :dispatcher-agent:test 338/0, :core:jvmTest 2465/0, :desktop-ui:test 950/0, :core:integrationTest 758/0, detekt/ktlintCheck green.
* Bump kDisco to 0.6.1-SNAPSHOT (cz.hovorka.kdisco -> cz.ksimulantenbande.kdisco) kDisco PR #54 renamed its Maven group and Kotlin package from the maintainer's personal namespace to the neutral cz.ksimulantenbande.kdisco and added public-domain license headers. Update all consumer-side references (dependency coordinates, imports, checkKdisco path, docs) to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Replace identity-hash Koin scopeId with a monotonic counter in context classes (#759) * Fix non-unique Koin scope ids in DefaultSimulationContext/DefaultEditingContext * Fix review findings on ContextScopeIdUniquenessTest (#759) Correct the misleading comment on scopeIdsAreUniqueAcrossManyEditingContexts (5,000 is below the ~77,000 birthday-bound 50% point of the old 31-bit identity-hash space, so the guard primarily validates the new counter, not the old collision) and add a strict-ordering assertion to nextContextScopeIdIsMonotonicAndUnique so it actually verifies monotonicity as its name implies (parsing ids to Long to avoid lexicographic-string comparison). * Convert train-movement event detection to kDisco waitCrossing state events (Issue #750, step 1) (#756) * sim(#750): convert block-boundary & tail-entry detection to kDisco waitCrossing state events; keep dtMax=1e-3 pending Motor redesign * sim(#750): finalize waitCrossing conversion — fix tolerance gap, correct comment, add regression tests Verifies and closes out the waitCrossing conversion from 16fb70c now that kDisco 0.6.1-SNAPSHOT (with waitCrossing) is locally publishable and the build works again: - Generator.kt: correct a comment that overclaimed velocity-target events are root-found — only block-boundary and tail-entry were converted; Motor's AccelerationStopCondition still uses plain waitUntil. The Motor/dtMax follow-up is now tracked separately in issue #760. - SimpleTestProcess.kt: fix a real regression exposed by the conversion. This test coordinator never runs Generator, so it silently relied on kDisco's own raw defaults (dtMin=1e-5, maxAbsError=1e-5 — nearly equal in magnitude). The waitCrossing guards intentionally leave a structural gap of exactly `dtMin` at the moment a threshold is reached (see the existing Train.kt comments on why that slack is necessary), which is negligible against the intended maxAbsError=1e-2 but trips LengthChecker's invariant when left at kDisco's near-equal raw defaults. Confirmed via A/B test against 74cd41b (same kDisco version, pre-conversion Train.kt) that this is new, not pre-existing. Fixed by configuring the same tolerances Generator.startAction() sets. - SimpleTestProcessTest.kt: relax an assertion that assumed `position` stays >= 0 immediately after a block-boundary crossing. The waitCrossing guard deliberately fires `dtMin` short of the boundary so it reliably crosses zero instead of asymptoting forever; this leaves a bounded, harmless negative transient that was never a documented invariant. - SimpleLinearTrackTestProcessTest.kt: add an explicit regression test proving both converted call sites fire correctly end-to-end. Verified locally: kDisco published to mavenLocal from copilot/add-zero-crossing-detection (feaa058), full build/test/ integrationTest/heavyTest all green (0 failures across all JUnit reports). sim(#797): wait on block boundaries with level-triggered waitUntil, not waitCrossing The canonical shuntingLoop deadlocked deterministically at t~683: Train #16 came to rest in kA and emitted nothing for the remaining ~341 simulated seconds, while its route onto the free second track k2 was fully reserved, switch vA was configured to BRANCH, and semaphore zA had gone STOP -> S80 at t=662 and never reverted. The train was standing in front of an allowing signal. Commit 0c38a75 (Issue #750 step 1) converted the block-boundary and tail-entry gates in Train.Site.actions() from level-triggered waitUntil to edge-triggered waitCrossing. "The front has reached the boundary" is a level condition, not an event. The braking law a = -v^2 / (2s) in Motor.derivatives() drives v -> 0 as s -> 0, so a train stopping at a semaphore located at a block boundary comes to rest a few nanometres short. waitCrossing samples its guard only across integration-step endpoints and requires a strict sign change, so once that crossing is missed the position never changes again and the notice can never fire. The Site coroutine is then parked for the rest of the run, and because separatorAction, semaphoreAction and the path re-query all sit downstream of the gate, the train goes silent even after its route is reserved and its signal clears. Both gates now use waitUntil with the dtMin slack that makes the predicate reachable for that asymptotic approach. kDisco re-tests a waitUntil notice after every discrete event and after every accepted integration step, so a train parked arbitrarily close to the boundary is always released. Redoing the #750 conversion needs a kDisco primitive that is level-triggered and root-found; dtMax is still 1e-3 here (Motor is not converted either, Issue #760), so nothing is lost by waiting for it. Verified by an A/B build in which the gate was the only variable: 16 trains and a deadlock at t=683 before, 25 trains running through to t=1020 after. Adds Issue797StoppedAtAllowingSignalTest, which asserts the safety invariant that no train may stand still while the signal ahead shows an allowing aspect - a property that holds for any topology and dispatcher, so it needs no re-measuring. It also pins the train-accounting identity exited + approved + queued == entered with approved <= maxConcurrentTrains, documenting that getTrainsEntered() counts trains generated into the queue rather than trains in the network. Re-measures the 1024s determinism baselines, which had locked in the deadlocked outcome: 28 entered / 14 exited -> 28 entered / 23 exited, identically on JVM (ShuntingLoopHeavyTest) and native (ShuntingLoopDeterminismTest). The 60s parity constants (2/1) are unchanged because the deadlock only begins at t=658. Closes #797 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> sim(#797): use waitUntilCrossing for block-boundary and tail-entry gates Replaces the interim plain-waitUntil #797 fix with kDisco's level-triggered, root-found waitUntilCrossing (bedaHovorka/kdisco#72) at the two Site gates in Train: the block-boundary wait and the tail-entry wait. Both gates express a LEVEL condition ("the front has reached the boundary" / "the front has advanced one train-length"). Issue #750 step 1 first expressed them as the edge-triggered waitCrossing, which permanently parked a train that decelerates to a stop at a semaphore located at the boundary: the braking law a = -v^2 / (2s) drives v -> 0 as s -> 0, so the front halts a few nanometres short, the guard's sign change is missed, and the process is never rescheduled (the #797 deadlock). The interim fix reverted to a plain waitUntil, which is safe but forfeits the root-finding precision that let #750 raise dtMax. waitUntilCrossing keeps both properties: it resumes as soon as guard() <= 0 — re-tested after every discrete event and every accepted integration step, so an asymptotic approach is never missed — while still locating a within-step crossing by bisection. The dtMin slack still makes the threshold reachable for the asymptotic approach. Requires kDisco with waitUntilCrossing (0.6.1-SNAPSHOT once bedaHovorka/kdisco#72 is merged and republished). Unblocks completing #750 and #760 (Motor conversion + dtMax retuning). Verified: Issue797StoppedAtAllowingSignalTest and the full :core suite (2398 unit + 734 integration) green; :fast-sim native (40) green; heavy determinism 28/23 and 60s parity 2/1 unchanged; five headless agent runs and a full 1024s GUI run deadlock-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Increase shuntingLoop example duration 300s -> 1024s + expose kDisco RNG cross-platform divergence (#785) * docs/CI: bump shuntingLoop example duration 300s -> 1024s Updates every doc and CI-pipeline reference to the canonical shuntingLoop example run (README, CLAUDE.md, architecture docs, PR templates, and the JVM/native smoke-test steps + cross-platform comparison summary in gradle-java21.yml). Integration-test durations are intentionally left unchanged (300s/120s baselines serve their own timeout/determinism purposes). docs/issues/issue_291_investigation_report.md is left as-is since it documents commands actually run during a past investigation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: add ShuntingLoop(1024s) determinism tests, JVM + native Adds a JUnit5 heavy test (:core:heavyTest, 1000 reps, manual-run) and a kotlin.test native test (:fast-sim:linuxX64Test, 20 reps, always-on in CI), each asserting its own platform's verified deterministic entered/exited train counts for ShuntingLoop(vyhybna.xml, endTime=1024). The two tests intentionally assert different numbers on paper but currently observe the same values (28 entered / 14 exited) for JVM and the linuxX64Test debug binary; the release fast-sim.kexe binary that `docker compose run fast-sim` actually runs deterministically produces a third outcome (16/14) for the identical seed and construction path. Root cause: kDisco's Random.exp()/.normal() route bit-identical raw uniform draws through kotlin.math.ln/exp, which are not required to be bit-identical by IEEE-754/the Java Math spec across JVM vs debug-vs-release Kotlin/Native codegen, and that near-epsilon difference is amplified by kDisco's discrete-event scheduling into different train counts over a long run. Tracked upstream: bedaHovorka/kdisco#69. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: assert identical ShuntingLoop counts on JVM and native (kdisco#69 fixed) kDisco 0.6.1-SNAPSHOT now ships a pure-Kotlin fdlibm PortableMath, making Random.exp()/normal() bit-identical across JVM and Kotlin/Native (bedaHovorka/kdisco#69). Verified here: JVM heavy test 1000/1000 reps and native determinism test 20/20 reps both produce 28 entered / 14 exited at 1024s; the 60s parity tests produce 2 entered / 1 exited on both platforms. - ShuntingLoopHeavyTest / ShuntingLoopDeterminismTest: KDocs rewritten — the per-platform divergence explanation is replaced by the cross-platform equality guarantee; counts unchanged (the upstream fix kept JVM sequences backward-compatible and aligned native to them). - JvmParityReferenceTest / NativeJvmParityTest: new invariant 7 asserts exact entered/exited counts (2/1 at 60s), identical constants on both platforms. - ShuntingLoopDeterminismTest: silence per-event INFO logging during the test (kotlin-logging native defaults to INFO -> stdout; 20 reps x 1024s flooded Gradle's test-output reader until it ran out of heap in CI) and add a per-iteration wall-clock guard (120s) so a stalling run fails with a diagnosable message instead of an opaque CI step timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: add shuntingLoopSync JVM example for exact cross-platform parity The JVM console `shuntingLoop` example runs the Goal 10 dispatcher-agent stack (Issue #733), which does not exist on the native target — its output can never be count-equal with fast-sim.kexe regardless of RNG determinism. The new `shuntingLoopSync` example mirrors the native `shuntingLoop` construction (wireSynchronousDispatcher) exactly, giving CLI-level comparisons an apples-to-apples target. Verified: at 1024s both binaries emit 266 byte-identical event lines and 16 approved trains. CrossPlatformParityTest now launches the JVM side with `shuntingLoopSync` and asserts exact equality of event counts and summary train counts (replacing the 0.5x-2.0x ratio check that predated the kdisco#69 fix). Its event-line regex is anchored to line start so logger lines containing `t=<digits>` mid-line (e.g. `endTime=60`) are not miscounted as events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: require exact JVM/native count equality; add missing test timeouts - compare-outputs: invariant 6 now requires exactly equal event counts and summary train counts between the JVM and native smoke outputs (was: event ratio within 0.5x-2.0x). The JVM smoke step runs `shuntingLoopSync` so both platforms execute the identical synchronous-dispatcher construction. - fast-sim job: raise the native-test step timeout 5 -> 10 minutes; the 20-rep 1024s determinism test is legitimately slower than the previous suite (its stdout flood, the actual cause of the observed Gradle OutOfMemoryError, is fixed at the source in the test itself). - :core:heavyTest: add a 20-minute task-level timeout so a wedged heavy run self-terminates (a healthy 1000-rep run takes ~13 minutes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cut native determinism reps 20 -> 5 to fit the CI step timeout The log-flood OOM is fixed (verified: the failed CI run shows no OutOfMemoryError and every earlier test passes), but the remaining failure is pure runtime: the debug test binary needs ~14 s per 1024s repetition unloaded (~26 s+ on the 2-core CI runner), so 20 repetitions overran the 10-minute step timeout at ~8.6 minutes. 5 repetitions run in 70 s locally (~2.5 min in CI) and still catch rep-to-rep non-determinism; the 1000-rep JVM heavy test covers the long tail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: anchor event-line grep in compare-outputs to line start The exact-equality check counted 280 JVM vs 266 native event lines: 14 logback INFO lines ("Train completed journey: trainId=... t=51.48...") contain t=<digits> mid-line and were miscounted as events by the unanchored grep. Anchoring to line start (as the invariant-4 timestamp sed already does, and as CrossPlatformParityTest does since the same bug was fixed there) yields 266 == 266. Train counts already matched (16 == 16). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Increase shuntingLoop example duration 300s -> 1024s + expose kDisco RNG cross-platform divergence (#785) * docs/CI: bump shuntingLoop example duration 300s -> 1024s Updates every doc and CI-pipeline reference to the canonical shuntingLoop example run (README, CLAUDE.md, architecture docs, PR templates, and the JVM/native smoke-test steps + cross-platform comparison summary in gradle-java21.yml). Integration-test durations are intentionally left unchanged (300s/120s baselines serve their own timeout/determinism purposes). docs/issues/issue_291_investigation_report.md is left as-is since it documents commands actually run during a past investigation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: add ShuntingLoop(1024s) determinism tests, JVM + native Adds a JUnit5 heavy test (:core:heavyTest, 1000 reps, manual-run) and a kotlin.test native test (:fast-sim:linuxX64Test, 20 reps, always-on in CI), each asserting its own platform's verified deterministic entered/exited train counts for ShuntingLoop(vyhybna.xml, endTime=1024). The two tests intentionally assert different numbers on paper but currently observe the same values (28 entered / 27 exited) for JVM and the linuxX64Test debug binary; the release fast-sim.kexe binary that `docker compose run fast-sim` actually runs deterministically produces a third outcome (16/14) for the identical seed and construction path. Root cause: kDisco's Random.exp()/.normal() route bit-identical raw uniform draws through kotlin.math.ln/exp, which are not required to be bit-identical by IEEE-754/the Java Math spec across JVM vs debug-vs-release Kotlin/Native codegen, and that near-epsilon difference is amplified by kDisco's discrete-event scheduling into different train counts over a long run. Tracked upstream: bedaHovorka/kdisco#69. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: assert identical ShuntingLoop counts on JVM and native (kdisco#69 fixed) kDisco 0.6.1-SNAPSHOT now ships a pure-Kotlin fdlibm PortableMath, making Random.exp()/normal() bit-identical across JVM and Kotlin/Native (bedaHovorka/kdisco#69). Verified here: JVM heavy test 1000/1000 reps and native determinism test 20/20 reps both produce 28 entered / 14 exited at 1024s; the 60s parity tests produce 2 entered / 1 exited on both platforms. - ShuntingLoopHeavyTest / ShuntingLoopDeterminismTest: KDocs rewritten — the per-platform divergence explanation is replaced by the cross-platform equality guarantee; counts unchanged (the upstream fix kept JVM sequences backward-compatible and aligned native to them). - JvmParityReferenceTest / NativeJvmParityTest: new invariant 7 asserts exact entered/exited counts (2/1 at 60s), identical constants on both platforms. - ShuntingLoopDeterminismTest: silence per-event INFO logging during the test (kotlin-logging native defaults to INFO -> stdout; 20 reps x 1024s flooded Gradle's test-output reader until it ran out of heap in CI) and add a per-iteration wall-clock guard (120s) so a stalling run fails with a diagnosable message instead of an opaque CI step timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: add shuntingLoopSync JVM example for exact cross-platform parity The JVM console `shuntingLoop` example runs the Goal 10 dispatcher-agent stack (Issue #733), which does not exist on the native target — its output can never be count-equal with fast-sim.kexe regardless of RNG determinism. The new `shuntingLoopSync` example mirrors the native `shuntingLoop` construction (wireSynchronousDispatcher) exactly, giving CLI-level comparisons an apples-to-apples target. Verified: at 1024s both binaries emit 266 byte-identical event lines and 16 approved trains. CrossPlatformParityTest now launches the JVM side with `shuntingLoopSync` and asserts exact equality of event counts and summary train counts (replacing the 0.5x-2.0x ratio check that predated the kdisco#69 fix). Its event-line regex is anchored to line start so logger lines containing `t=<digits>` mid-line (e.g. `endTime=60`) are not miscounted as events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: require exact JVM/native count equality; add missing test timeouts - compare-outputs: invariant 6 now requires exactly equal event counts and summary train counts between the JVM and native smoke outputs (was: event ratio within 0.5x-2.0x). The JVM smoke step runs `shuntingLoopSync` so both platforms execute the identical synchronous-dispatcher construction. - fast-sim job: raise the native-test step timeout 5 -> 10 minutes; the 20-rep 1024s determinism test is legitimately slower than the previous suite (its stdout flood, the actual cause of the observed Gradle OutOfMemoryError, is fixed at the source in the test itself). - :core:heavyTest: add a 20-minute task-level timeout so a wedged heavy run self-terminates (a healthy 1000-rep run takes ~13 minutes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cut native determinism reps 20 -> 5 to fit the CI step timeout The log-flood OOM is fixed (verified: the failed CI run shows no OutOfMemoryError and every earlier test passes), but the remaining failure is pure runtime: the debug test binary needs ~14 s per 1024s repetition unloaded (~26 s+ on the 2-core CI runner), so 20 repetitions overran the 10-minute step timeout at ~8.6 minutes. 5 repetitions run in 70 s locally (~2.5 min in CI) and still catch rep-to-rep non-determinism; the 1000-rep JVM heavy test covers the long tail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: anchor event-line grep in compare-outputs to line start The exact-equality check counted 280 JVM vs 266 native event lines: 14 logback INFO lines ("Train completed journey: trainId=... t=51.48...") contain t=<digits> mid-line and were miscounted as events by the unanchored grep. Anchoring to line start (as the invariant-4 timestamp sed already does, and as CrossPlatformParityTest does since the same bug was fixed there) yields 266 == 266. Train counts already matched (16 == 16). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * core: add branchySwitchChain topology and findAllPaths branchy-network performance test (#803) * core: add branchySwitchChain topology and findAllPaths branchy-network performance test Adds TestTopologies.branchySwitchChain(stageCount) which creates a chain of N binary-switch bypass stages (SIMPLE_RIGHT_FALSE RailSwitch + RailSemaphore bypass + RailSemaphore merge), producing exactly 2^N switch-constrained paths from "A" to "B". Each stage's switch fans out via Segment.F (main/east) and Segment.G (bypass/southeast); the RailSemaphore merge point imposes no path constraints, so both legs converge freely. Adds a second performance test in DefaultAutomaticPathFindingServiceTest covering findAllPaths on a 15-stage branchy network (2^15 = 32,768 paths). The test asserts that the maxPaths cap is respected and saturated after full BFS enumeration, and that enumeration completes within 5,000 ms. This guards against regressions in findAllSwitchConstrainedPaths on switch-rich networks. Closes #652 * fix: branchySwitchChain merge node needs RailSwitch, not RailSemaphore MrgSem{i} requires 3 physical connections (main-leg in, bypass-leg in, outgoing) but RailSemaphore only exposes 2 for HORIZONTAL orientation, so the topology could not actually route both legs to the merge point. Switched MrgSem{i} to RailSwitch(SIMPLE_LEFT_TRUE), which exposes the required {A, D, F} segments, and corrected the InOut entry/exit orientation booleans to match. * fix: guard every switch input with a semaphore in branchySwitchChain and add 2^n correctness test Review fixes for PR #803: - Add approach guards (AppSem{i}, MainSem{i}) so every switch input in branchySwitchChain is protected by a RailSemaphore, matching real interlocking practice — a converging/diverging junction must be approached under signal protection. BrSem{i} already guards the merge's bypass input (Mrg{i}.D). The exit leg is a switch output, so it carries no guard. RailSemaphore is a pass-through in findAllSwitchConstrainedPaths, so the 2^stageCount path count is unchanged (verified by the new test). - Rename the merge node mrgSem/"MrgSem{i}" -> mrgSw/"Mrg{i}" and update comments/KDoc: the merge is a RailSwitch (SIMPLE_LEFT_TRUE), not a semaphore. A RailSemaphore only exposes 2 connection segments and cannot merge two legs. - Add a parameterized correctness test (stageCount 1..5) asserting the exact 2^stageCount path count; the 15-stage perf test only checked cap saturation, not the headline invariant. - Drop the redundant `result.size <= maxPaths` assertion (subsumed by hasSize(maxPaths)); align the perf-test warm-up to repeat(5); lower the stageCount bound from 1..20 to 1..18 (2^20 ~ 1M paths risks CI timeout).
…re ollama-tests (#816) * feat(#815): warm up Ollama model before first dispatch cycle and before ollama-tests - Add OllamaModelPrewarmer: sends a minimal /api/generate request (empty prompt, num_predict=1, num_ctx=contextWindowTokens) to preload the model into Ollama memory before the first real inference cycle. Uses the same num_ctx as ContextWindowStrategy.Fixed so Ollama does not reload the model between warm-up and first real inference. Failures are non-fatal (caught, WARN logged). - Integrate into KoogAgentFactory.createAgent(): OllamaModelPrewarmer.warmUp() is called before the agent is built, outside of KoogAgentPlanAdapter's withTimeout(inferenceTimeout) block. Cold model-load latency is therefore absorbed before the 30 s per-cycle dispatch timeout window starts. - Add OllamaPrewarmExtension: JUnit 5 BeforeAllCallback that runs warmUp() once per test class before any @tag("ollama-test") test executes. - Add @ExtendWith(OllamaPrewarmExtension::class) to the three test classes that carry @tag("ollama-test") tests: OllamaExecutorConfigTest, OllamaSimpleExecutorTest, KoogRealOllamaToolCallingTest. - Add OllamaModelPrewarmerTest: network-free unit tests for request body shape (model name, num_ctx, num_predict, stream=false) and non-fatal failure behaviour. Closes #815 * fix(tests): remove duplicate warmUp test; use config values instead of hardcoded defaults in body test * detekt + ktlint * fix(#815): harden Ollama prewarmer per PR #816 code review - Rethrow kotlinx.coroutines.CancellationException in warmUp() instead of swallowing it. warmUp runs outside KoogAgentPlanAdapter.plan's cancellation-aware try/catch, so this catch is the only guard against silently absorbing cooperative cancellation of the driver coroutine mid warm-up (mirrors the pattern in KoogAgentPlanAdapter.plan). - buildRequestBody now emits clean single-line JSON; the prior trimMargin() was a no-op (no '|' markers) that shipped literal \n\t inside the body. - Close the JDK HttpClient (AutoCloseable on JDK 21) via client.use { } to release the selector thread / connection pool instead of leaking one per warm-up call. - Extract GENERATE_PATH constant + generateUrl() helper so the hand-rolled /api/generate path is defined once and asserted in tests; KDoc notes why the prewarmer bypasses Koog's OllamaClient (which only exposes /api/chat). - Add OllamaModelPrewarmerHttpTest: network-free MockWebServer test asserting the outbound POST method/path/body and non-fatal 5xx handling. MockWebServer pinned to 5.0.0 because a transitive dep forces okhttp to 5.3.2, and mockwebserver:4.x breaks (NoClassDefFoundError: okhttp3.internal.Util) on the 5.x line. Not tagged ollama-test — runs in plain test, does not affect the real-Ollama ollama-test classes.
) * fix(#812): direction-aware signal display for DynamicRailSemaphore Add isAllowingFor(from, to) to DynamicRailSemaphore, update PerceptionMapping.separatorAspect, SemaphoreReading, DefaultNetworkPerceptionPort, and AnimationStateCapture to use direction-aware signal checks. - DynamicRailSemaphore.isAllowingFor(from, to): new method that returns true only when signal is allowing AND from/to match the canonical forward direction (anti(direction()) → direction()). Prevents consumers from treating another train's forward-direction grant as a proceed for the reverse direction. - PerceptionMapping.separatorAspect: updated to call isAllowingFor(anti(d), d) for both DynamicRailSemaphore and DynamicInOut instead of reading raw signal. - SemaphoreReading: added authorizedFrom/authorizedTo nullable fields (default null) so the LLM dispatcher's all_signal_aspects tool exposes direction context alongside the signal aspect. - DefaultNetworkPerceptionPort.toReading(): populates authorizedFrom/authorizedTo from direction() when signal is allowing; null otherwise. - AnimationStateCapture.captureSignalState(): uses isAllowingFor to only report proceed aspects authorized for the forward direction. - DynamicRailSemaphoreTest: 6 new tests for isAllowingFor covering STOP signal, forward direction, reverse direction, after cancel, null segments, and second orientation. - DefaultNetworkPerceptionPortTest: updated semaphore() helper to mock direction() = Cell.Segment.A and isAllowingFor(any(), any()), updated 6 SemaphoreReading assertions to include authorizedFrom/authorizedTo. * fix(#812): store reservation direction for direction-aware signal display Address PR #813 review findings (C1 + I1/I2/I3). The PR's isAllowingFor guard was a no-op: it queried the static orientation, which always matched the static forward, so separatorAspect/captureSignalState were unchanged and toReading always reported the static forward direction — a lit semaphore cleared for the reverse direction still read "proceed" to a forward-facing observer. A static-orientation query cannot distinguish reserved-forward from reserved-reverse; the semaphore must store the actual direction. DynamicRailSemaphore now records the reservation direction in setUpSpeed (cleared on STOP). authorizedDirection() returns the stored pair, defaulting to the canonical forward when unknown (direct signal writes that bypass setUpSpeed — entry/facing signals cleared outside the reservation flow). isAllowingFor compares against the stored direction, so a forward query against a reverse-reserved proceed aspect correctly returns false. - DynamicRailSemaphore: authorizedFrom/authorizedTo + authorizedDirection(); isAllowingFor rewritten against the stored direction. - PerceptionMapping.separatorAspect: revert to raw signal. This is the train's own next-separator perception (the holder is always authorized), so a guard would wrongly show STOP to a reverse-travelling holder at its own semaphore. - DefaultNetworkPerceptionPort.toReading: report authorizedDirection() so the LLM dispatcher's all_signal_aspects learns the actual reserved direction. - SimulationCellRenderer: direction-aware canvas color — the forward-facing view shows STOP (RED) for a reverse-reserved aspect. AnimationStateCapture KDoc updated (its guard is now meaningful). - Tests: reverse-reservation isAllowingFor/authorizedDirection coverage, new PerceptionMappingTest (commonTest), tightened DefaultNetworkPerceptionPortTest stub to drive toReading via authorizedDirection.
…s rate tracking (#818) * feat(#817): add MeasuringPlanAdapter for fallback metrics and Ollama success rate tracking - Add FallbackReason enum (EMPTY_NO_TOOLS, TIMEOUT, EXCEPTION) - Add PlannerCycleListener interface for per-cycle success/fallback callbacks - Add PlannerMetricsSnapshot immutable data class (Goal 6 pattern) - Add @volatile cycleListener property to KoogAgentPlanAdapter with hooks at all 4 cycle outcome paths - Add MeasuringPlanAdapter decorator: wraps KoogAgentPlanAdapter via Kotlin 'by' delegation, tracks AtomicLong counters per reason, logs structured INFO on each fallback + periodic summary every 10 cycles, exposes getMetricsSnapshot() - Wire MeasuringPlanAdapter in ExampleRegistry for the shuntingLoopAI GUI example - Add MeasuringPlanAdapterTest with 20 unit tests covering all paths and boundary values Closes #817 * docs(design): add spec for dispatcher final metrics log on simulation stop Design for logging a guaranteed final PlannerMetricsSnapshot summary when the shuntingLoopAI example stops, closing the gap where a run ending between periodic MeasuringPlanAdapter checkpoints leaves stale stats. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(dispatcher-agent): correct MeasuringPlanAdapterTest isZero() type mismatch and ktlint formatting isZero() requires Assert<Number>, but Map<FallbackReason, Long>[key] lookups return nullable Long — switched those assertions to isEqualTo(0L). Also normalizes trailing-comma and multi-line parameter formatting across dispatcher-agent planner sources to satisfy ktlintCheck. Verified with a full ./gradlew build integrationTest run (BUILD SUCCESSFUL). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(dispatcher-agent): add MeasuringPlanAdapter.logFinalSummary() Guarantees one final PlannerMetricsSnapshot log line regardless of REPORT_EVERY_N_CYCLES alignment, so a run stopping between periodic checkpoints does not leave stale stats as the last visible entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(desktop-ui): register MeasuringPlanAdapter into context scope The shuntingLoopAI example built its MeasuringPlanAdapter as a local that was discarded after wireDispatcherAgent, leaving nothing outside ExampleRegistry able to reach it. Declaring it into context.scope makes it retrievable via getOrNull() once the run stops. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(desktop-ui): log final dispatcher metrics when simulation stops Frame's STOPPED handler already fires for both natural completion and manual Stop. Resolve MeasuringPlanAdapter from the context's Koin scope there and log its final summary — guarantees the shuntingLoopAI example always ends with an up-to-date fallback/success-rate log line, even if the run stopped between MeasuringPlanAdapter's periodic checkpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * polish: final review fixes for dispatcher final-metrics log feature Whole-branch review polish (no new behavior): capture MeasuringPlanAdapter at RUNNING time and log it last during STOPPED cleanup in Frame.kt so a failure there can never skip the safety-critical decision-hub/SEMI_AUTO detach calls; add a log-text regression test for the "final summary" label; expand MeasuringPlanAdapter KDoc (in-flight-cycle and lifetime-cumulative caveats); fix a stale example in the design spec doc. * docs(plan): add implementation plan for dispatcher final metrics log Companion plan to the already-committed design spec, executed via subagent-driven development across 3 tasks plus a final-review polish pass.
* docs(#848): RuleBasedDispatcher decision-vocabulary audit Durable copy of the SP2c.25 spike findings (also posted live on #822) and the traffic-simulation-expert ruling (also posted live on #848): RuleBasedDispatcher.decide() is type-restricted to ApproveTrain, ReservePath and NoAction only; HoldTrain/SetSignalAspect/SetSwitchPosition are unreachable from any production dispatcher today; the four-action DispatchAction vocabulary in #822 §5.2 suffices, with a discriminant constraint recorded for SP2c.3 on the ReservePath/RequestRoute merge.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



TODO