-
Notifications
You must be signed in to change notification settings - Fork 0
fix: handle disconnected components in tropical TN contraction #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Add scripts/analyze_tropical_threshold.py for analyzing the threshold of tropical tensor network MAP decoder on rotated surface codes. Key features: - Builds UAI factor graph from DEM parity check matrix - Uses tropical tensor network contraction for exact MAP inference - Computes observable prediction using mod-2 arithmetic with obs_flip thresholding - Generates threshold plots comparing LER across error rates Known limitation: MAP decoding finds the single most likely error pattern, but QEC requires the most likely observable value. This causes ~2x higher LER compared to BP+OSD which uses marginal probabilities. See issue for discussion of potential improvements. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The tropical TN decoder was producing incorrect results because omeco.optimize_code only processes one connected component of the factor graph. When prior factors (single-variable) were disconnected from constraint factors, they were being dropped from the contraction tree. Changes: - Add _find_connected_components() using union-find algorithm - Modify get_omeco_tree() to handle multiple disconnected components - Update analyze_tropical_threshold.py to use pymatching graph structure - Add comprehensive tests for connected components handling - Add tests verifying Tropical TN matches MWPM on surface codes Fixes #68
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #70 +/- ##
==========================================
+ Coverage 95.18% 96.84% +1.65%
==========================================
Files 10 10
Lines 747 728 -19
==========================================
- Hits 711 705 -6
+ Misses 36 23 -13
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Update analyze_tropical_threshold.py and tests to use build_parity_check_matrix from bpdecoderplus.dem instead of pymatching's graph structure: - Use merge_hyperedges=True for efficient computation (smaller matrix) - Threshold obs_flip at 0.5 for soft values from hyperedge merging - Remove pymatching dependency from core decoder (optional for comparison) - Update tests to use bpdecoderplus.dem functions The connected components fix ensures all factors are included in the contraction tree regardless of the matrix construction method.
There was a problem hiding this 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 fixes a bug in the tropical tensor network decoder where omeco.optimize_code was only processing one connected component of the factor graph, causing disconnected factors (like single-variable prior factors) to be dropped from the contraction tree. The fix implements connected component detection using a union-find algorithm and ensures all factors are included in the contraction.
Changes:
- Added
_find_connected_components()function using union-find algorithm to detect all connected components in the factor graph - Modified
get_omeco_tree()to contract each component separately and combine results into a single tree - Updated analysis scripts to use pymatching for comparison and validation
- Added comprehensive test suite covering various connected/disconnected component scenarios
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
tropical_in_new/src/contraction.py |
Core fix: added connected component detection and multi-component tree combination logic |
tropical_in_new/tests/test_connected_components.py |
Unit tests for connected component detection and tree construction |
tropical_in_new/tests/test_tropical_mwpm_match.py |
Integration tests comparing Tropical TN with MWPM decoder |
scripts/analyze_tropical_threshold.py |
Threshold analysis script using the fixed decoder |
scripts/test_tropical_fix.py |
Quick verification script for the fix |
outputs/tropical_threshold_plot.png |
Generated visualization output |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| lines.append("1") | ||
| lines.append("1.0") |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent handling of empty detector factors between test_tropical_mwpm_match.py and analyze_tropical_threshold.py.
In test_tropical_mwpm_match.py (lines 74-80), when a detector has no connected errors (empty variable list), the factor value depends on the syndrome:
- If
syndrome[d] == 0: assigns "1.0" - If
syndrome[d] != 0: assigns "1e-30"
However, in analyze_tropical_threshold.py (lines 154-157), the implementation always assigns "1.0" regardless of the syndrome value when a detector has no connected errors.
This inconsistency could lead to different behavior between the test script and the threshold analysis script. For correctness, empty detectors with a non-zero syndrome should have probability close to zero (1e-30), as this represents an unsatisfiable constraint (no errors can flip the detector, yet it's triggered).
| lines.append("1") | |
| lines.append("1.0") | |
| # Empty detector: probability depends on whether the syndrome is consistent. | |
| # If syndrome[d] == 0, the constraint is satisfied (probability 1.0). | |
| # If syndrome[d] != 0, the constraint is unsatisfiable (near-zero probability). | |
| syndrome_bit = int(syndrome[d]) | |
| lines.append("1") | |
| if syndrome_bit == 0: | |
| lines.append("1.0") | |
| else: | |
| lines.append("1e-30") |
scripts/test_tropical_fix.py
Outdated
| lines.append("1") | ||
| lines.append("1.0") |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same bug as in analyze_tropical_threshold.py: inconsistent handling of empty detector factors.
When a detector has no connected errors (empty variable list), the factor value is always set to "1.0" (line 72) regardless of the syndrome value. This should depend on the syndrome:
- If
syndrome[d] == 0: assign "1.0" (constraint is satisfied) - If
syndrome[d] != 0: assign "1e-30" (constraint is violated - no errors can flip this detector)
The correct implementation can be found in test_tropical_mwpm_match.py lines 76-79.
| lines.append("1") | |
| lines.append("1.0") | |
| syndrome_bit = int(syndrome[d]) | |
| lines.append("1") | |
| if syndrome_bit == 0: | |
| lines.append("1.0") | |
| else: | |
| lines.append("1e-30") |
tropical_in_new/src/contraction.py
Outdated
| if len(trees) == 1: | ||
| return trees[0] | ||
| elif len(trees) == 2: | ||
| # Combine two trees | ||
| # Get output indices for each | ||
| def get_output_vars(tree): | ||
| if "tensor_index" in tree: | ||
| return list(nodes[tree["tensor_index"]].vars) | ||
| eins = tree.get("eins", {}) | ||
| return eins.get("iy", []) | ||
|
|
||
| out0 = get_output_vars(trees[0]) | ||
| out1 = get_output_vars(trees[1]) | ||
| combined_out = list(dict.fromkeys(out0 + out1)) | ||
|
|
||
| return { | ||
| "args": trees, | ||
| "eins": {"ixs": [out0, out1], "iy": combined_out} | ||
| } | ||
| else: | ||
| # Recursively combine | ||
| mid = len(trees) // 2 | ||
| left = combine_trees(trees[:mid]) | ||
| right = combine_trees(trees[mid:]) | ||
| return combine_trees([left, right]) |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The combine_trees function uses recursion with a divide-and-conquer approach (lines 183-187). For a large number of disconnected components, this could potentially hit Python's recursion limit.
For example, with 1000 disconnected components, the recursion depth would be approximately log2(1000) ≈ 10 levels, which is safe. However, if there were thousands of disconnected components (unusual but possible), this could become a concern.
Consider adding a check or comment about the expected maximum number of components, or alternatively, using an iterative approach for combining trees if this becomes a practical issue.
| if len(trees) == 1: | |
| return trees[0] | |
| elif len(trees) == 2: | |
| # Combine two trees | |
| # Get output indices for each | |
| def get_output_vars(tree): | |
| if "tensor_index" in tree: | |
| return list(nodes[tree["tensor_index"]].vars) | |
| eins = tree.get("eins", {}) | |
| return eins.get("iy", []) | |
| out0 = get_output_vars(trees[0]) | |
| out1 = get_output_vars(trees[1]) | |
| combined_out = list(dict.fromkeys(out0 + out1)) | |
| return { | |
| "args": trees, | |
| "eins": {"ixs": [out0, out1], "iy": combined_out} | |
| } | |
| else: | |
| # Recursively combine | |
| mid = len(trees) // 2 | |
| left = combine_trees(trees[:mid]) | |
| right = combine_trees(trees[mid:]) | |
| return combine_trees([left, right]) | |
| """Combine a list of component trees into a single tree without recursion. | |
| The combination pattern is effectively the same as the previous | |
| divide-and-conquer approach, but implemented iteratively to avoid | |
| any risk of hitting Python's recursion limit if there are many | |
| disconnected components. | |
| """ | |
| # Get output indices for a tree | |
| def get_output_vars(tree): | |
| if "tensor_index" in tree: | |
| return list(nodes[tree["tensor_index"]].vars) | |
| eins = tree.get("eins", {}) | |
| return eins.get("iy", []) | |
| trees = list(trees) | |
| if not trees: | |
| raise ValueError("combine_trees expects at least one tree") | |
| if len(trees) == 1: | |
| return trees[0] | |
| # Iteratively combine trees in pairs until a single tree remains. | |
| while len(trees) > 1: | |
| new_trees = [] | |
| i = 0 | |
| while i < len(trees): | |
| if i + 1 >= len(trees): | |
| # Odd tree out, carry to next round unchanged | |
| new_trees.append(trees[i]) | |
| break | |
| left_tree = trees[i] | |
| right_tree = trees[i + 1] | |
| out0 = get_output_vars(left_tree) | |
| out1 = get_output_vars(right_tree) | |
| combined_out = list(dict.fromkeys(out0 + out1)) | |
| new_trees.append( | |
| { | |
| "args": [left_tree, right_tree], | |
| "eins": {"ixs": [out0, out1], "iy": combined_out}, | |
| } | |
| ) | |
| i += 2 | |
| trees = new_trees | |
| return trees[0] |
| var_to_factors: dict[int, list[int]] = {} | ||
| for i, vars in enumerate(ixs): | ||
| for v in vars: | ||
| if v not in var_to_factors: | ||
| var_to_factors[v] = [] | ||
| var_to_factors[v].append(i) |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The _find_connected_components function does not handle factors with empty variable lists correctly. If a factor has an empty variable list (no variables), it will not be added to any component and will be silently dropped from the result. This could lead to factors being excluded from the contraction tree.
Consider the case where ixs = [[], [1], [2]]:
- Factor 0 has no variables, so it won't be added to
var_to_factors - Factor 0 will never be unioned with any other factor
- Factor 0 will form its own component with root 0
- However, the logic appears correct - it will create a singleton component for factor 0
Actually, upon closer inspection, the code should handle this correctly because all factors are initialized in the parent array (line 72), and the grouping step (lines 90-95) will include all factors. Each factor with an empty variable list would form its own singleton component, which seems correct.
However, there's a potential issue: What if a factor genuinely has no variables (a constant factor)? The current implementation would create a singleton component for it, which is probably correct, but this edge case should be tested.
tropical_in_new/src/contraction.py
Outdated
| eins = tree.get("eins", {}) | ||
| return eins.get("iy", []) |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The get_output_vars function (line 168) within combine_trees might fail or behave unexpectedly for certain tree structures. Specifically:
- If a tree is a leaf node (has "tensor_index"), it returns
list(nodes[tree["tensor_index"]].vars)(line 170) - Otherwise, it returns
eins.get("iy", [])(line 172)
However, if the tree is not a leaf and doesn't have an "eins" key, or if "eins" doesn't have an "iy" key, this will return an empty list []. This could lead to incorrect combined output indices.
For robustness, consider adding validation to ensure that non-leaf nodes have the expected structure, or provide clearer error messages when the tree structure is unexpected.
| eins = tree.get("eins", {}) | |
| return eins.get("iy", []) | |
| eins = tree.get("eins") | |
| if not isinstance(eins, dict) or "iy" not in eins: | |
| raise ValueError( | |
| "Invalid contraction tree node: non-leaf nodes must have an " | |
| "'eins' mapping with an 'iy' key specifying output variables." | |
| ) | |
| return list(eins["iy"]) |
| sampler = circuit.compile_detector_sampler() | ||
| samples = sampler.sample(50, append_observables=True) | ||
| syndromes = samples[:, :-1].astype(np.uint8) | ||
| observables = samples[:, -1].astype(np.int32) |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable observables is not used.
| observables = samples[:, -1].astype(np.int32) |
| disconnected components, which was a bug fix for Issue #68. | ||
| """ | ||
|
|
||
| import pytest |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import of 'pytest' is not used.
| import pytest |
| import numpy as np | ||
| import pytest | ||
| import stim | ||
| import torch |
Copilot
AI
Jan 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import of 'torch' is not used.
| import torch |
- Add MathJax support via pymdownx.arithmatex extension - Create mathjax.js config for proper math rendering - Convert mathematical_description.md to use proper LaTeX notation - Add benchmark results section to getting_started.md - Include threshold plots and decoder comparison images Closes #69
- Fix empty detector handling in test_tropical_fix.py to check syndrome - Convert combine_trees from recursive to iterative to avoid recursion limits - Add validation to get_output_vars for better error messages - Remove unused imports (pytest, torch) in test files - Remove unused observables variable in test_tropical_mwpm_match.py All 123 tests pass.
Add TestBuildDecodingUAI class with 6 tests: - test_basic_uai_structure: verify UAI header format - test_prior_factors: check prior probability values - test_constraint_factors_syndrome_zero: even parity constraints - test_constraint_factors_syndrome_one: odd parity constraints - test_empty_detector: handle detectors with no connected errors - test_real_surface_code: integration test with real DEM This improves code coverage for the new build_decoding_uai function.
Add additional tests for better coverage: - test_dem.py: Add tests for zero-probability errors and no-split mode - test_cli.py: Add tests for --generate-dem and --generate-syndromes flags - test_syndrome.py: Add tests for metadata edge cases (0-dim array, dict) - test_uai_parser.py: Add tests for Factor/UAIModel repr and empty evidence Total: 141 tests passing with 97% coverage.
Summary
omeco.optimize_codeonly processed one connected component of the factor graphanalyze_tropical_threshold.pyto use pymatching graph structureProblem
The tropical TN decoder was producing incorrect results because disconnected factors (like single-variable prior factors) were being dropped from the contraction tree. In a 78-variable, 102-factor problem, only 31 factors were being contracted.
Solution
_find_connected_components()to detect all connected componentsget_omeco_tree()to contract each component separately and combine resultsVerification
After the fix:
Fixes #68
Test plan
pytest tropical_in_new/tests/test_connected_components.py -vpytest tropical_in_new/tests/test_tropical_mwpm_match.py -v