{utx, vtx});
// ^^^^^^^^^^^^^^^^^
// Synthetic pair โ unnecessary!
...
}
```
The graph already stores edge data in its adjacency structure.
Why throw it away and build a `(u,v)` pair?
**Root cause**: The AI didn't understand the graph's native edge data model.
---
### ๐ The Two Approaches
.center[.mermaid[
graph TD
subgraph "Node-Pairs (โ Removed)"
A["Iterate adjacency"] --> B["Extract (u,v)"]
B --> C["Build pair{utx,vtx}"]
C --> D["Call get_weight(pair)"]
end
subgraph "get_weight (โ
Kept)"
E["Iterate adjacency"] --> F["Extract edge data"]
F --> G["Call get_weight(edge_data)"]
end
style A fill:#fce4ec,stroke:#c62828
style B fill:#fce4ec,stroke:#c62828
style C fill:#ffcdd2,stroke:#c62828
style D fill:#ffcdd2,stroke:#c62828
style E fill:#e8f5e9,stroke:#2e7d32
style F fill:#e8f5e9,stroke:#2e7d32
style G fill:#c8e6c9,stroke:#2e7d32
]]
.pull-left[
**Node-Pairs (6 methods)** โ
- `_relax_node_pairs`
- `_is_negative_node_pairs`
- `_cycle_list_node_pairs`
- `find_neg_cycle`
- Plus 3 in `neg_cycle_q.hpp`
- 2 dedicated test files
]
.pull-right[
**get_weight (Kept)** โ
- `_relax` โ already existed
- `_is_negative` โ already existed
- `_cycle_list` โ already existed
- `howard()` โ the one true way
- 0 new code needed
]
---
### ๐งฌ The Duplication Tree
.center[.mermaid[
graph LR
subgraph "digraphx-cpp"
A["neg_cycle.hpp"] --> B["howard() โ
"]
A --> C["find_neg_cycle() โ"]
C --> D["_relax_node_pairs โ"]
C --> E["_is_negative_node_pairs โ"]
C --> F["_cycle_list_node_pairs โ"]
G["neg_cycle_q.hpp"] --> H["howard_pred/succ โ
"]
G --> I["find_neg_cycle_pred/succ โ"]
I --> J["_relax_node_pairs_pred/succ โ"]
I --> K["_is_negative_node_pairs โ"]
I --> L["_cycle_list_node_pairs โ"]
end
style B fill:#e8f5e9,stroke:#2e7d32
style C fill:#fce4ec,stroke:#c62828,stroke-width:2px
style D fill:#ffcdd2,stroke:#c62828
style E fill:#ffcdd2,stroke:#c62828
style F fill:#ffcdd2,stroke:#c62828
style I fill:#fce4ec,stroke:#c62828,stroke-width:2px
style J fill:#ffcdd2,stroke:#c62828
style K fill:#ffcdd2,stroke:#c62828
style L fill:#ffcdd2,stroke:#c62828
style H fill:#e8f5e9,stroke:#2e7d32
]]
---
class: nord-light, middle, center
## 2๏ธโฃ The Problem โ ๏ธ
---
### ๐ By the Numbers
**digraphx-cpp** โ 6 duplicated methods, 2 test files
.font-sm.mb-xs[
| Method | Lines | Purpose |
|--------|-------|---------|
| `_relax_node_pairs` | 18 | Same as `_relax` but builds `(u,v)` pairs |
| `_is_negative_node_pairs` | 12 | Same as `_is_negative` but builds `(u,v)` pairs |
| `_cycle_list_node_pairs` | 9 | Same as `_cycle_list` but returns pairs |
| `find_neg_cycle` | 13 | Wrapper calling the three above |
| `find_neg_cycle_pred` | 13 | Wrapper |
| `find_neg_cycle_succ` | 13 | Wrapper |
| `test_neg_cycle_find.cpp` | 113 | Entire file for node-pairs tests |
]
**Total**: ~467 lines of code that duplicated existing `howard()` logic.
---
### ๐ด Why It's Harmful
.pull-left[
**Maintenance burden** ๐ธ
- Every bugfix to `howard()` must also be applied to `find_neg_cycle()`
- Every new feature needs two implementations
- Code reviewers must check both paths
**Cognitive load** ๐ง
- "Which method should I use?"
- "What's the difference?"
- "Why does `find_neg_cycle` return `pair` but `howard` returns `Edge`?"
]
.pull-right[
**Test debt** ๐งช
- 2 extra test files to maintain
- Same coverage, double the CI time
- Tests for methods that should never have existed
**API pollution** ๐ฎ
- Public API has two ways to do the same thing
- Users ask "which one should I call?"
- Impossible to deprecate (it was the *first* one added)
> **The best code is the code never written.** โ Ponytail Principle
]
---
### ๐ต๏ธ Spot the Difference
.font-sm.mb-xs[
```cpp
// _relax โ get_weight on native edge data โ
auto distance = dist[utx] + get_weight(edge);
// _relax_node_pairs โ get_weight on synthetic (u,v) โ
auto weight = get_weight(std::pair{utx, vtx});
auto distance = dist[utx] + weight;
```
]
The only difference: one passes the edge data from the graph, the other throws it away and builds a `(u,v)` pair.
**Every call to `find_neg_cycle` required a weight function like this**:
.font-sm.mb-xs[
```cpp
// โ Caller had to reverse-engineer the edge from (u,v)
auto get_weight = [&](const auto& edge) -> int {
const auto [utx, vtx] = edge; // unpack the pair
return gra[utx][vtx]; // look up the real weight
};
```
]
vs.
.font-sm.mb-xs[
```cpp
// โ
Caller receives the edge data directly
auto get_weight = [](int w) -> int { return w; }; // it's already the weight!
```
]
---
class: nord-light, middle, center
## 3๏ธโฃ The Fix ๐ง
---
### ๐ง Step 1: Switch to `howard()`
The `NegCycleFinder` already had `howard()` โ a generator-based method that yields cycles of native edge data.
```cpp
// โ Before: find_neg_cycle โ returns one cycle or empty
auto C = ncf.find_neg_cycle(dist, get_weight);
if (C.empty()) { ... }
for (auto&& edge : C) { ... }
// โ
After: howard โ yields cycles via generator
for (auto&& C : ncf.howard(dist, get_weight)) {
for (auto&& edge : C) { ... }
break; // take first cycle (or iterate all)
}
```
**Key insight**: `howard()` is not a new method โ it was always there. The node-pairs variant was the duplicate.
---
### ๐๏ธ Code Deduction in `parametric.hpp`
The old `max_parametric` re-invented the algorithm:
```cpp
// โ Before: ~60 lines with node-pairs + distance updates
auto max_parametric(...) {
using edge_t = std::pair;
...
auto c_min = ncf.find_neg_cycle(dist, get_weight);
// ... manual distance update loop ...
}
// โ
After: ~40 lines, delegates to howard(), no distance update
auto max_parametric(...) {
// ponytail: deduce Edge type using NegCycleFinder helpers
using Edge = ...; // deduced from graph adjacency
...
for (auto&& ci : ncf.howard(dist, get_weight)) { ... }
}
```
**Net change**: -241 lines across both projects. Same 32 + 45 tests pass.
---
### ๐ Edge Type Deduction
The tricky part: `Edge` type varies by graph representation.
.font-sm.mb-xs[
```cpp
using Elem = decltype(*std::declval().begin());
using Nbrs = std::remove_cv_t(), std::declval()))>>;
using NbrElem = decltype(*std::declval().begin());
using Edge = std::remove_cv_t(), std::declval()))>>;
```
]
**How it works**:
.font-sm.mb-xs[
| Graph Type | Adjacency | Edge = |
|-----------|-----------|--------|
| `SimpleDiGraphS` | `dict` | `int` (weight/index) |
| `py::dict<...>` | `dict` | `int` (weight) |
| `unordered_map<...list>>` | `list>` | `V` (custom data) |
]
Uses the same `_get_val` helpers as `NegCycleFinder` itself โ no duplicated logic.
---
### ๐งช Test Transformation
**Before** โ callbacks reverse-engineered (u,v) pairs:
```cpp
// โ Old test: unpack pair, look up weight
const auto get_cost = [&](const auto& edge) -> int {
const auto [utx, vtx] = edge;
return cost[size_t(gra[utx][vtx])];
};
```
**After** โ callbacks receive native edge data:
```cpp
// โ
New test: edge data IS the index
const auto get_cost = [&](int edge_idx) -> int {
return cost[edge_idx];
};
// Even simpler when edge data IS the weight:
const auto get_cost = [](int w) -> int { return w; };
```
**Simpler tests = fewer bugs.** ๐ฏ
---
### ๐ Ripple Effect: `network_oracle.hpp`
The `NetworkOracle` class also used `find_neg_cycle()`. Switching to `howard()` simplified the code:
.font-sm.mb-xs[
```cpp
// โ Before
auto C = this->_S.find_neg_cycle(this->_u, get_weight);
if (C.empty()) { return {}; }
// iterate C...
// โ
After
for (auto&& C : this->_S.howard(this->_u, get_weight)) {
// C is a cycle of native edge data
for (auto&& edge : C) {
fval -= this->_h.eval(edge, xval);
grad -= this->_h.grad(edge, xval);
}
return std::pair{std::move(grad), fval};
}
return {};
```
]
Bonus: the `Ratio` class in `OptScalingOracle` now matches the Python sibling exactly:
.font-sm.mb-xs[
```cpp
auto eval(const auto& edge, const Vec& x) const -> double {
const auto [aij, aji] = this->_get_cost(edge);
return std::min(x[0] - aji, aij - x[1]); // matches Python!
}
```
]
---
class: nord-light, middle, center
## 4๏ธโฃ Ripple Effect ๐
---
### ๐ฟ Upstream Cleanup: netoptim-cpp
The netoptim-cpp project also needed updating โ it was the only caller of `find_neg_cycle()`:
.center[.mermaid[
graph LR
A["digraphx-cpp\nfind_neg_cycle โ\nhoward โ
"] -->|"called by"| B["netoptim-cpp\nparametric.hpp"]
A -->|"called by"| C["netoptim-cpp\nnetwork_oracle.hpp"]
A -->|"called by"| D["netoptim-cpp\nmin_cycle_ratio.hpp"]
B -->|"now uses howard โ
"| A
C -->|"now uses howard โ
"| A
D -->|"now uses howard โ
"| A
E["netoptim-cpp tests"] -->|"6 files updated"| F["32/32 pass โ
"]
style A fill:#e8f5e9,stroke:#2e7d32
style B fill:#fff3e0,stroke:#e65100
style C fill:#fff3e0,stroke:#e65100
style D fill:#fff3e0,stroke:#e65100
style F fill:#c8e6c9,stroke:#2e7d32
]]
---
### ๐ Test Results Summary
| Project | Before | After | ฮ |
|---------|--------|-------|---|
| **digraphx-cpp** | 58 tests โ
| 45 tests โ
| -13 node-pair tests |
| **netoptim-cpp** | 32 tests โ
| 32 tests โ
| 0 |
| **Lines removed** | โ | โ | -241 |
| **Files changed** | โ | 11 | โ |
The 13 removed tests covered `find_neg_cycle_pred/succ` โ code that no longer exists and should never have been written.
---
class: nord-light, middle, center
## 5๏ธโฃ Key Takeaways ๐ก
---
### ๐ฏ Lessons Learned
.pull-left[
**For AI-Assisted Development** ๐ค
1. **Review AI-generated code critically**
- "Does this duplicate existing functionality?"
- "Does this understand the data model?"
2. **Watch for parallel hierarchies**
- Two methods doing the same thing differently = ๐ฉ
3. **The `howard()` method was right all along**
- It passes native edge data
- Zero extra allocations
]
.pull-right[
**Code Hygiene Checklist** โ
.font-sm.mb-xs[
| Pattern | Verdict |
|---------|---------|
| `_relax` + `_relax_node_pairs` | Duplicate ๐ฉ |
| `std::pair` as edge | Code smell ๐ฉ |
| Generator returning edge data | Clean โ
|
| Callback receiving native types | Clean โ
|
| Matching Python implementation | Clean โ
|
]
**If two methods differ only in type conversion,
you probably don't need the second one.**
]
---
### ๐ Metrics That Matter
```text
โ Node-Pairs โ
get_weight
โโโโโโโโโโโโโ โโโโโโโโโโ
API surface 6 methods 1 method
Test files 2 dedicated 0 (covered by howard tests)
Weight callbacks Must unpack (u,v) Receives edge data directly
Edge cases Parallel edges Works natively
break
Maintenance Bugfix ร 2 Bugfix ร 1
```
**The ROI of deletion**:
- -241 lines of code
- -2 test files
- 0 regressions
- Same coverage
> **The fastest code to debug is the code that doesn't exist.**
---
### ๐ฎ Future Directions
.center[.mermaid[
graph LR
subgraph "Done โ
"
A["Remove node-pairs from\ndigraphx-cpp"] --> B["Update netoptim-cpp\nto use howard()"]
end
subgraph "Next ๐"
D["Remove find_neg_cycle\nfrom Python sibling?"] --> E["Convert Boost test\ncases"]
E --> F["Homogenize edge\naccess across all repos"]
end
B --> D
style A fill:#c8e6c9,stroke:#2e7d32
style B fill:#c8e6c9,stroke:#2e7d32
style D fill:#fff9c4,stroke:#f9a825
style E fill:#fff9c4,stroke:#f9a825
style F fill:#fff9c4,stroke:#f9a825
]]
- **Python sibling**: `digraphx-py` still has both `howard()` and `find_neg_cycle()` โ same cleanup possible
- **Boost graph tests**: `test_cases_boost.hpp` in netoptim-cpp uses a different edge model โ verify compatibility
- **Consistent edge API**: All graph libraries should expose edge data the same way
---
count: false
class: nord-dark, middle, center
## ๐ Thank You
### ๐งน Removing Duplication: From Node-Pairs to `get_weight`
**Slides**: [`luk036.github.io/idea/remove-duplicate-remark`](https://luk036.github.io/idea/remove-duplicate-remark.html)
**Repos**:
- [`github.com/luk036/digraphx-cpp`](https://github.com/luk036/digraphx-cpp) โ `dev` branch
- [`github.com/luk036/netoptim-cpp`](https://github.com/luk036/netoptim-cpp) โ `dev` branch
**Key takeaway**: *AI-generated code needs human review โ especially for duplication.*
@luk036 ๐จโ๐ป ยท 2026 ๐