This is related to #40. The from_eilon workaround introduced there replaced a line-wrapping bug with an indexing bug.
Both code paths for parsing EDGE_WEIGHT_FORMAT: LOWER_ROW produce incorrect distance matrices. This is demonstrable against the published BKS for E-n13-k4 (optimal value 247).
The Two Bugs
1. from_lower_row incorrectly depends on physical line wrapping
The standard function executes distances[i + 1, : i + 1] = triangular[i], expecting each inner list to correspond to exactly one triangular row. TSPLIB95 specifies that EDGE_WEIGHT_SECTION is a continuous 1D stream where line breaks carry no structural meaning. If a physical line contains values spanning multiple triangular rows (or if all values are on a single line), the parser scrambles the matrix or crashes.
2. from_eilon uses the wrong (i,j) mapping (UPPER_ROW indexing)
The Eilon workaround correctly flattens the data to 1D but maps values using itertools.combinations(range(n), r=2). This produces pairs in (0,1), (0,2), (0,3) order—which is the UPPER_ROW convention. The LOWER_ROW order per TSPLIB95 is (1,0), (2,0), (2,1), (3,0). These diverge from the third value onward, silently assigning distances to the wrong node pairs. (Note: If this was implemented to fix a previous issue, it replaced a line-wrapping bug with an indexing bug).
Proof with E-n13-k4
The CVRPLIB BKS for E-n13-k4 is 247. This instance uses EDGE_WEIGHT_FORMAT: LOWER_ROW.
- vrplib v2.1.0 (
from_eilon path): d(0,3) = 21, d(1,2) = 5, solver optimal = 290 (Fails) - Correct LOWER_ROW (flat stream, strictly lower-triangular): d(0,3) = 23, d(1,2) = 21, solver optimal = 247 (Matches BKS)
Minimal Reproducible Examples
Here are two minimal .vrp files proving both paths fail. Both represent the exact same LOWER_ROW sequence: 10, 20, 30, 40, 50, 60.
MRE 1: Testing from_eilon indexing bug
NAME : test_eilon
COMMENT : (Eilon et al, test)
TYPE : CVRP
DIMENSION : 4
EDGE_WEIGHT_TYPE : EXPLICIT
EDGE_WEIGHT_FORMAT : LOWER_ROW
EDGE_WEIGHT_SECTION
10
20 30
40 50 60
DEMAND_SECTION
1 0
2 10
3 10
4 10
DEPOT_SECTION
1
-1
EOF
Expected: d(0,3) = 40, d(1,2) = 30
Actual (vrplib v2.1.0): d(0,3) = 30, d(1,2) = 40 (Wrong values due to combinations mapping)
MRE 2: Testing from_lower_row physical line bug
NAME : test_standard
COMMENT : standard test
TYPE : CVRP
DIMENSION : 4
EDGE_WEIGHT_TYPE : EXPLICIT
EDGE_WEIGHT_FORMAT : LOWER_ROW
EDGE_WEIGHT_SECTION
10 20 30 40 50 60
DEMAND_SECTION
1 0
2 10
3 10
4 10
DEPOT_SECTION
1
-1
EOF
Expected: The matrix parses successfully with d(0,3) = 40, d(1,2) = 30.
Actual (vrplib v2.1.0):ValueError: could not broadcast input array from shape (6,) into shape (1,)(Fails to parse entirely)
Suggested Fix
Replace both from_lower_row and the from_eilon special case with a single, vectorised NumPy function that flattens incoming data natively (handling both ragged nested arrays and flat arrays) and mathematically enforces the TSPLIB95 bounds.
importmathimportitertoolsimportnumpyasnpdeffrom_lower_row(data):
# 1. Robust flattening: Handle both ragged nested sequences and flat arraysifisinstance(data, np.ndarray):
flattened=np.asarray(data).ravel()
elifisinstance(data, (list, tuple)):
# If the first element is also a sequence, flatten the nested structureiflen(data) >0andisinstance(data[0], (list, tuple, np.ndarray)):
flattened=np.fromiter(itertools.chain.from_iterable(data), dtype=float)
else:
flattened=np.asarray(data, dtype=float).ravel()
else:
flattened=np.asarray(data, dtype=float).ravel()
m=flattened.size# 2. Exact mathematical inverse using the quadratic formula# n^2 - n - 2m = 0 => n = (1 + sqrt(1 + 8m)) / 2disc=1+8*mroot=math.isqrt(disc)
ifroot*root!=disc:
raiseValueError(f"Invalid LOWER_ROW size: {m} elements is not a valid triangular sequence.")
n= (1+root) //2# 3. Vectorized assignment using true row-major lower triangular indexingdistances=np.zeros((n, n), dtype=flattened.dtype)
distances[np.tril_indices(n, k=-1)] =flatteneddistances+=distances.T# Mirror across diagonal for symmetryreturndistancesImplementation & Cleanup Notes
I have tested this locally by patching parse_distances.py. To implement this cleanly, the following legacy elements should also be removed from that file:
- Remove Eilon Routing: Delete the
if comment is not None and "Eilon" in comment: logic from parse_distances(). The new function handles Eilon instances natively. - Delete Dead Functions: The
from_eilon() and is_triangular_number() functions are now entirely obsolete and should be removed. - Clean Imports: Remove
from itertools import combinations, but ensure import math and import itertools are added at the top.
This eliminates the fragile comment-string detection, making the parser robust and mathematically correct across all compliant instances.
Tested against vrplib v2.1.0.
This is related to #40. The from_eilon workaround introduced there replaced a line-wrapping bug with an indexing bug.
Both code paths for parsing
EDGE_WEIGHT_FORMAT: LOWER_ROWproduce incorrect distance matrices. This is demonstrable against the published BKS forE-n13-k4(optimal value 247).The Two Bugs
1.
from_lower_rowincorrectly depends on physical line wrappingThe standard function executes
distances[i + 1, : i + 1] = triangular[i], expecting each inner list to correspond to exactly one triangular row. TSPLIB95 specifies thatEDGE_WEIGHT_SECTIONis a continuous 1D stream where line breaks carry no structural meaning. If a physical line contains values spanning multiple triangular rows (or if all values are on a single line), the parser scrambles the matrix or crashes.2.
from_eilonuses the wrong (i,j) mapping (UPPER_ROW indexing)The Eilon workaround correctly flattens the data to 1D but maps values using
itertools.combinations(range(n), r=2). This produces pairs in (0,1), (0,2), (0,3) order—which is theUPPER_ROWconvention. TheLOWER_ROWorder per TSPLIB95 is (1,0), (2,0), (2,1), (3,0). These diverge from the third value onward, silently assigning distances to the wrong node pairs. (Note: If this was implemented to fix a previous issue, it replaced a line-wrapping bug with an indexing bug).Proof with E-n13-k4
The CVRPLIB BKS for
E-n13-k4is 247. This instance usesEDGE_WEIGHT_FORMAT: LOWER_ROW.from_eilonpath): d(0,3) = 21, d(1,2) = 5, solver optimal = 290 (Fails)Minimal Reproducible Examples
Here are two minimal
.vrpfiles proving both paths fail. Both represent the exact same LOWER_ROW sequence:10, 20, 30, 40, 50, 60.MRE 1: Testing
from_eilonindexing bugExpected: d(0,3) = 40, d(1,2) = 30
Actual (vrplib v2.1.0): d(0,3) = 30, d(1,2) = 40 (Wrong values due to combinations mapping)
MRE 2: Testing
from_lower_rowphysical line bugExpected: The matrix parses successfully with d(0,3) = 40, d(1,2) = 30.
Actual (vrplib v2.1.0):
ValueError: could not broadcast input array from shape (6,) into shape (1,)(Fails to parse entirely)Suggested Fix
Replace both
from_lower_rowand thefrom_eilonspecial case with a single, vectorised NumPy function that flattens incoming data natively (handling both ragged nested arrays and flat arrays) and mathematically enforces the TSPLIB95 bounds.Implementation & Cleanup Notes
I have tested this locally by patching
parse_distances.py. To implement this cleanly, the following legacy elements should also be removed from that file:if comment is not None and "Eilon" in comment:logic fromparse_distances(). The new function handles Eilon instances natively.from_eilon()andis_triangular_number()functions are now entirely obsolete and should be removed.from itertools import combinations, but ensureimport mathandimport itertoolsare added at the top.This eliminates the fragile comment-string detection, making the parser robust and mathematically correct across all compliant instances.
Tested against vrplib v2.1.0.