Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions sme/categories/gap_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 23 additions & 1 deletion tests/test_gap_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading