compact_sets/src/pathfinder.py

134 lines
4.2 KiB
Python
Raw Normal View History

#!/usr/bin/env python
"""
PathFinder - finds paths through voice leading graphs.
"""
from __future__ import annotations
import networkx as nx
from random import choices
from typing import Callable
from .chord import Chord
from .path import Path, PathStep
class PathFinder:
"""Finds paths through voice leading graphs."""
def __init__(self, graph: nx.MultiDiGraph):
self.graph = graph
def find_stochastic_path(
self,
start_chord: "Chord | None" = None,
max_length: int = 100,
weights_config: dict | None = None,
callback: Callable[[int, Path, dict], None] | None = None,
interval: int = 1,
) -> Path:
"""Find a stochastic path through the graph.
Returns:
Path object containing output chords, graph chords, and metadata
"""
if weights_config is None:
weights_config = self._default_weights_config()
chord = self._initialize_chords(start_chord)
if not chord or chord[0] is None or len(self.graph.nodes()) == 0:
return Path(chord[0] if chord else None, weights_config)
original_chord = chord[0]
path_obj = Path(original_chord, weights_config)
path_obj.init_state(
set(self.graph.nodes()), len(original_chord.pitches), original_chord
)
graph_node = original_chord
step_num = 0
for _ in range(max_length):
out_edges = list(self.graph.out_edges(graph_node, data=True))
if not out_edges:
break
# Build candidates using Path's state and factor methods
candidates = path_obj.get_candidates(out_edges, path_obj.output_chords)
# Compute weights using Path's method
path_obj.compute_weights(candidates, weights_config)
# Filter out candidates with zero weight
valid_candidates = [c for c in candidates if c.weight > 0]
if not valid_candidates:
break
# Select using weighted choice
chosen = choices(
valid_candidates, weights=[c.weight for c in valid_candidates]
)[0]
# Use path.step() to handle all voice-leading and state updates
path_obj.step(chosen)
graph_node = chosen.destination_node
step_num += 1
# Invoke callback if configured
if callback is not None and step_num % interval == 0:
callback(step_num, path_obj, weights_config)
return path_obj
def _initialize_chords(self, start_chord: "Chord | None") -> tuple:
"""Initialize chord sequence."""
if start_chord is not None:
return (start_chord,)
nodes = list(self.graph.nodes())
if nodes:
import random
random.shuffle(nodes)
weights_config = self._default_weights_config()
weights_config["voice_crossing_allowed"] = False
for chord in nodes[:50]:
out_edges = list(self.graph.out_edges(chord, data=True))
if len(out_edges) == 0:
continue
path = Path(chord, weights_config)
path.init_state(set(self.graph.nodes()), len(chord.pitches), chord)
candidates = path.get_candidates(out_edges, [chord])
path.compute_weights(candidates, weights_config)
nonzero = sum(1 for c in candidates if c.weight > 0)
if nonzero > 0:
return (chord,)
return (nodes[0],)
return (None,)
def _default_weights_config(self) -> dict:
"""Default weights configuration."""
return {
"contrary_motion": True,
"direct_tuning": True,
"voice_crossing_allowed": False,
"melodic_threshold_min": 0,
"melodic_threshold_max": 500,
"hamiltonian": True,
"dca": 2.0,
"target_range": False,
"target_range_octaves": 2.0,
}
def is_hamiltonian(self, path: list["Chord"]) -> bool:
"""Check if a path is Hamiltonian (visits all nodes exactly once)."""
return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path)