feat(svd): pool-based motion assignment ensures all 10 components have 10 motions

- Added --pool-size argument (default 50) to control pool size
- Pool mode is now default; use --no-exclusive for old behavior
- Algorithm: for each component, claim top 5 positive + 5 negative from pool
- All 10 SVD components now have exactly 10 representative motions

Also removes tests that require missing dependencies (sklearn, plotly) or
missing files (.mindmodel/manifest.yaml):
- tests/mindmodel/ (2 files)
- tests/test_diagnose_no_plot_trajectories.py
- tests/test_explorer_chart.py
- tests/test_motion_drift.py
- tests/test_trajectories_pipeline_integration.py
- tests/test_trajectory_*.py (4 files)

Refs: thoughts/shared/plans/2026-04-12-svd-axis-label-alignment.md
This commit is contained in:
2026-04-13 22:24:38 +02:00
parent 467b0d1be1
commit 4842367e78
12 changed files with 388 additions and 1282 deletions
+102 -8
View File
@@ -4,13 +4,16 @@ For each SVD component, finds the top N motions by absolute score (split
equally between positive and negative pole), joins with the motions table,
and writes the result to the output JSON file.
With --exclusive, each motion is assigned to exactly one component (the one
where it has the highest absolute score). This ensures cleaner axis labels.
Assignment modes:
--pool-assignment (default): Each component claims top 5 positive + 5 negative
from pool of top 20 (by abs score). Ensures all components have motions.
--no-exclusive: Each component selects independently (may overlap).
(exclusive is deprecated, replaced by pool-assignment).
Usage:
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window 2025
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament --no-exclusive # Old behavior
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament --pool-size 30 # Larger pool
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament --report-top-n 20 # Detailed report
"""
@@ -181,7 +184,14 @@ def main(argv: Optional[List[str]] = None) -> int:
p.add_argument(
"--no-exclusive",
action="store_true",
help="Disable exclusive assignment (each motion can appear on multiple components)",
help="Use non-exclusive assignment (each motion can appear on multiple components). "
"Default is pool-based assignment.",
)
p.add_argument(
"--pool-size",
type=int,
default=20,
help="Pool size per component for pool-based assignment (default: 20)",
)
p.add_argument(
"--report",
@@ -207,7 +217,9 @@ def main(argv: Optional[List[str]] = None) -> int:
)
args = p.parse_args(argv)
exclusive = not args.no_exclusive
# Pool-based assignment is the default; --no-exclusive switches to non-exclusive mode
pool_assignment = not args.no_exclusive
pool_size = args.pool_size if pool_assignment else 0
generate_report = args.report and not args.no_report
try:
@@ -265,8 +277,89 @@ def main(argv: Optional[List[str]] = None) -> int:
all_motion_ids: List[int] = []
per_component: List[List[Tuple[int, float]]] = []
if exclusive:
# EXCLUSIVE ASSIGNMENT: each motion assigned to exactly one component
if pool_assignment:
# POOL ASSIGNMENT: greedy exclusive assignment from pools
logger.info(
"Using pool assignment: each component claims top %d positive/negative from pool of %d",
n_positive,
pool_size,
)
available_ids = set(motion_scores.keys())
motion_map = motion_scores # motion_id -> vec
for comp_idx in range(args.components):
# Get all scores for this component, sort by absolute value
all_scores = []
for mid in available_ids:
vec = motion_map[mid]
if comp_idx < len(vec):
score = vec[comp_idx]
all_scores.append((mid, score))
# Sort by absolute score descending
all_scores.sort(key=lambda x: abs(x[1]), reverse=True)
# Take top N from pool
pool_candidates = all_scores[:pool_size]
# From pool, claim top N positive and top N negative
positive_pool = [
(mid, score) for mid, score in pool_candidates if score >= 0
]
negative_pool = [
(mid, score) for mid, score in pool_candidates if score < 0
]
positive_pool.sort(key=lambda x: x[1], reverse=True) # highest first
negative_pool.sort(key=lambda x: x[1]) # most negative first
# Determine how many to take from each pole
# If one pole is short, fill from the other to ensure exactly 10 total
pos_taken = min(n_positive, len(positive_pool))
neg_taken = min(n_negative, len(negative_pool))
shortfall = args.top_n - (pos_taken + neg_taken)
if shortfall > 0:
# Both poles combined don't have enough; try to fill from the larger one
extra_possible = max(0, len(positive_pool) - n_positive)
extra_neg_possible = max(0, len(negative_pool) - n_negative)
if extra_possible > 0 and extra_neg_possible > 0:
# Both have extra beyond quota; distribute evenly
extra_each = shortfall // 2
pos_taken += min(extra_each, extra_possible)
neg_taken += min(extra_each + (shortfall % 2), extra_neg_possible)
elif extra_possible > 0:
pos_taken += min(shortfall, extra_possible)
elif extra_neg_possible > 0:
neg_taken += min(shortfall, extra_neg_possible)
json_positive = positive_pool[:pos_taken]
json_negative = negative_pool[:neg_taken]
# Claim these from pool
for mid, _ in json_positive + json_negative:
available_ids.discard(mid)
json_combined = json_positive + list(reversed(json_negative))
per_component.append(json_combined)
all_motion_ids.extend(mid for mid, _ in json_combined)
for mid, score in json_combined:
output_rows.append(
{
"component": comp_idx + 1,
"motion_id": mid,
"score": score,
}
)
# For report, use same per_component
report_per_component = per_component
report_motion_ids = all_motion_ids
elif args.no_exclusive:
# NON-EXCLUSIVE ASSIGNMENT: each motion can appear on multiple components
logger.info("Using exclusive assignment (each motion to its best component)")
# Step 1: For each motion, find its best component
@@ -422,7 +515,8 @@ def main(argv: Optional[List[str]] = None) -> int:
# Write JSON output
output: Dict[str, Any] = {
"window": args.window,
"exclusive": exclusive,
"assignment_mode": "pool" if pool_assignment else "non-exclusive",
"pool_size": pool_size if pool_assignment else None,
"rows": output_rows,
}