Refactor: Move factor methods to Path class, add normalized_scores

- Move all _factor_* methods from pathfinder.py to path.py
- Add get_candidates() and compute_weights() to Path class
- Simplify step() to just commit chosen candidate
- Add normalized_scores field for consistent influence calculation
- Remove duplicate transposition/voice_map logic between get_candidates and step
- dca_voice_movement and target_range now use destination_chord directly
This commit is contained in:
Michael Winter 2026-03-16 18:59:13 +01:00
parent 482f2b0df5
commit 7809fa5a76
2 changed files with 381 additions and 453 deletions

View file

@ -22,11 +22,14 @@ class PathStep:
transposition: Pitch | None = None transposition: Pitch | None = None
movements: dict[int, int] = field(default_factory=dict) movements: dict[int, int] = field(default_factory=dict)
scores: dict[str, float] = field(default_factory=dict) scores: dict[str, float] = field(default_factory=dict)
weight: float = 0.0 # computed later by _compute_weights normalized_scores: dict[str, float | None] = field(default_factory=dict)
weight: float = 0.0 # computed later by compute_weights
last_visited_counts_before: dict | None = None last_visited_counts_before: dict | None = None
last_visited_counts_after: dict | None = None last_visited_counts_after: dict | None = None
sustain_counts_before: tuple[int, ...] | None = None sustain_counts_before: tuple[int, ...] | None = None
sustain_counts_after: tuple[int, ...] | None = None sustain_counts_after: tuple[int, ...] | None = None
new_cumulative_trans: Pitch | None = None
new_voice_map: list[int] = field(default_factory=list)
class Path: class Path:
@ -56,6 +59,350 @@ class Path:
dims = initial_chord.dims dims = initial_chord.dims
self._cumulative_trans = Pitch(tuple(0 for _ in range(len(dims))), dims) self._cumulative_trans = Pitch(tuple(0 for _ in range(len(dims))), dims)
def get_candidates(
self, out_edges: list, path_chords: list[Chord]
) -> list[PathStep]:
"""Generate candidates from graph edges using current state.
Applies current cumulative_trans and voice_map to create destination_chord.
Computes all factor scores.
"""
if not out_edges or not path_chords:
return []
source_chord = path_chords[-1]
candidates = []
for edge in out_edges:
source_node = edge[0]
destination_node = edge[1]
edge_data = edge[2]
trans = edge_data.get("transposition")
movement = edge_data.get("movements", {})
# Compute new state after this candidate
if trans is not None and self._cumulative_trans is not None:
new_cumulative_trans = self._cumulative_trans.transpose(trans)
else:
new_cumulative_trans = self._cumulative_trans
new_voice_map = [None] * len(self._voice_map)
for src_idx, dest_idx in movement.items():
new_voice_map[dest_idx] = self._voice_map[src_idx]
# Transpose destination node using current cumulative_trans
if trans is not None and self._cumulative_trans is not None:
transposed = destination_node.transpose(
self._cumulative_trans.transpose(trans)
)
else:
transposed = destination_node
# Apply voice map (reorder pitches)
reordered_pitches = tuple(
transposed.pitches[new_voice_map[i]] for i in range(len(new_voice_map))
)
destination_chord = Chord(reordered_pitches, destination_node.dims)
# Compute all factor scores
last_visited_before = self._get_last_visited_counts()
sustain_before = self._get_sustain_counts()
scores = {
"direct_tuning": self._factor_direct_tuning(
edge_data, self.weights_config
),
"voice_crossing": self._factor_voice_crossing(
edge_data, self.weights_config
),
"melodic_threshold": self._factor_melodic_threshold(
edge_data, self.weights_config
),
"contrary_motion": self._factor_contrary_motion(
edge_data, self.weights_config
),
"dca_hamiltonian": self._factor_dca_hamiltonian(
destination_node, last_visited_before, self.weights_config
),
"dca_voice_movement": self._factor_dca_voice_movement(
source_chord,
destination_chord,
sustain_before,
self.weights_config,
),
"target_range": self._factor_target_range(
path_chords,
destination_chord,
self.weights_config,
),
}
# Compute AFTER state for this candidate
last_visited_after = dict(last_visited_before)
for node in last_visited_after:
last_visited_after[node] += 1
last_visited_after[destination_node] = 0
sustain_after = list(sustain_before)
for voice_idx in range(len(sustain_after)):
curr_cents = source_chord.pitches[voice_idx].to_cents()
next_cents = destination_chord.pitches[voice_idx].to_cents()
if curr_cents == next_cents:
sustain_after[voice_idx] += 1
else:
sustain_after[voice_idx] = 0
step = PathStep(
source_node=source_node,
destination_node=destination_node,
source_chord=source_chord,
destination_chord=destination_chord,
transposition=trans,
movements=movement,
scores=scores,
last_visited_counts_before=last_visited_before,
last_visited_counts_after=last_visited_after,
sustain_counts_before=sustain_before,
sustain_counts_after=tuple(sustain_after),
new_cumulative_trans=new_cumulative_trans,
new_voice_map=new_voice_map,
)
candidates.append(step)
return candidates
def compute_weights(self, candidates: list[PathStep], config: dict) -> list[float]:
"""Compute weights from raw scores for all candidates.
Updates each step's weight field.
"""
if not candidates:
return []
melodic_values = [c.scores.get("melodic_threshold", 0) for c in candidates]
contrary_values = [c.scores.get("contrary_motion", 0) for c in candidates]
hamiltonian_values = [c.scores.get("dca_hamiltonian", 0) for c in candidates]
dca_values = [c.scores.get("dca_voice_movement", 0) for c in candidates]
target_values = [c.scores.get("target_range", 0) for c in candidates]
def sum_normalize(values: list) -> list | None:
"""Normalize values to sum to 1. Returns None if no discrimination."""
total = sum(values)
if total == 0 or len(set(values)) <= 1:
return None
return [v / total for v in values]
melodic_norm = sum_normalize(melodic_values)
contrary_norm = sum_normalize(contrary_values)
hamiltonian_norm = sum_normalize(hamiltonian_values)
dca_norm = sum_normalize(dca_values)
target_norm = sum_normalize(target_values)
weights = []
for i, step in enumerate(candidates):
scores = step.scores
w = 1.0
w *= scores.get("direct_tuning", 0)
if w == 0:
step.weight = 0.0
weights.append(0.0)
continue
w *= scores.get("voice_crossing", 0)
if w == 0:
step.weight = 0.0
weights.append(0.0)
continue
if melodic_norm:
w += melodic_norm[i] * config.get("weight_melodic", 1)
if contrary_norm:
w += contrary_norm[i] * config.get("weight_contrary_motion", 0)
if hamiltonian_norm:
w += hamiltonian_norm[i] * config.get("weight_dca_hamiltonian", 1)
if dca_norm:
w += dca_norm[i] * config.get("weight_dca_voice_movement", 1)
if target_norm:
w += target_norm[i] * config.get("weight_target_range", 1)
step.weight = w
weights.append(w)
# Store normalized scores (0-1 range) for influence calculation
step.normalized_scores = {
"melodic_threshold": melodic_norm[i] if melodic_norm else None,
"contrary_motion": contrary_norm[i] if contrary_norm else None,
"dca_hamiltonian": hamiltonian_norm[i] if hamiltonian_norm else None,
"dca_voice_movement": dca_norm[i] if dca_norm else None,
"target_range": target_norm[i] if target_norm else None,
}
return weights
# ========== Factor Methods ==========
def _factor_melodic_threshold(self, edge_data: dict, config: dict) -> float:
"""Returns continuous score based on melodic threshold."""
melodic_min = config.get("melodic_threshold_min", 0)
melodic_max = config.get("melodic_threshold_max", float("inf"))
cent_diffs = edge_data.get("cent_diffs", [])
if not cent_diffs:
return 1.0
product = 1.0
for cents in cent_diffs:
abs_cents = abs(cents)
if abs_cents == 0:
score = 1.0
elif abs_cents < melodic_min:
score = (abs_cents / melodic_min) ** 3
elif abs_cents > melodic_max:
score = ((1200 - abs_cents) / (1200 - melodic_max)) ** 3
else:
score = 1.0
product *= score
return product
def _factor_direct_tuning(self, edge_data: dict, config: dict) -> float:
"""Returns 1.0 if directly tunable (or disabled), 0.0 otherwise."""
if config.get("weight_direct_tuning", 1) == 0:
return 1.0
if config.get("direct_tuning", True):
if edge_data.get("is_directly_tunable", False):
return 1.0
return 0.0
return 1.0
def _factor_voice_crossing(self, edge_data: dict, config: dict) -> float:
"""Returns 1.0 if no voice crossing (or allowed), 0.0 if crossing."""
if config.get("voice_crossing_allowed", False):
return 1.0
if edge_data.get("voice_crossing", False):
return 0.0
return 1.0
def _factor_contrary_motion(self, edge_data: dict, config: dict) -> float:
"""Returns factor based on contrary motion."""
if config.get("weight_contrary_motion", 0) == 0:
return 1.0
cent_diffs = edge_data.get("cent_diffs", [])
num_up = sum(1 for d in cent_diffs if d > 0)
num_down = sum(1 for d in cent_diffs if d < 0)
num_moving = num_up + num_down
if num_moving < 2:
return 0.0
ideal_up = num_moving / 2
distance = abs(num_up - ideal_up)
return max(0.0, 1.0 - (distance / ideal_up))
def _factor_dca_hamiltonian(
self, destination_node, last_visited_counts: dict, config: dict
) -> float:
"""Returns score based on how long since node was last visited."""
if config.get("weight_dca_hamiltonian", 1) == 0:
return 1.0
if last_visited_counts is None:
return 0.0
visit_count = last_visited_counts.get(destination_node, 0)
return float(visit_count)
def _factor_dca_voice_movement(
self,
source_chord: Chord,
destination_chord: Chord,
sustain_counts: tuple[int, ...],
config: dict,
) -> float:
"""Returns probability that voices will change."""
if config.get("weight_dca_voice_movement", 1) == 0:
return 1.0
if sustain_counts is None:
return 1.0
num_voices = len(sustain_counts)
if num_voices == 0:
return 1.0
current_cents = [p.to_cents() for p in source_chord.pitches]
candidate_cents = [p.to_cents() for p in destination_chord.pitches]
sum_changing = 0
sum_all = sum(sustain_counts)
for voice_idx in range(num_voices):
if current_cents[voice_idx] != candidate_cents[voice_idx]:
sum_changing += sustain_counts[voice_idx]
return sum_changing
def _factor_target_range(
self,
path_chords: list[Chord],
destination_chord: Chord,
config: dict,
) -> float:
"""Returns factor based on movement toward target."""
if config.get("weight_target_range", 1) == 0:
return 1.0
if not config.get("target_range", False):
return 1.0
if len(path_chords) == 0:
return 1.0
target_octaves = config.get("target_range_octaves", 2.0)
max_path = config.get("max_path", 50)
target_cents = target_octaves * 1200
start_avg_cents = sum(p.to_cents() for p in path_chords[0].pitches) / len(
path_chords[0].pitches
)
progress = len(path_chords) / max_path
current_target = start_avg_cents + (progress * target_cents)
current_chord = path_chords[-1]
current_avg_cents = sum(p.to_cents() for p in current_chord.pitches) / len(
current_chord.pitches
)
candidate_avg_cents = sum(
p.to_cents() for p in destination_chord.pitches
) / len(destination_chord.pitches)
if current_target <= 0:
return 1.0
dist_before = abs(current_avg_cents - current_target)
dist_after = abs(candidate_avg_cents - current_target)
if dist_before == 0:
return 1.0
if dist_after < dist_before:
return 1.0 + (dist_before - dist_after) / dist_before
elif dist_after > dist_before:
return max(0.1, 1.0 - (dist_after - dist_before) / dist_before)
else:
return 1.0
# ========== State Methods ==========
def _get_last_visited_counts(self) -> dict: def _get_last_visited_counts(self) -> dict:
"""Get last visited counts from the last step, or initialize fresh.""" """Get last visited counts from the last step, or initialize fresh."""
if self.steps: if self.steps:
@ -63,7 +410,6 @@ class Path:
if last_step.last_visited_counts_after is not None: if last_step.last_visited_counts_after is not None:
return dict(last_step.last_visited_counts_after) return dict(last_step.last_visited_counts_after)
# Initialize fresh: all nodes start at 0 (except initial which we set to 0 explicitly)
return {node: 0 for node in self._graph_nodes} return {node: 0 for node in self._graph_nodes}
def _get_sustain_counts(self) -> tuple: def _get_sustain_counts(self) -> tuple:
@ -73,52 +419,19 @@ class Path:
if last_step.sustain_counts_after is not None: if last_step.sustain_counts_after is not None:
return last_step.sustain_counts_after return last_step.sustain_counts_after
# Initialize fresh: all voices start at 0
return tuple(0 for _ in range(self._num_voices)) return tuple(0 for _ in range(self._num_voices))
def step(self, step: PathStep) -> PathStep: def step(self, step: PathStep) -> PathStep:
"""Add a completed step to the path. """Commit a chosen candidate to the path.
Takes a PathStep (computed as a hypothetical step), updates internal state, All state was computed in get_candidates().
and adds it to the path. This just applies the stored new state and commits the step.
""" """
# Update cumulative transposition # Apply stored new state
if step.transposition is not None: self._cumulative_trans = step.new_cumulative_trans
self._cumulative_trans = self._cumulative_trans.transpose( self._voice_map = step.new_voice_map
step.transposition
)
# Update voice map based on movement
new_voice_map = [None] * len(self._voice_map)
for src_idx, dest_idx in step.movements.items():
new_voice_map[dest_idx] = self._voice_map[src_idx]
self._voice_map = new_voice_map
# Get BEFORE state from last step (or initialize fresh)
last_visited_before = self._get_last_visited_counts()
sustain_before = self._get_sustain_counts()
# Compute AFTER state
last_visited_after = dict(last_visited_before)
for node in last_visited_after:
last_visited_after[node] += 1
last_visited_after[step.destination_node] = 0
sustain_after = list(sustain_before)
for voice_idx in range(len(sustain_after)):
curr_cents = step.source_chord.pitches[voice_idx].to_cents()
next_cents = step.destination_chord.pitches[voice_idx].to_cents()
if curr_cents == next_cents:
sustain_after[voice_idx] += 1
else:
sustain_after[voice_idx] = 0
# Update step with computed state
step.last_visited_counts_before = last_visited_before
step.last_visited_counts_after = last_visited_after
step.sustain_counts_before = sustain_before
step.sustain_counts_after = tuple(sustain_after)
# Commit
self.steps.append(step) self.steps.append(step)
return step return step
@ -143,8 +456,7 @@ class Path:
def get_influence(self, weights: dict[str, Any]) -> dict[str, float]: def get_influence(self, weights: dict[str, Any]) -> dict[str, float]:
"""Compute weighted score contribution per factor for chosen candidates. """Compute weighted score contribution per factor for chosen candidates.
Returns a dict mapping factor name to accumulated influence (weight * score) Uses normalized scores (0-1 range) for consistent influence across factors.
for all steps in the path.
""" """
influence = { influence = {
"melodic": 0.0, "melodic": 0.0,
@ -154,24 +466,25 @@ class Path:
"target_range": 0.0, "target_range": 0.0,
} }
for step in self.steps:
scores = step.scores
w_melodic = weights.get("weight_melodic", 1) w_melodic = weights.get("weight_melodic", 1)
w_contrary = weights.get("weight_contrary_motion", 0) w_contrary = weights.get("weight_contrary_motion", 0)
w_hamiltonian = weights.get("weight_dca_hamiltonian", 1) w_hamiltonian = weights.get("weight_dca_hamiltonian", 1)
w_dca = weights.get("weight_dca_voice_movement", 1) w_dca = weights.get("weight_dca_voice_movement", 1)
w_target = weights.get("weight_target_range", 1) w_target = weights.get("weight_target_range", 1)
influence["melodic"] += scores.get("melodic_threshold", 0) * w_melodic for step in self.steps:
norm = step.normalized_scores
influence["melodic"] += (norm.get("melodic_threshold") or 0) * w_melodic
influence["contrary_motion"] += ( influence["contrary_motion"] += (
scores.get("contrary_motion", 0) * w_contrary norm.get("contrary_motion") or 0
) ) * w_contrary
influence["dca_hamiltonian"] += ( influence["dca_hamiltonian"] += (
scores.get("dca_hamiltonian", 0) * w_hamiltonian norm.get("dca_hamiltonian") or 0
) ) * w_hamiltonian
influence["dca_voice_movement"] += ( influence["dca_voice_movement"] += (
scores.get("dca_voice_movement", 0) * w_dca norm.get("dca_voice_movement") or 0
) ) * w_dca
influence["target_range"] += scores.get("target_range", 0) * w_target influence["target_range"] += (norm.get("target_range") or 0) * w_target
return influence return influence

