From d3fcc2f5a7d292ff51303cfc15110faab00f4ff3 Mon Sep 17 00:00:00 2001 From: jp Date: Sun, 24 May 2026 18:15:07 -0700 Subject: [PATCH] feat(cat5): representative cycles alongside Betti-1 (#16) Co-Authored-By: Claude Opus 4.6 --- sme/categories/gap_detection.py | 25 +++++++++++++++++++++++++ tests/test_gap_detection.py | 24 +++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/sme/categories/gap_detection.py b/sme/categories/gap_detection.py index 6e6b973..a0f7139 100644 --- a/sme/categories/gap_detection.py +++ b/sme/categories/gap_detection.py @@ -80,6 +80,7 @@ class GapDetectionReport: h1_max_persistence: float h1_skipped: bool = False h1_skip_reason: str = "" + representative_cycles: list[list[str]] = field(default_factory=list) # Candidate gaps across components (top-K by score, post-filter) candidate_gaps: list[CandidateGap] = field(default_factory=list) @@ -290,6 +291,17 @@ def score_gap_detection( "Betti-1 persistence readings" ) + representative_cycles: list[list[str]] = [] + if largest: + sub_undirected = nx.Graph() + sub_undirected.add_nodes_from(largest) + for u, v, _ in G.edges(keys=True): + if u in largest and v in largest and u != v: + sub_undirected.add_edge(u, v) + raw_cycles = nx.cycle_basis(sub_undirected) + raw_cycles.sort(key=lambda c: (len(c), c)) + representative_cycles = raw_cycles[:10] + candidates, considered = _candidate_gaps( analyzer, components, @@ -340,6 +352,7 @@ def score_gap_detection( h1_max_persistence=h1_max_persistence, h1_skipped=h1_skipped, h1_skip_reason=h1_skip_reason, + representative_cycles=representative_cycles, candidate_gaps=candidates, candidate_gaps_considered=considered, gap_recall=gap_recall, @@ -418,6 +431,18 @@ def format_report(report: GapDetectionReport) -> str: if report.h1_skipped: lines.append(f" (homology skipped: {report.h1_skip_reason})") + if report.representative_cycles: + lines.append( + f" Representative cycles ({len(report.representative_cycles)}):" + ) + for cycle in report.representative_cycles[:5]: + nodes_str = " → ".join(cycle) + " → " + cycle[0] + lines.append(f" [{len(cycle)}-cycle] {nodes_str}") + if len(report.representative_cycles) > 5: + lines.append( + f" ... +{len(report.representative_cycles) - 5} more" + ) + lines.append("") lines.append( f" Candidate gaps: {len(report.candidate_gaps):,} shown" diff --git a/tests/test_gap_detection.py b/tests/test_gap_detection.py index 4d0cb29..ae6e18b 100644 --- a/tests/test_gap_detection.py +++ b/tests/test_gap_detection.py @@ -14,7 +14,7 @@ import pytest -from sme.categories.gap_detection import score_gap_detection +from sme.categories.gap_detection import format_report, score_gap_detection ripser = pytest.importorskip # alias for readability below @@ -154,3 +154,25 @@ def test_empty_graph_is_all_zeros(): assert report.isolated_nodes == 0 assert report.bridges == [] assert report.candidate_gaps == [] + + +# --- Representative cycles (#16) ------------------------------------- + + +def test_representative_cycles(gap_graph): + """Issue #16 — representative cycles from largest component.""" + entities, edges, _ = gap_graph + report = score_gap_detection(entities, edges, run_homology=False) + assert len(report.representative_cycles) >= 1 + cycle_nodes = [set(c) for c in report.representative_cycles] + five_cycle_nodes = {"A", "B", "C", "D", "E"} + assert any(five_cycle_nodes <= nodes for nodes in cycle_nodes) + + +def test_format_report_shows_cycles(gap_graph): + """Issue #16 — format_report includes cycle descriptions.""" + entities, edges, _ = gap_graph + report = score_gap_detection(entities, edges, run_homology=False) + rendered = format_report(report) + assert "Representative cycles" in rendered + assert "-cycle]" in rendered