Skip to content

Goal 10 parent PR#721

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

Goal 10 parent PR#721
bedaHovorka wants to merge 16 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
…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>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
73.7% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Copilot AI and others added 3 commits July 11, 2026 18:50
…/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>
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