#!/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_register": False, "target_register_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)