Skip to content

Goal 10 parent PR - #721

Draft
bedaHovorka wants to merge 56 commits into
developfrom
goal-10
Draft

Goal 10 parent PR#721
bedaHovorka wants to merge 56 commits into
developfrom
goal-10

Conversation

@bedaHovorka

Copy link
Copy Markdown
Owner

TODO

bedaHovorka

This comment was marked as off-topic.

@bedaHovorka
bedaHovorka force-pushed the goal-10 branch 2 times, most recently from 5df3642 to 03f884f Compare July 7, 2026 16:52
@bedaHovorka
bedaHovorka marked this pull request as ready for review July 7, 2026 17:01
@bedaHovorka
bedaHovorka marked this pull request as draft July 7, 2026 17:01
@bedaHovorka bedaHovorka linked an issue Jul 7, 2026 that may be closed by this pull request
8 tasks
Copilot AI and others added 11 commits July 11, 2026 05:51
* 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
@bedaHovorka bedaHovorka self-assigned this Jul 11, 2026
Copilot AI and others added 10 commits July 11, 2026 18:06
…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.
Copilot AI and others added 13 commits July 19, 2026 07:28
* 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.
Copilot AI and others added 15 commits July 24, 2026 04:51
…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.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Goal 10: AI Dispatcher Routing

2 participants