Refactor DCA factor: rename to DCA Voice Movement and DCA Hamiltonian

- Rename _factor_dca to _factor_dca_voice_movement (tracks voice pitch changes)
- Rename _factor_hamiltonian to _factor_dca_hamiltonian (tracks node visit counts)
- Update CLI: --weight-dca -> --weight-dca-voice-movement
- Update CLI: --weight-hamiltonian -> --weight-dca-hamiltonian
- Remove hamiltonian coverage from analysis (no longer tracking)
This commit is contained in:
Michael Winter 2026-03-15 12:20:12 +01:00
parent 8cdbe90501
commit 218d7a55ff
3 changed files with 54 additions and 38 deletions

View file

@ -195,28 +195,17 @@ def format_analysis(metrics: dict) -> str:
f"Percentage: {metrics['contrary_motion_percent']:.1f}%",
f"Avg score: {metrics['contrary_motion_score']:.2f}",
"",
"--- DCA (Voice Stay) ---",
"--- DCA Voice Movement ---",
f"Avg stay count: {metrics['dca_avg_voice_stay']:.2f} steps",
f"Max stay count: {metrics['dca_max_voice_stay']} steps",
"",
"--- Hamiltonian ---",
f"Unique nodes: {metrics['hamiltonian_unique_nodes']}",
"--- Target Range ---",
f"Target: {metrics['target_octaves']} octaves ({metrics['target_cents']:.0f} cents)",
f"Start: {metrics['target_start_cents']:.0f} cents",
f"End: {metrics['target_end_cents']:.0f} cents",
f"Achieved: {metrics['target_actual_cents']:.0f} cents ({metrics['target_percent']:.1f}%)",
]
if metrics["hamiltonian_coverage"] is not None:
lines.append(f"Coverage: {metrics['hamiltonian_coverage']:.1f}%")
lines.extend(
[
"",
"--- Target Range ---",
f"Target: {metrics['target_octaves']} octaves ({metrics['target_cents']:.0f} cents)",
f"Start: {metrics['target_start_cents']:.0f} cents",
f"End: {metrics['target_end_cents']:.0f} cents",
f"Achieved: {metrics['target_actual_cents']:.0f} cents ({metrics['target_percent']:.1f}%)",
]
)
return "\n".join(lines)

View file

