auto howard(Mapping& dist,
Callable get_weight);
```
**Independent params**: `Mapping`
and `Callable` are free variables.
C++ deduces types per-call.
]
---
### ๐ Type System Comparison
.pull-left[
**C++ Templates** โก
| Feature | Behavior |
|---------|----------|
| TypeParams | **Independent** |
| Constraint | None (duck typing) |
| Returns | `auto` deduction |
| Conversion | **Implicit** (widening + truncation) |
| Error | Compile-time (if `+` fails) |
]
.pull-right[
**Python TypeVars** ๐
| Feature | Behavior |
|---------|----------|
| TypeParams | **Shared** via TypeVar |
| Constraint | Value restriction |
| Returns | Must match TypeVar |
| Conversion | **Explicit** only |
| Error | mypy type mismatch |
]
**The C++ template is the reference design.** Python's TypeVar was too strict
for this use case โ it forced an artificial constraint that doesn't exist in
the original algorithm.
---
### ๐ก The Realization
.mermaid[
graph TD
subgraph "C++ Templates โก"
M1["Mapping: int dist"] --> PLUS["+ operator"]
C1["Callable: double weight"] --> PLUS
PLUS --> AUTO["auto = double"]
AUTO --> ASSIGN["dist[v] = double โ int"]
end
subgraph "Python TypeVars ๐"
M2["dist: int"] --> TV["Domain = int!"]
C2["weight: float"] -.->|can't match| TV
TV --> ERROR["โ Type error"]
end
style M1 fill:#4caf50
style C1 fill:#4caf50
style PLUS fill:#ff9800
style AUTO fill:#2196f3
style ASSIGN fill:#9c27b0
style M2 fill:#f44336
style C2 fill:#f44336
style TV fill:#ff9800
style ERROR fill:#f44336
]
**Three differences stack up**:
1. **Independent params** (C++) vs **shared TypeVar** (Python)
2. **`auto` deduction** (C++) vs **fixed return type** (Python)
3. **Implicit truncation** (C++) vs **no implicit conversion** (Python)
---
class: nord-light, middle, center
## ๐ ๏ธ Part 3: The Fix
---
### ๐ง Fix 1: Decouple Types
The root fix goes in the **digraphx library** (`neg_cycle.py`):
```python
# Before (overly constrained โ C++ would never enforce this)
def relax(self, dist: MutableMapping[Node, Domain],
get_weight: Callable[[Arc], Domain]) -> bool:
# After (decoupled โ matches C++ independent params)
def relax(self, dist: MutableMapping[Node, Domain],
get_weight: Callable[[Arc], Any]) -> bool: # โ Any!
```
Applied to all 5 methods: `relax`, `is_negative`, `howard` in `neg_cycle.py`,
and `relax_pred`, `relax_succ`, `is_negative`, `howard_pred`, `howard_succ`
in `neg_cycle_q.py`.
`Any` tells mypy: "the types are independent" โ exactly what C++ templates say.
---
### ๐ง Fix 2: Explicit Truncation
C++ implicitly truncates `double โ int` on assignment. Python needs it explicit:
```python
tmp = dist[u_node] + get_weight(edge)
# C++: int + double = double โ stored as int (truncates)
if isinstance(dist[u_node], int) and isinstance(tmp, float):
tmp = int(floor(tmp)) # โ explicit C++ truncation behavior
distance = tmp
```
.mermaid[
flowchart LR
A["dist[u] = int"] --> B{tmp is float?}
B -->|Yes| C["tmp = int(floor(tmp))"]
B -->|No| D["keep unchanged"]
C --> E["dist[v] = tmp (int)"]
D --> E
style A fill:#4caf50
style B fill:#ff9800
style C fill:#2196f3
]
---
### ๐ง Fix 3: Float-Safe Test Literals
Tests that typed `Dict[str, float]` but used `0` literals were actually `int`:
```python
# Triggers floor: isinstance(0, int) โ True!
dist: Dict[str, float] = {"v1": 0, "v2": 0, "v3": 0}
# Skips floor: isinstance(0.0, int) โ False!
dist: Dict[str, float] = {"v1": 0.0, "v2": 0.0, "v3": 0.0}
```
**Changed in 4 netoptim + 5 digraphx test files.** Literals only โ no logic changes.
---
### โ
Results
```bash
$ mypy src/ tests/
Success: no issues found in 14 source files โจ
$ pytest tests/ # netoptim
27 passed in 9.24s โ
$ pytest D:\github\py\digraphx\tests\ # digraphx
122 passed in 42.16s โ
```
**149 tests, 0 failures, 0 mypy errors** ๐
**Zero changes to netoptim source code** โ only test files and the
digraphx library were modified.
---
class: nord-light, middle, center
## ๐ฌ Part 4: Deeper Dive
---
### ๐ Beyond TypeVars: Graph Container Types
The Python/C++ divergence isn't limited to the `Domain` type โ it extends
to **how graphs are represented**:
.pull-left[
**Question**: Can the algorithm handle **multiple edges** between the
same pair of nodes (parallel edges)?
C++ **yes**. Python **now yes** (after fix).
]
.pull-right[
.mermaid[
graph LR
A["Node 2"] -->|"edge A"| B["Node 0"]
A -->|"edge B"| B
style A fill:#ff9800
style B fill:#2196f3
]
]
---
### ๐ฆ Container Type Comparison
.pull-left[
**Python** ๐ โ `Mapping[Node, Mapping[Node, Arc]]`
```python
graph = {
0: {1: 5.0, 2: 3.0},
2: {1: 1.0, 0: 2.0} # โ one edge 2โ0 only!
}
```
Inner dict: **unique keys** โ one edge per target node.
Iteration:
```python
for v_node, edge in neighbors.items():
# v_node is unique โ can't have parallel edges
```
]
.pull-right[
**C++** โก โ any container-of-containers
```cpp
list>>> graph{
{2, {{1, 1.0}, {0, 2.0}, {0, 1.0}}}
// two edges 2โ0! ^^^^^ ^^^^^
};
```
Inner list: **duplicate keys allowed** โ parallel edges!
Iteration:
```cpp
for (const auto& [vtx, edge] : neighbors) {
// vtx CAN repeat โ multiple edges processed
}
```
]
---
### ๐งช C++ Multi-Edge Tests
The C++ suite explicitly tests parallel edges with two container types:
```cpp
// 1. list-of-lists
list>>> graph{
{2, {{1, 1.0}, {0, 2.0}, {0, 1.0}}} // two edges 2โ0
};
// 2. unordered_multimap (designed for multi-edges!)
list>> graph{
{2, {{1, 4}, {0, 5}, {0, 6}}} // two edges 2โ0
};
NegCycleFinder ncf(graph); // both work โ
```
The algorithm processes **every edge independently** โ duplicates don't matter.
---
### ๐ Python's "Multi-Edge" Test
Python has a test named `test_multiple_edges_same_nodes` โ but it's misleading:
```python
def test_multiple_edges_same_nodes():
graph = DiGraphAdapter()
graph.add_edge(0, 1, weight=5)
graph.add_edge(0, 1, weight=-5) # โ REPLACES previous!
finder = NegCycleFinder(graph)
cycles = list(finder.howard(dist, lambda edge: edge.get("weight", 1)))
assert len(cycles) == 0 # Single edge can't form cycle
```
The comment confirms: **"NetworkX typically replaces parallel edges."**
This was a "last write wins" replacement test โ not a true multi-edge test. โ
**Now fixed**: Python handles real multi-edge graphs via the `_view_items` helper.
---
### ๐ ๏ธ The Fix: `_view_items` Helper
Added a `_view_items()` function mirroring C++ `if constexpr` dispatch:
```python
def _view_items(container):
"""Get a pair-iterable view. Mirrors C++ `_view_items`."""
if isinstance(container, dict):
return container.items() # dict โ (key, value) pairs
return container # list โ direct iteration yields pairs
```
**Constructor change**:
```python
# Before (dict-only):
def __init__(self, digraph: Mapping[Node, Mapping[Node, Arc]]):
# After (dict or list):
def __init__(self, digraph: Mapping[Node, Iterable[Tuple[Node, Arc]]]):
```
**4 new tests added** โ covering list-of-lists with parallel edges, negative
cycles via parallel edges, `NegCycleFinderQ` variant, and dict backward compat.
---
This reveals a deeper difference in type system philosophy:
.font-sm.mb-xs[
| Aspect | C++ โก | Python ๐ |
|--------|---------|-----------|
| Container contract | **Structural**: any iterable works | **Nominal**: `Mapping` protocol required |
| Helper dispatch | `if constexpr` (`_view_items`, `_get_key`, `_get_val`) | `isinstance` dispatch (`_view_items`) |
| Edge multiplicity | **Naturally supported** via `list`/`multimap` | **Now supported** via `_view_items` + `Iterable` type |
| Type flexibility | Compile-time polymorphism | Runtime duck typing, static types adapted |
]
**The irony**: Python is famous for duck typing at runtime, but the **static
type annotations** originally (`Mapping[Node, Mapping[Node, Arc]]`) were more
restrictive than C++ templates. Now fixed with `Iterable[Tuple[Node, Arc]]`.
---
### ๐ฎ What's Still Missing
Comparing the full API surface reveals gaps in the Python port:
.mermaid[
graph TD
subgraph "C++ NegCycleFinder โก"
H["howard()"] --> G["Generator<Cycle>"]
F["find_neg_cycle()"] --> P["vector<pair<Node,Node>>"]
end
subgraph "Python NegCycleFinder ๐"
H2["howard()"] --> G2["Generator[Cycle]"]
MISSING["find_neg_cycle()"] -.->|"โ MISSING"| P2["vector[pairs]"]
end
style MISSING stroke:#f44336,stroke-dasharray: 5 5
style P2 stroke:#9c27b0
]
**Missing in Python `NegCycleFinder`** (still):
- `find_neg_cycle()` โ C++ `NetworkOracle` uses this, not `howard`!
- `_relax_node_pairs()` โ weight function receives node pairs, not edge data
- `_is_negative_node_pairs()` โ node-pair cycle verification
- `_cycle_list_node_pairs()` โ node-pair cycle extraction
---
class: nord-light, middle, center
## ๐ Part 5: Lessons
---
### ๐ Four Key Lessons
.pull-left[
**1. Templates โ TypeVars** ๐
C++ templates are **unconstrained by default**.
Python TypeVars are **constrained by default**.
Don't blindly translate one to the other.
**2. The C++ design is the reference** ๐
When Python and C++ implementations coexist,
the C++ template semantics are the "ground truth."
]
.pull-right[
**3. `Any` is sometimes correct** โ
Using `Any` to decouple unrelated types is
not a hack โ it's Python's way of saying
"these types are independent" (matching C++).
**4. Structural > Nominal for containers** ๐ฆ
C++ accepts any iterable; Python's `Mapping` was too restrictive.
Fixed with `_view_items()` helper that dispatches between
`dict.items()` and direct iteration. Now supports both `dict`
and `list[tuple]` neighbor containers.
]
---
### ๐ฏ Final Takeaway
**Type systems encode design intent.**
C++ templates say: *"I don't care about the types โ the `+` operator handles it."*
Python TypeVars say: *"These types must be the same."*
When the **same algorithm** has different type semantics in two languages,
the C++ template design is usually more flexible. Python types must be
adjusted to match โ not the other way around.
---
count: false
class: nord-dark, middle, center
### โ Q&A
**"When in doubt, check the C++ implementation."** ๐
---
count: false
class: nord-dark, middle, center
# ๐ Thank you!
### Happy cross-language coding! ๐
```python
# The final result:
Success: no issues found in 14 source files โจ
```