View file

@ -5,7 +5,7 @@ PathFinder - finds paths through voice leading graphs.
from __future__ import annotations from __future__ import annotations
import networkx as nx import networkx as nx
from random import choices, seed from random import choices
from typing import Callable from typing import Callable
from .chord import Chord from .chord import Chord
@ -54,29 +54,11 @@ class PathFinder:
if not out_edges: if not out_edges:
break break
# Derive state from last step (or initialize fresh for step 0) # Build candidates using Path's state and factor methods
if path_obj.steps: candidates = path_obj.get_candidates(out_edges, path_obj.output_chords)
last_step = path_obj.steps[-1]
sustain_counts = last_step.sustain_counts_after
last_visited_counts = last_step.last_visited_counts_after
else:
# First step - derive from path object's current state
sustain_counts = tuple(0 for _ in range(len(path_obj._voice_map)))
last_visited_counts = {node: 0 for node in set(self.graph.nodes())}
# Build candidates with raw scores # Compute weights using Path's method
candidates = self._build_candidates( path_obj.compute_weights(candidates, weights_config)
out_edges,
path_obj.output_chords,
weights_config,
sustain_counts,
path_obj.graph_chords,
path_obj._cumulative_trans,
last_visited_counts,
)
# Compute weights from raw scores
self._compute_weights(candidates, weights_config)
# Filter out candidates with zero weight # Filter out candidates with zero weight
valid_candidates = [c for c in candidates if c.weight > 0] valid_candidates = [c for c in candidates if c.weight > 0]
@ -100,158 +82,6 @@ class PathFinder:
return path_obj return path_obj
def _build_candidates(
self,
out_edges: list,
path: list["Chord"],
config: dict,
sustain_counts: tuple[int, ...] | None,
graph_path: list["Chord"] | None,
cumulative_trans: "Pitch | None",
last_visited_counts: dict | None,
) -> list[PathStep]:
"""Build hypothetical path steps with raw factor scores."""
if not out_edges:
return []
if not path:
return []
source_chord = path[-1]
candidates = []
for i, edge in enumerate(out_edges):
source_node = edge[0]
destination_node = edge[1]
edge_data = edge[2]
trans = edge_data.get("transposition")
movement = edge_data.get("movements", {})
# Transpose destination node
if trans is not None and cumulative_trans is not None:
transposed = destination_node.transpose(
cumulative_trans.transpose(trans)
)
else:
transposed = destination_node
# Apply voice map
voice_map = list(range(len(source_chord.pitches)))
new_voice_map = [None] * len(voice_map)
for src_idx, dest_idx in movement.items():
new_voice_map[dest_idx] = voice_map[src_idx]
reordered_pitches = tuple(
transposed.pitches[new_voice_map[i]] for i in range(len(new_voice_map))
)
destination_chord = Chord(reordered_pitches, destination_node.dims)
# All factors - always compute verbatim
direct_tuning = self._factor_direct_tuning(edge_data, config)
voice_crossing = self._factor_voice_crossing(edge_data, config)
melodic = self._factor_melodic_threshold(edge_data, config)
contrary = self._factor_contrary_motion(edge_data, config)
hamiltonian = self._factor_dca_hamiltonian(
edge, last_visited_counts, config
)
dca_voice = self._factor_dca_voice_movement(
edge, path, sustain_counts, config, cumulative_trans
)
target = self._factor_target_range(edge, path, config, cumulative_trans)
scores = {
"direct_tuning": direct_tuning,
"voice_crossing": voice_crossing,
"melodic_threshold": melodic,
"contrary_motion": contrary,
"dca_hamiltonian": hamiltonian,
"dca_voice_movement": dca_voice,
"target_range": target,
}
step = PathStep(
source_node=source_node,
destination_node=destination_node,
source_chord=source_chord,
destination_chord=destination_chord,
transposition=trans,
movements=movement,
scores=scores,
)
candidates.append(step)
return candidates
def _compute_weights(
self,
candidates: list[PathStep],
config: dict,
) -> list[float]:
"""Compute weights from raw scores for all candidates.
Returns a list of weights, and updates each step's weight field.
"""
if not candidates:
return []
# Collect raw values for normalization
melodic_values = [c.scores.get("melodic_threshold", 0) for c in candidates]
contrary_values = [c.scores.get("contrary_motion", 0) for c in candidates]
hamiltonian_values = [c.scores.get("dca_hamiltonian", 0) for c in candidates]
dca_values = [c.scores.get("dca_voice_movement", 0) for c in candidates]
target_values = [c.scores.get("target_range", 0) for c in candidates]
# Helper function for sum normalization
def sum_normalize(values: list) -> list | None:
"""Normalize values to sum to 1. Returns None if no discrimination."""
total = sum(values)
if total == 0 or len(set(values)) <= 1:
return None
return [v / total for v in values]
# Sum normalize each factor
melodic_norm = sum_normalize(melodic_values)
contrary_norm = sum_normalize(contrary_values)
hamiltonian_norm = sum_normalize(hamiltonian_values)
dca_norm = sum_normalize(dca_values)
target_norm = sum_normalize(target_values)
# Calculate weights for each candidate
weights = []
for i, step in enumerate(candidates):
scores = step.scores
w = 1.0
# Hard factors (multiplicative - eliminates if 0)
w *= scores.get("direct_tuning", 0)
if w == 0:
step.weight = 0.0
weights.append(0.0)
continue
w *= scores.get("voice_crossing", 0)
if w == 0:
step.weight = 0.0
weights.append(0.0)
continue
# Soft factors (sum normalized, then weighted)
if melodic_norm:
w += melodic_norm[i] * config.get("weight_melodic", 1)
if contrary_norm:
w += contrary_norm[i] * config.get("weight_contrary_motion", 0)
if hamiltonian_norm:
w += hamiltonian_norm[i] * config.get("weight_dca_hamiltonian", 1)
if dca_norm:
w += dca_norm[i] * config.get("weight_dca_voice_movement", 1)
if target_norm:
w += target_norm[i] * config.get("weight_target_range", 1)
step.weight = w
weights.append(w)
return weights
def _initialize_chords(self, start_chord: "Chord | None") -> tuple: def _initialize_chords(self, start_chord: "Chord | None") -> tuple:
"""Initialize chord sequence.""" """Initialize chord sequence."""
if start_chord is not None: if start_chord is not None:
@ -271,9 +101,10 @@ class PathFinder:
if len(out_edges) == 0: if len(out_edges) == 0:
continue continue
candidates = self._build_candidates( path = Path(chord, weights_config)
out_edges, [chord], weights_config, None, None, None, None 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) nonzero = sum(1 for c in candidates if c.weight > 0)
if nonzero > 0: if nonzero > 0:
@ -297,222 +128,6 @@ class PathFinder:
"target_range_octaves": 2.0, "target_range_octaves": 2.0,
} }
def _factor_melodic_threshold(self, edge_data: dict, config: dict) -> float:
"""Returns continuous score based on melodic threshold.
- cents == 0: score = 1.0 (no movement is always ideal)
- Below min (0 < cents < min): score = (cents / min)^3
- Within range (min <= cents <= max): score = 1.0
- Above max (cents > max): score = ((1200 - cents) / (1200 - max))^3
Returns product of all voice scores.
"""
melodic_min = config.get("melodic_threshold_min", 0)
melodic_max = config.get("melodic_threshold_max", float("inf"))
cent_diffs = edge_data.get("cent_diffs", [])
if not cent_diffs:
return 1.0
product = 1.0
for cents in cent_diffs:
abs_cents = abs(cents)
if abs_cents == 0:
score = 1.0
elif abs_cents < melodic_min:
score = (abs_cents / melodic_min) ** 3
elif abs_cents > melodic_max:
score = ((1200 - abs_cents) / (1200 - melodic_max)) ** 3
else:
score = 1.0
product *= score
return product
def _factor_direct_tuning(self, edge_data: dict, config: dict) -> float:
"""Returns 1.0 if directly tunable (or disabled), 0.0 otherwise."""
# Check weight - if 0, return 1.0 (neutral)
if config.get("weight_direct_tuning", 1) == 0:
return 1.0
if config.get("direct_tuning", True):
if edge_data.get("is_directly_tunable", False):
return 1.0
return 0.0
return 1.0 # not configured, neutral
def _factor_voice_crossing(self, edge_data: dict, config: dict) -> float:
"""Returns 1.0 if no voice crossing (or allowed), 0.0 if crossing and not allowed."""
if config.get("voice_crossing_allowed", False):
return 1.0
if edge_data.get("voice_crossing", False):
return 0.0
return 1.0
def _factor_contrary_motion(self, edge_data: dict, config: dict) -> float:
"""Returns factor based on contrary motion.
Contrary motion: half of moving voices go one direction, half go opposite.
Weighted by closeness to ideal half split.
factor = 1.0 - (distance_from_half / half)
"""
if config.get("weight_contrary_motion", 0) == 0:
return 1.0
cent_diffs = edge_data.get("cent_diffs", [])
num_up = sum(1 for d in cent_diffs if d > 0)
num_down = sum(1 for d in cent_diffs if d < 0)
num_moving = num_up + num_down
if num_moving < 2:
return 0.0 # Need at least 2 moving voices for contrary motion
ideal_up = num_moving / 2
distance = abs(num_up - ideal_up)
return max(0.0, 1.0 - (distance / ideal_up))
def _factor_dca_hamiltonian(
self, edge: tuple, last_visited_counts: dict | None, config: dict
) -> float:
"""Returns score based on how long since node was last visited.
DCA Hamiltonian: longer since visited = higher score.
"""
if config.get("weight_dca_hamiltonian", 1) == 0:
return 1.0
if last_visited_counts is None:
return 0.0
destination = edge[1]
visit_count = last_visited_counts.get(destination, 0)
# Return the visit count - higher is better (more steps since last visit)
return float(visit_count)
def _factor_dca_voice_movement(
self,
edge: tuple,
path: list,
sustain_counts: tuple[int, ...] | None,
config: dict,
cumulative_trans: "Pitch | None",
) -> float:
"""Returns probability that voices will change.
DCA = Dissonant Counterpoint Algorithm
Probability = (sum of sustain_counts for changing voices) / (sum of ALL sustain_counts)
Higher probability = more likely to choose edge where long-staying voices change.
"""
if config.get("weight_dca_voice_movement", 1) == 0:
return 1.0
if sustain_counts is None or len(path) == 0:
return 1.0
if cumulative_trans is None:
return 1.0
num_voices = len(sustain_counts)
if num_voices == 0:
return 1.0
current_chord = path[-1]
edge_data = edge[2]
next_graph_node = edge[1]
trans = edge_data.get("transposition")
if trans is not None:
candidate_transposed = next_graph_node.transpose(
cumulative_trans.transpose(trans)
)
else:
candidate_transposed = next_graph_node.transpose(cumulative_trans)
current_cents = [p.to_cents() for p in current_chord.pitches]
candidate_cents = [p.to_cents() for p in candidate_transposed.pitches]
sum_changing = 0
sum_all = sum(sustain_counts)
if sum_all == 0:
return 1.0
for voice_idx in range(num_voices):
if current_cents[voice_idx] != candidate_cents[voice_idx]:
sum_changing += sustain_counts[voice_idx]
return sum_changing / sum_all
def _factor_target_range(
self,
edge: tuple,
path: list,
config: dict,
cumulative_trans: "Pitch | None",
) -> float:
"""Returns factor based on movement toward target.
Target progresses based on position in path.
Uses average cents of current chord for accurate targeting.
Factor > 1.0 if moving toward target, < 1.0 if moving away.
"""
if config.get("weight_target_range", 1) == 0:
return 1.0
if not config.get("target_range", False):
return 1.0
if len(path) == 0 or cumulative_trans is None:
return 1.0
target_octaves = config.get("target_range_octaves", 2.0)
max_path = config.get("max_path", 50)
target_cents = target_octaves * 1200
start_avg_cents = sum(p.to_cents() for p in path[0].pitches) / len(
path[0].pitches
)
progress = len(path) / max_path
current_target = start_avg_cents + (progress * target_cents)
current_chord = path[-1]
current_avg_cents = sum(p.to_cents() for p in current_chord.pitches) / len(
current_chord.pitches
)
edge_data = edge[2]
next_graph_node = edge[1]
edge_trans = edge_data.get("transposition")
if edge_trans is not None:
candidate_transposed = next_graph_node.transpose(
cumulative_trans.transpose(edge_trans)
)
else:
candidate_transposed = next_graph_node.transpose(cumulative_trans)
candidate_avg_cents = sum(
p.to_cents() for p in candidate_transposed.pitches
) / len(candidate_transposed.pitches)
if current_target <= 0:
return 1.0
dist_before = abs(current_avg_cents - current_target)
dist_after = abs(candidate_avg_cents - current_target)
if dist_before == 0:
return 1.0
if dist_after < dist_before:
return 1.0 + (dist_before - dist_after) / dist_before
elif dist_after > dist_before:
return max(0.1, 1.0 - (dist_after - dist_before) / dist_before)
else:
return 1.0
def is_hamiltonian(self, path: list["Chord"]) -> bool: def is_hamiltonian(self, path: list["Chord"]) -> bool:
"""Check if a path is Hamiltonian (visits all nodes exactly once).""" """Check if a path is Hamiltonian (visits all nodes exactly once)."""
return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path) return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path)