def check_cld001_loop_polarity(model: CausalLoopModel) -> list[Finding]:
"""CLD-001: Loop polarity classification.
Find all cycles in the CLD and classify them:
- Even number of negative links = Reinforcing (R)
- Odd number of negative links = Balancing (B)
"""
findings: list[Finding] = []
# Build adjacency list with polarity
adj: dict[str, list[tuple[str, str]]] = {v.name: [] for v in model.variables}
for link in model.links:
if link.source in adj:
adj[link.source].append((link.target, link.polarity))
# Find all simple cycles using DFS
cycles: list[list[tuple[str, str]]] = []
visited: set[str] = set()
def dfs(
node: str,
start: str,
path: list[tuple[str, str]],
in_path: set[str],
) -> None:
for neighbor, polarity in adj[node]:
if neighbor == start and len(path) > 1:
cycles.append(path + [(neighbor, polarity)])
elif neighbor not in in_path and neighbor not in visited:
dfs(
neighbor, start, path + [(neighbor, polarity)], in_path | {neighbor}
)
for var in model.variables:
dfs(var.name, var.name, [(var.name, "")], {var.name})
visited.add(var.name)
if not cycles:
findings.append(
Finding(
check_id="CLD-001",
severity=Severity.INFO,
message="No feedback loops detected in the CLD",
source_elements=[],
passed=True,
)
)
else:
for cycle in cycles:
nodes = [n for n, _ in cycle[:-1]]
# Count negative links in the cycle (skip first tuple which has empty polarity)
neg_count = sum(1 for _, p in cycle[1:] if p == "-")
loop_type = "Balancing (B)" if neg_count % 2 == 1 else "Reinforcing (R)"
findings.append(
Finding(
check_id="CLD-001",
severity=Severity.INFO,
message=(
f"Loop {' -> '.join(nodes)} -> {nodes[0]}: "
f"{loop_type} ({neg_count} negative link(s))"
),
source_elements=nodes,
passed=True,
)
)
return findings