Skip to content

Commit 07dc7c9

Browse files
committed
bugfixing
1 parent dba087e commit 07dc7c9

File tree

4 files changed

+123
-32
lines changed

4 files changed

+123
-32
lines changed

docs/reference/api-full.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Quick links:
1212
- [CLI Reference](cli.md)
1313
- [DSL Reference](dsl.md)
1414

15-
Generated from source code on: August 13, 2025 at 21:26 UTC
15+
Generated from source code on: August 14, 2025 at 01:51 UTC
1616

1717
Modules auto-discovered: 68
1818

@@ -1436,7 +1436,7 @@ are present, traversal may not terminate.
14361436
- `add(self, other: 'PathBundle') -> 'PathBundle'` - Concatenate this bundle with another bundle (end-to-start).
14371437
- `contains(self, other: 'PathBundle') -> 'bool'` - Check if this bundle's edge set contains all edges of `other`.
14381438
- `from_path(path: 'Path', resolve_edges: 'bool' = False, graph: 'Optional[StrictMultiDiGraph]' = None, edge_select: 'Optional[EdgeSelect]' = None, cost_attr: 'str' = 'cost', capacity_attr: 'str' = 'capacity') -> 'PathBundle'` - Construct a PathBundle from a single `Path` object.
1439-
- `get_sub_path_bundle(self, new_dst_node: 'NodeID', graph: 'StrictMultiDiGraph', cost_attr: 'str' = 'cost') -> 'PathBundle'` - Create a sub-bundle ending at `new_dst_node` (which must appear in this bundle).
1439+
- `get_sub_path_bundle(self, new_dst_node: 'NodeID', graph: 'StrictMultiDiGraph', cost_attr: 'str' = 'cost') -> 'PathBundle'` - Create a sub-bundle ending at `new_dst_node` with correct minimal cost.
14401440
- `is_disjoint_from(self, other: 'PathBundle') -> 'bool'` - Check if this bundle shares no edges with `other`.
14411441
- `is_subset_of(self, other: 'PathBundle') -> 'bool'` - Check if this bundle's edge set is contained in `other`'s edge set.
14421442
- `resolve_to_paths(self, split_parallel_edges: 'bool' = False) -> 'Iterator[Path]'` - Generate all concrete `Path` objects contained in this PathBundle.

ngraph/paths/bundle.py

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
from collections import deque
12+
from heapq import heappop, heappush
1213
from typing import Dict, Iterator, List, Optional, Set, Tuple
1314

1415
from ngraph.algorithms.base import Cost, EdgeSelect
@@ -77,11 +78,13 @@ def __init__(
7778
while queue:
7879
node = queue.popleft()
7980
self.nodes.add(node)
81+
# Ensure key exists even if `node` has no predecessors.
82+
self.pred.setdefault(node, {})
8083

8184
# Traverse all predecessors of `node`
8285
for prev_node, edges_list in pred[node].items():
8386
# Record these edges in our local `pred` structure
84-
self.pred.setdefault(node, {})[prev_node] = edges_list
87+
self.pred[node][prev_node] = edges_list
8588
# Update the set of all edges seen in this bundle
8689
self.edges.update(edges_list)
8790
# Store the tuple form for quick equality checks on parallel edges
@@ -284,11 +287,14 @@ def get_sub_path_bundle(
284287
graph: StrictMultiDiGraph,
285288
cost_attr: str = "cost",
286289
) -> PathBundle:
287-
"""Create a sub-bundle ending at `new_dst_node` (which must appear in this bundle).
290+
"""Create a sub-bundle ending at `new_dst_node` with correct minimal cost.
288291
289-
This method performs a reverse traversal (BFS) from `new_dst_node` up to
290-
`self.src_node`, collecting edges and recalculating the cost along the way
291-
using the minimal edge attribute found.
292+
The returned bundle contains the predecessor subgraph that reaches from
293+
`self.src_node` to `new_dst_node` using only relations present in this
294+
bundle's `pred`. The `cost` of the returned bundle is recomputed as the
295+
minimal sum of per-hop costs along any valid path from `self.src_node`
296+
to `new_dst_node`, where each hop cost is the minimum of `cost_attr`
297+
across the parallel edges recorded for that hop.
292298
293299
Args:
294300
new_dst_node: The new destination node, which must be present in `pred`.
@@ -305,30 +311,55 @@ def get_sub_path_bundle(
305311
raise ValueError(f"{new_dst_node} not in this PathBundle's pred")
306312

307313
edges_dict = graph.get_edges()
308-
new_pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {self.src_node: {}}
309-
new_cost: float = 0.0
310-
311-
visited: Set[NodeID] = set()
312-
queue: deque[Tuple[float, NodeID]] = deque([(0.0, new_dst_node)])
313-
visited.add(new_dst_node)
314314

315-
while queue:
316-
cost_to_node, node = queue.popleft()
317-
# For each predecessor of `node`, add them to new_pred
315+
# 1) Build the restricted predecessor subgraph reachable from new_dst_node
316+
# back to self.src_node. This preserves all allowed predecessors without
317+
# collapsing to a single path.
318+
new_pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {self.src_node: {}}
319+
visited_build: Set[NodeID] = set([new_dst_node])
320+
queue_build: deque[NodeID] = deque([new_dst_node])
321+
while queue_build:
322+
node = queue_build.popleft()
318323
for prev_node, edges_list in self.pred[node].items():
319324
new_pred.setdefault(node, {})[prev_node] = edges_list
320-
# Recompute the cost increment of traveling from prev_node to node
321-
cost_increment = min(
322-
edges_dict[eid][3][cost_attr] for eid in edges_list
323-
)
324-
updated_cost = cost_to_node + cost_increment
325-
326-
# Enqueue predecessor if not source and not yet visited
327-
if prev_node != self.src_node and prev_node not in visited:
328-
visited.add(prev_node)
329-
queue.append((updated_cost, prev_node))
330-
else:
331-
# Once we reach the src_node, record the final cost
332-
new_cost = updated_cost
325+
if prev_node != self.src_node and prev_node not in visited_build:
326+
visited_build.add(prev_node)
327+
queue_build.append(prev_node)
328+
329+
# 2) Compute minimal cost from self.src_node to new_dst_node over new_pred
330+
# using Dijkstra on the reversed edges (from dst backwards to src).
331+
def hop_cost(u: NodeID, v: NodeID) -> float:
332+
# cost to go from u<-v (i.e., v -> u in forward direction)
333+
edges_list = new_pred[u][v]
334+
return float(min(edges_dict[eid][3][cost_attr] for eid in edges_list))
335+
336+
# Trivial case: src == dst
337+
if new_dst_node == self.src_node:
338+
return PathBundle(self.src_node, new_dst_node, new_pred, 0.0)
339+
340+
dist: Dict[NodeID, float] = {new_dst_node: 0.0}
341+
heap: List[Tuple[float, NodeID]] = [(0.0, new_dst_node)]
342+
best_cost: float = float("inf")
343+
344+
while heap:
345+
cost_to_node, node = heappop(heap)
346+
if cost_to_node > dist.get(node, float("inf")):
347+
continue
348+
if node == self.src_node:
349+
best_cost = cost_to_node
350+
break
351+
# Relax predecessors of `node` (reverse traversal)
352+
for prev_node in new_pred.get(node, {}):
353+
c = cost_to_node + hop_cost(node, prev_node)
354+
if c < dist.get(prev_node, float("inf")):
355+
dist[prev_node] = c
356+
heappush(heap, (c, prev_node))
357+
358+
# If src_node was not reached, this subgraph does not connect src->new_dst.
359+
# Treat as an error to avoid silent mis-reporting.
360+
if best_cost == float("inf"):
361+
raise ValueError(
362+
f"No path from '{self.src_node}' to '{new_dst_node}' within this PathBundle."
363+
)
333364

334-
return PathBundle(self.src_node, new_dst_node, new_pred, new_cost)
365+
return PathBundle(self.src_node, new_dst_node, new_pred, float(best_cost))

tests/paths/test_bundle.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from typing import List, Set
1+
from typing import Dict, List, Set
22

33
import pytest
44

55
from ngraph.algorithms.base import EdgeSelect
6-
from ngraph.graph.strict_multidigraph import StrictMultiDiGraph
6+
from ngraph.graph.strict_multidigraph import EdgeID, NodeID, StrictMultiDiGraph
77
from ngraph.paths.bundle import Path, PathBundle
88

99

@@ -148,6 +148,51 @@ def test_get_sub_bundle_2(self, triangle1):
148148
}
149149
assert sub_bundle.cost == 0
150150

151+
def test_get_sub_bundle_min_cost_across_alternatives(self):
152+
g = StrictMultiDiGraph()
153+
for n in ("A", "B", "C", "D"):
154+
g.add_node(n)
155+
156+
# Two routes A->D: A->C->D cost 2 (1+1), A->B->D cost 20 (10+10)
157+
e_ac = g.add_edge("A", "C", cost=1)
158+
e_cd = g.add_edge("C", "D", cost=1)
159+
e_ab = g.add_edge("A", "B", cost=10)
160+
e_bd = g.add_edge("B", "D", cost=10)
161+
162+
pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {
163+
"A": {},
164+
"C": {"A": [e_ac]},
165+
"B": {"A": [e_ab]},
166+
"D": {"C": [e_cd], "B": [e_bd]},
167+
}
168+
169+
bundle = PathBundle("A", "D", pred, cost=2)
170+
sub = bundle.get_sub_path_bundle("D", g, cost_attr="cost")
171+
assert sub.cost == 2
172+
173+
def test_get_sub_bundle_src_equals_dst_zero_cost(self):
174+
g = StrictMultiDiGraph()
175+
g.add_node("A")
176+
pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {"A": {}}
177+
bundle = PathBundle("A", "A", pred, cost=0)
178+
sub = bundle.get_sub_path_bundle("A", g)
179+
assert sub.cost == 0
180+
181+
def test_get_sub_bundle_raises_when_src_unreachable_in_subgraph(self):
182+
g = StrictMultiDiGraph()
183+
for n in ("A", "B", "C"):
184+
g.add_node(n)
185+
# pred missing any chain from A to C (only B->C exists)
186+
e_bc = g.add_edge("B", "C", cost=1)
187+
pred: Dict[NodeID, Dict[NodeID, List[EdgeID]]] = {
188+
"A": {},
189+
"B": {},
190+
"C": {"B": [e_bc]},
191+
}
192+
bundle = PathBundle("A", "C", pred, cost=0)
193+
with pytest.raises(ValueError, match="No path from 'A' to 'C'"):
194+
_ = bundle.get_sub_path_bundle("C", g)
195+
151196
def test_add_method(self):
152197
"""Test concatenating two PathBundles with matching src/dst."""
153198
pb1 = PathBundle(

tests/paths/test_path.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,18 @@ def test_get_sub_path_empty_parallel_edges():
165165
sub = p.get_sub_path("N2", g)
166166
assert sub.cost == 10
167167
assert len(sub) == 2
168+
169+
170+
def test_get_sub_path_uses_min_cost_among_parallel_edges():
171+
g = StrictMultiDiGraph()
172+
for n in ("A", "B", "C"):
173+
g.add_node(n)
174+
175+
e1 = g.add_edge("A", "B", cost=5)
176+
e2 = g.add_edge("A", "B", cost=7)
177+
e3 = g.add_edge("B", "C", cost=1)
178+
179+
path_tuple: PathTuple = (("A", (e1, e2)), ("B", (e3,)), ("C", ()))
180+
p = Path(path_tuple, cost=0)
181+
sub = p.get_sub_path("B", g, cost_attr="cost")
182+
assert sub.cost == 5

0 commit comments

Comments
 (0)