diff --git a/src/domain/AlternativeRecommendationService.cpp b/src/domain/AlternativeRecommendationService.cpp index 4762fb2..d8a4f90 100644 --- a/src/domain/AlternativeRecommendationService.cpp +++ b/src/domain/AlternativeRecommendationService.cpp @@ -505,9 +505,30 @@ void finalizeDiffKeys(const AlternativeRecommendationInput& request, ScenarioDra } } -bool sourceHasConnectionBlock(const ScenarioDraft& scenario, const std::string& connectionId) { +bool connectionBlockConstrainsReachability( + const ConnectionBlockDraft& block, + const std::optional& elapsedSeconds) { + if (block.connectionId.empty()) { + return false; + } + if (elapsedSeconds.has_value()) { + return connectionBlockActiveAt(block, *elapsedSeconds); + } + if (block.intervals.empty()) { + return true; + } + return std::any_of(block.intervals.begin(), block.intervals.end(), [](const auto& interval) { + return interval.startSeconds <= 0.0 && interval.endSeconds <= interval.startSeconds; + }); +} + +bool sourceHasReachabilityConnectionBlock( + const ScenarioDraft& scenario, + const std::string& connectionId, + const std::optional& elapsedSeconds) { return std::any_of(scenario.control.connectionBlocks.begin(), scenario.control.connectionBlocks.end(), [&](const auto& block) { - return block.connectionId == connectionId; + return block.connectionId == connectionId + && connectionBlockConstrainsReachability(block, elapsedSeconds); }); } @@ -876,9 +897,10 @@ bool connectionTraversableFromZone( const ScenarioDraft& scenario, const Connection2D& connection, const std::string& zoneId, - std::string& nextZoneId) { + std::string& nextZoneId, + const std::optional& elapsedSeconds) { if (connection.directionality == TravelDirection::Closed - || sourceHasConnectionBlock(scenario, connection.id)) { + || sourceHasReachabilityConnectionBlock(scenario, connection.id, elapsedSeconds)) { return false; } if (connection.fromZoneId == zoneId && connection.directionality != TravelDirection::ReverseOnly) { @@ -896,7 +918,8 @@ bool routeExistsBetweenZones( const FacilityLayout2D& layout, const ScenarioDraft& scenario, const std::string& startZoneId, - const std::string& targetZoneId) { + const std::string& targetZoneId, + const std::optional& elapsedSeconds = std::nullopt) { if (startZoneId.empty() || targetZoneId.empty()) { return false; } @@ -910,7 +933,7 @@ bool routeExistsBetweenZones( const auto zoneId = pending[index]; for (const auto& connection : layout.connections) { std::string nextZoneId; - if (!connectionTraversableFromZone(scenario, connection, zoneId, nextZoneId) + if (!connectionTraversableFromZone(scenario, connection, zoneId, nextZoneId, elapsedSeconds) || visited.find(nextZoneId) != visited.end()) { continue; } @@ -926,7 +949,8 @@ bool routeExistsBetweenZones( bool exitReachableFromScenarioSources( const AlternativeRecommendationInput& request, - const std::string& exitZoneId) { + const std::string& exitZoneId, + const std::optional& elapsedSeconds = std::nullopt) { if (exitZoneId.empty()) { return false; } @@ -939,7 +963,7 @@ bool exitReachableFromScenarioSources( return false; } return std::any_of(zones.begin(), zones.end(), [&](const auto& zoneId) { - return routeExistsBetweenZones(request.layout, request.sourceScenario, zoneId, exitZoneId); + return routeExistsBetweenZones(request.layout, request.sourceScenario, zoneId, exitZoneId, elapsedSeconds); }); } @@ -979,12 +1003,13 @@ bool guidanceDetourAcceptable( std::optional leastUsedReachableExit( const AlternativeRecommendationInput& request, - const std::vector& excludedExitZoneIds = {}) { + const std::vector& excludedExitZoneIds = {}, + const std::optional& elapsedSeconds = std::nullopt) { const auto candidates = exitUsageCandidates(request); std::optional best; for (const auto& usage : candidates) { if (containsString(excludedExitZoneIds, usage.exitZoneId) - || !exitReachableFromScenarioSources(request, usage.exitZoneId)) { + || !exitReachableFromScenarioSources(request, usage.exitZoneId, elapsedSeconds)) { continue; } if (!best.has_value() @@ -996,6 +1021,25 @@ std::optional leastUsedReachableExit( return best; } +std::size_t reachableExitCandidateCount( + const AlternativeRecommendationInput& request, + const std::optional& elapsedSeconds = std::nullopt) { + std::unordered_set reachableExitZoneIds; + for (const auto& usage : exitUsageCandidates(request)) { + if (!usage.exitZoneId.empty() + && exitReachableFromScenarioSources(request, usage.exitZoneId, elapsedSeconds)) { + reachableExitZoneIds.insert(usage.exitZoneId); + } + } + return reachableExitZoneIds.size(); +} + +bool hasAlternativeReachableExit( + const AlternativeRecommendationInput& request, + const std::optional& elapsedSeconds = std::nullopt) { + return reachableExitCandidateCount(request, elapsedSeconds) >= 2; +} + bool bottleneckLessSevere(const ScenarioBottleneckMetric& lhs, const ScenarioBottleneckMetric& rhs) { if (lhs.stalledAgentCount == rhs.stalledAgentCount) { return lhs.nearbyAgentCount < rhs.nearbyAgentCount; @@ -1410,10 +1454,14 @@ std::optional makeBottleneckGuidanceCandidat if (!bottleneck.has_value() || bottleneck->connectionId.empty()) { return std::nullopt; } + if (!hasAlternativeReachableExit(request, bottleneck->detectedAtSeconds)) { + return std::nullopt; + } const auto adjacentExitZoneIds = adjacentExitZoneIdsForConnection(request, bottleneck->connectionId); const auto targetExit = leastUsedReachableExit( request, - adjacentExitZoneIds); + adjacentExitZoneIds, + bottleneck->detectedAtSeconds); if (!targetExit.has_value() || targetExit->exitZoneId.empty()) { return std::nullopt; } @@ -1487,7 +1535,7 @@ std::optional makeBottleneckGuidanceCandidat std::optional makeExitBalancingCandidate( const AlternativeRecommendationInput& request, const RecommendationContext& context) { - if (exitUsageCandidates(request).size() < 2) { + if (!hasAlternativeReachableExit(request)) { return std::nullopt; } const auto low = context.leastUsedReachableExit; @@ -1561,7 +1609,7 @@ std::optional makePressureHotspotCandidate( if (!hasPressureSignal(request)) { return std::nullopt; } - if (exitUsageCandidates(request).size() < 2) { + if (!hasAlternativeReachableExit(request)) { return std::nullopt; } @@ -1670,7 +1718,7 @@ std::optional makeCrossFlowCandidate( } const auto severity = signal->severity; - if (!context.exitUsageImbalanced && exitUsageCandidates(request).size() >= 2) { + if (!context.exitUsageImbalanced && hasAlternativeReachableExit(request)) { const auto targetExit = context.leastUsedReachableExit; if (targetExit.has_value() && context.mostUsedExit.has_value() diff --git a/tests/AlternativeRecommendationServiceTests.cpp b/tests/AlternativeRecommendationServiceTests.cpp index c0c45c9..868583d 100644 --- a/tests/AlternativeRecommendationServiceTests.cpp +++ b/tests/AlternativeRecommendationServiceTests.cpp @@ -512,6 +512,59 @@ SC_TEST(AlternativeRecommendationService_addsBottleneckGuidanceAtExit) { SC_EXPECT_TRUE(containsDiffKey(it->recommendedScenario, "control.routeGuidances")); } +SC_TEST(AlternativeRecommendationService_skipsBottleneckGuidanceForSingleExitLayout) { + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeSingleExitRecommendationLayout(), + .sourceScenario = makeScenario(), + .risk = risk, + .artifacts = makeSingleExitUsageArtifacts("exit-main", "Main Exit", 20, 1.0), + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BottleneckBypassGuidance)); + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::ExitUsageBalancing)); +} + +SC_TEST(AlternativeRecommendationService_keepsInactiveTimedBlockReachableForBottleneckGuidance) { + auto scenario = makeScenario(); + scenario.control.connectionBlocks.push_back({ + .id = "block-main-early", + .connectionId = "door-main", + .intervals = {{.startSeconds = 0.0, .endSeconds = 5.0}}, + }); + + ScenarioRiskSnapshot risk; + risk.bottlenecks.push_back({ + .connectionId = "door-main", + .nearbyAgentCount = 8, + .stalledAgentCount = 5, + .detectedAtSeconds = 20.0, + }); + + const AlternativeRecommendationService service; + const auto result = service.recommend({ + .layout = makeRecommendationLayout(), + .sourceScenario = scenario, + .risk = risk, + .artifacts = makeExitUsageArtifacts(), + }); + + SC_EXPECT_TRUE(!hasCandidateKind(result, AlternativeRecommendationKind::BlockedConnectionRelief)); + const auto it = std::find_if(result.candidates.begin(), result.candidates.end(), [](const auto& candidate) { + return candidate.kind == AlternativeRecommendationKind::BottleneckBypassGuidance; + }); + SC_EXPECT_TRUE(it != result.candidates.end()); + SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.size(), std::size_t{1}); + SC_EXPECT_EQ(it->recommendedScenario.control.routeGuidances.front().guidedExitZoneId, std::string{"exit-east"}); +} + SC_TEST(AlternativeRecommendationService_installsCorridorBottleneckGuidanceAtExitOnly) { ScenarioRiskSnapshot risk; risk.bottlenecks.push_back({ @@ -1007,11 +1060,25 @@ SC_TEST(AlternativeRecommendationService_sortsBlockedReliefBeforeGuidance) { .nearbyAgentCount = 8, .stalledAgentCount = 5, }); + auto layout = makeRecommendationLayout(); + layout.zones.push_back({ + .id = "exit-west", + .floorId = "L1", + .kind = ZoneKind::Exit, + .label = "West Exit", + }); + layout.connections.push_back({ + .id = "door-west", + .floorId = "L1", + .kind = ConnectionKind::Exit, + .fromZoneId = "room-a", + .toZoneId = "exit-west", + }); const auto artifacts = makeExitUsageArtifacts(); const AlternativeRecommendationService service; const auto result = service.recommend({ - .layout = makeRecommendationLayout(), + .layout = layout, .sourceScenario = scenario, .risk = risk, .artifacts = artifacts,