Skip to content

Now threshold in shortest paths is applied on the whole path, not ste…#438

Open
Grufoony wants to merge 1 commit intomainfrom
fixThresholdInPaths
Open

Now threshold in shortest paths is applied on the whole path, not ste…#438
Grufoony wants to merge 1 commit intomainfrom
fixThresholdInPaths

Conversation

@Grufoony
Copy link
Copy Markdown
Collaborator

…p-by-step

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 14, 2026

Codecov Report

❌ Patch coverage is 86.32479% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.09%. Comparing base (e34a5ca) to head (f1e9f1a).

Files with missing lines Patch % Lines
src/dsf/mobility/RoadNetwork.hpp 83.95% 13 Missing ⚠️
test/mobility/Test_graph.cpp 91.42% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #438      +/-   ##
==========================================
- Coverage   87.26%   87.09%   -0.17%     
==========================================
  Files          52       52              
  Lines        6527     6558      +31     
  Branches      732      732              
==========================================
+ Hits         5696     5712      +16     
- Misses        809      822      +13     
- Partials       22       24       +2     
Flag Coverage Δ
unittests 87.09% <86.32%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

graph.addStreets(s1, s2, s3, s4, s5);

auto const& pathMap =
graph.allPathsTo(4, [](auto const& pEdge) { return pEdge.length(); }, 0.7);

auto const& pathMap =
graph.allPathsTo(4, [](auto const& pEdge) { return pEdge.length(); }, 0.7);
CHECK_EQ(pathMap.at(0).size(), 2);
auto const& pathMap =
graph.allPathsTo(4, [](auto const& pEdge) { return pEdge.length(); }, 0.7);
CHECK_EQ(pathMap.at(0).size(), 2);
}
graph.allPathsTo(4, [](auto const& pEdge) { return pEdge.length(); }, 0.7);
CHECK_EQ(pathMap.at(0).size(), 2);
}

// Best path: 0 -> 1 -> 2 -> 5 = 10.0
// Alternative: 0 -> 1 -> 3 -> 5 = 10.89 (within 10%)
// Over-budget path: 0 -> 1 -> 3 -> 4 -> 5 = 11.39 (must be excluded)
Street s01(0, std::make_pair(0, 1), 1.0);
path[3] == 5) {
foundBestPath = true;
}
if (path.size() == 4 && path[0] == 0 && path[1] == 1 && path[2] == 3 &&
path[3] == 5) {
foundValidAlternative = true;
}
if (path.size() == 5 && path[0] == 0 && path[1] == 1 && path[2] == 3 &&
path[3] == 4 && path[4] == 5) {
foundOverBudgetPath = true;
}
}

CHECK(foundBestPath);
CHECK(foundValidAlternative);
CHECK_FALSE(foundOverBudgetPath);
CHECK(foundBestPath);
CHECK(foundValidAlternative);
CHECK_FALSE(foundOverBudgetPath);
}
@Grufoony Grufoony marked this pull request as ready for review April 14, 2026 12:36
Copilot AI review requested due to automatic review settings April 14, 2026 12:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the road-network pathfinding “threshold” semantics so the tolerance is applied against the total source→target (or node→target) path cost, rather than being applied step-by-step at each hop. This affects both C++ pathfinding behavior and its test coverage, plus a small Python-docstring clarification.

Changes:

  • Refactors RoadNetwork::allPathsTo() / RoadNetwork::shortestPath() to compute strict distances-to-target first, then filter next-hops using a full-path budget rule.
  • Adds new tests covering deeper alternative paths and ensuring over-budget full paths are excluded.
  • Updates FirstOrderDynamics test usage and Python binding docstrings to match the clarified threshold meaning.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/dsf/mobility/RoadNetwork.hpp Refactors pathfinding to enforce full-path budget threshold via distances-to-target and new filtering/recursion logic.
test/mobility/Test_graph.cpp Adds new unit tests for full-path threshold behavior (deep alternatives + budget exclusion).
test/mobility/Test_dynamics.cpp Adjusts weight-function threshold usage in a dynamics test to avoid unintended alternatives under new semantics.
src/dsf/bindings.cpp Clarifies Python docstring: threshold is relative tolerance on full source→target cost.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +518 to +521
// Keep recursion acyclic and guaranteed to converge toward target.
if (nextDistToTarget + detail::kPathBudgetEpsilon >= currentDistToTarget) {
continue;
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both allPathsTo() and shortestPath() drop any hop where distToTarget[next] >= distToTarget[current] (the “acyclic / converge” check). That enforces a strictly distance-decreasing property, which is stronger than the documented “within threshold budget” semantics and will exclude valid within-budget detours that temporarily move to a node with larger distToTarget.

If this monotonic constraint is intentional (e.g., to keep PathCollection::explode finite), it should be documented on the APIs; otherwise consider a different cycle-prevention mechanism (e.g., path-local visited set during expansion).

Copilot uses AI. Check for mistakes.
Comment on lines +497 to +542
std::function<bool(Id, double)> buildPathsWithinBudget;
buildPathsWithinBudget = [&](Id const currentNode, double const prefixCost) -> bool {
if (currentNode == targetId) {
return true;
}

while (!queue.empty()) {
Id current = queue.front();
queue.pop();
auto const currentDistToTarget = distToTarget.at(currentNode);
bool hasValidPathToTarget{false};

if (current == targetId) {
continue;
}
for (auto const& outEdgeId : this->node(currentNode).outgoingEdges()) {
auto const& outEdge = this->edge(outEdgeId);
if (outEdge.roadStatus() == RoadStatus::CLOSED) {
continue;
}

auto const nextNodeId = outEdge.target();
auto const nextDistToTarget = distToTarget.at(nextNodeId);
if (nextDistToTarget == std::numeric_limits<double>::infinity()) {
continue;
}

// Add this node's next hops to the result if they exist
if (nextHopsToTarget.contains(current) && !nextHopsToTarget[current].empty()) {
result[current] = nextHopsToTarget[current];
// Keep recursion acyclic and guaranteed to converge toward target.
if (nextDistToTarget + detail::kPathBudgetEpsilon >= currentDistToTarget) {
continue;
}

auto const edgeWeight = f(outEdge);
auto const newPrefixCost = prefixCost + edgeWeight;
auto const optimisticCost = newPrefixCost + nextDistToTarget;
if (optimisticCost > sourceBudget + detail::kPathBudgetEpsilon) {
continue;
}

// Add next hops to the queue if not already visited
for (Id nextHop : nextHopsToTarget[current]) {
if (!nodesOnPath.contains(nextHop)) {
nodesOnPath.insert(nextHop);
queue.push(nextHop);
if (buildPathsWithinBudget(nextNodeId, newPrefixCost)) {
auto& hops = result[currentNode];
if (std::find(hops.begin(), hops.end(), nextNodeId) == hops.end()) {
hops.push_back(nextNodeId);
}
hasValidPathToTarget = true;
}
}
}

return hasValidPathToTarget;
};

(void)buildPathsWithinBudget(sourceId, 0.0);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shortestPath() uses a recursive lambda (buildPathsWithinBudget) to traverse the graph. On large/long road networks this can create very deep recursion (depth ~= path length), which risks stack overflow and is hard to debug operationally.

Consider switching to an explicit stack/iterative DFS over the dist-decreasing DAG (or otherwise bounding recursion depth).

Copilot uses AI. Check for mistakes.
RoadNetwork graph{};
graph.addStreets(s1, s2, s3, s4, s5);

auto const& pathMap =
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allPathsTo() returns by value, but this test binds it to auto const& (a reference to a temporary). While lifetime-extension makes this safe, it’s easy to misread and can lead to accidental dangling refs if the code is refactored.

Prefer storing the result by value (auto const pathMap = ...) here.

Suggested change
auto const& pathMap =
auto const pathMap =

Copilot uses AI. Check for mistakes.
Comment on lines +497 to +535
std::function<bool(Id, double)> buildPathsWithinBudget;
buildPathsWithinBudget = [&](Id const currentNode, double const prefixCost) -> bool {
if (currentNode == targetId) {
return true;
}

while (!queue.empty()) {
Id current = queue.front();
queue.pop();
auto const currentDistToTarget = distToTarget.at(currentNode);
bool hasValidPathToTarget{false};

if (current == targetId) {
continue;
}
for (auto const& outEdgeId : this->node(currentNode).outgoingEdges()) {
auto const& outEdge = this->edge(outEdgeId);
if (outEdge.roadStatus() == RoadStatus::CLOSED) {
continue;
}

auto const nextNodeId = outEdge.target();
auto const nextDistToTarget = distToTarget.at(nextNodeId);
if (nextDistToTarget == std::numeric_limits<double>::infinity()) {
continue;
}

// Add this node's next hops to the result if they exist
if (nextHopsToTarget.contains(current) && !nextHopsToTarget[current].empty()) {
result[current] = nextHopsToTarget[current];
// Keep recursion acyclic and guaranteed to converge toward target.
if (nextDistToTarget + detail::kPathBudgetEpsilon >= currentDistToTarget) {
continue;
}

auto const edgeWeight = f(outEdge);
auto const newPrefixCost = prefixCost + edgeWeight;
auto const optimisticCost = newPrefixCost + nextDistToTarget;
if (optimisticCost > sourceBudget + detail::kPathBudgetEpsilon) {
continue;
}

// Add next hops to the queue if not already visited
for (Id nextHop : nextHopsToTarget[current]) {
if (!nodesOnPath.contains(nextHop)) {
nodesOnPath.insert(nextHop);
queue.push(nextHop);
if (buildPathsWithinBudget(nextNodeId, newPrefixCost)) {
auto& hops = result[currentNode];
if (std::find(hops.begin(), hops.end(), nextNodeId) == hops.end()) {
hops.push_back(nextNodeId);
}
hasValidPathToTarget = true;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shortestPath() builds a node->nextHops map while the feasibility check depends on prefixCost (how you reached the node). Because result[currentNode] is shared across all recursive visits, next-hops that are only valid for a cheaper prefix can remain in the map and later be combined (via PathCollection::explode) with a more expensive prefix path to produce a source→target path that exceeds sourceBudget.

To make the returned PathCollection sound, consider constraining stored edges so they’re valid for any reachable prefix in the returned graph (e.g., restrict to edges consistent with a distFromSource DAG, or redesign to return explicit paths / include prefix-state).

Copilot uses AI. Check for mistakes.
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.

3 participants