@ -44,6 +44,11 @@ class PathFinder:
graph_path = [graph_node]
# Track how long since each node was last visited (for DCA Hamiltonian)
node_visit_counts = {node: 0 for node in self.graph.nodes()}
# Mark start node as just visited
node_visit_counts[graph_node] = 0
from .pitch import Pitch
dims = output_chord.dims
@ -55,6 +60,10 @@ class PathFinder:
voice_stay_count = [0] * num_voices
for _ in range(max_length):
# Increment all node visit counts
for node in node_visit_counts:
node_visit_counts[node] += 1
out_edges = list(self.graph.out_edges(graph_node, data=True))
if not out_edges:
@ -68,6 +77,7 @@ class PathFinder:
tuple(voice_stay_count),
graph_path,
cumulative_trans,
node_visit_counts,
)
edge = choices(out_edges, weights=weights)[0]
@ -103,6 +113,10 @@ class PathFinder:
graph_node = next_graph_node
graph_path.append(graph_node)
# Reset visit count for visited node
if next_graph_node in node_visit_counts:
node_visit_counts[next_graph_node] = 0
path.append(output_chord)
last_graph_nodes = last_graph_nodes + (graph_node,)
if len(last_graph_nodes) > 2:
@ -164,6 +178,7 @@ class PathFinder:
voice_stay_count: tuple[int, ...] | None = None,
graph_path: list["Chord"] | None = None,
cumulative_trans: "Pitch | None" = None,
node_visit_counts: dict | None = None,
) -> list[float]:
"""Calculate weights for edges based on configuration.
@ -201,10 +216,12 @@ class PathFinder:
melodic_values.append(self._factor_melodic_threshold(edge_data, config))
contrary_values.append(self._factor_contrary_motion(edge_data, config))
hamiltonian_values.append(
self._factor_hamiltonian(edge, graph_path, config)
self._factor_dca_hamiltonian(edge, node_visit_counts, config)
)
dca_values.append(
self._factor_dca(edge, path, voice_stay_count, config, cumulative_trans)
self._factor_dca_voice_movement(
edge, path, voice_stay_count, config, cumulative_trans
)
)
target_values.append(
self._factor_target_range(edge, path, config, cumulative_trans)
@ -248,9 +265,9 @@ class PathFinder:
if contrary_norm:
w += contrary_norm[i] * config.get("weight_contrary_motion", 0)
if hamiltonian_norm:
w += hamiltonian_norm[i] * config.get("weight_hamiltonian", 1)
w += hamiltonian_norm[i] * config.get("weight_dca_hamiltonian", 1)
if dca_norm:
w += dca_norm[i] * config.get("weight_dca", 1)
w += dca_norm[i] * config.get("weight_dca_voice_movement", 1)
if target_norm:
w += target_norm[i] * config.get("weight_target_range", 1)
@ -321,23 +338,33 @@ class PathFinder:
distance = abs(num_up - ideal_up)
return max(0.0, 1.0 - (distance / ideal_up))
def _factor_hamiltonian(
self, edge: tuple, graph_path: list | None, config: dict
def _factor_dca_hamiltonian(
self, edge: tuple, node_visit_counts: dict | None, config: dict
) -> float:
"""Returns 1.0 if destination not visited, lower if already visited."""
# Check weight - if 0, return 1.0 (neutral)
if config.get("weight_hamiltonian", 1) == 0:
"""Returns probability based on how long since node was last visited.
DCA Hamiltonian: longer since visited = higher probability.
Similar to DCA voice movement but for graph nodes.
"""
if config.get("weight_dca_hamiltonian", 1) == 0:
return 1.0
if not config.get("hamiltonian", False):
if node_visit_counts is None:
return 1.0
destination = edge[1]
if graph_path and destination in graph_path:
return 0.1 # penalize revisiting
return 1.0
if destination not in node_visit_counts:
return 1.0
def _factor_dca(
visit_count = node_visit_counts[destination]
total_counts = sum(node_visit_counts.values())
if total_counts == 0:
return 1.0
return visit_count / total_counts
def _factor_dca_voice_movement(
self,
edge: tuple,
path: list,
@ -352,7 +379,7 @@ class PathFinder:
Higher probability = more likely to choose edge where long-staying voices change.
"""
if config.get("weight_dca", 1) == 0:
if config.get("weight_dca_voice_movement", 1) == 0:
return 1.0
if voice_stay_count is None or len(path) == 0:

View file

@ -303,16 +303,16 @@ def main():
help="Weight for contrary motion factor (0=disabled, default: 0)",
)
parser.add_argument(
"--weight-hamiltonian",
"--weight-dca-hamiltonian",
type=float,
default=1,
help="Weight for Hamiltonian factor (0=disabled, default: 1)",
help="Weight for DCA Hamiltonian factor - favors long-unvisited nodes (0=disabled, default: 1)",
)
parser.add_argument(
"--weight-dca",
"--weight-dca-voice-movement",
type=float,
default=1,
help="Weight for DCA factor - favors edges where voices stay (0=disabled, default: 1)",
help="Weight for DCA voice movement factor - favors voices that stay long to change (0=disabled, default: 1)",
)
parser.add_argument(
"--weight-target-range",
@ -435,8 +435,8 @@ def main():
# Soft factor weights
weights_config["weight_melodic"] = args.weight_melodic
weights_config["weight_contrary_motion"] = args.weight_contrary_motion
weights_config["weight_hamiltonian"] = args.weight_hamiltonian
weights_config["weight_dca"] = args.weight_dca
weights_config["weight_dca_hamiltonian"] = args.weight_dca_hamiltonian
weights_config["weight_dca_voice_movement"] = args.weight_dca_voice_movement
# Target range
if args.target_range > 0: