5.9 KiB
| date | topic | status |
|---|---|---|
| 2026-03-30 | compass-trajectory-consistency | validated |
Problem Statement
What we're solving and why
We must ensure the political compass (single-window snapshot) and the Explorer trajectories use the same numeric coordinate frame for the first two SVD axes so the compass numbers match the trajectory centroids exactly.
Key issue: Component 1 already matched, but component 2 shows persistent mismatches due to an API/shape ambiguity and occasional fallback logic differences. Fixing this prevents confusing, inconsistent numbers in the UI.
Constraints
Non-negotiables and limitations
- The canonical coordinate frame is the Procrustes-aligned output of compute_2d_axes (the repo artifact that produces positions_by_window).
- Keep UI responsiveness and existing cache usage (@st.cache_data where present).
- Minimal, focused changes: only update Explorer call sites and the compass renderer API. Do not change the SVD pipeline outputs.
- Use the first chronological party vector as the fallback when a party has no MPs in a window (user decision).
Approach
Chosen approach and why
We will adopt an explicit API for the compass renderer: pass per-party 2D projected coordinates (party → (x,y)) computed from positions_by_window for the target window. This eliminates shape/indexing ambiguity and guarantees numeric equality with trajectory centroids.
Why:
- Simpler and less error-prone than synthesizing k-dimensional vectors or changing compute_2d_axes.
- Keeps the canonical data source unchanged (positions_by_window) and makes intent explicit at the Explorer surface.
- Easy to test: we can assert numeric equality directly on the 2D coordinates.
Architecture
High-level structure of the change
Key pieces:
- compute_2d_axes (unchanged): produces positions_by_window which is the canonical frame.
- Explorer: party centroid helper: new helper that computes per-party (x, y) means from positions_by_window for a window.
- _build_party_axis_figure (changed API): now accepts party_coords: Dict[str, Tuple[float,float]] and a selected component index (1 or 2) and uses the explicit coordinate values for plotting.
- Call-site updates: update all places that previously passed party SVD vectors to instead compute and pass party_coords (use first-chronological party vector only when no MPs are present for that party in the window).
Components
Key pieces and responsibilities
-
compute_party_coords(positions_by_window, party_map, window_id):
- Input: positions_by_window, party->MP mapping (load_party_map or similar), window id.
- Output: party -> (x_mean, y_mean). If no MPs for a party, returns None or uses fallback loader.
-
_build_party_axis_figure(party_coords, comp_sel, ...):
- Input: explicit 2D coords; comp_sel ∈ {1,2}.
- Behavior: uses party_coords[p][comp_sel-1] as the axis value, constructs hover text, CIs, and plots. No indexing into long SVD vectors.
-
Fallback loader: existing load_party_axis_scores (unchanged). When compute_party_coords finds no MPs, we will use the party's first chronological vector from load_party_axis_scores(window) as fallback and indicate fallback in hover text.
-
Callers to update:
- build_svd_components_tab
- any other explorer function that previously passed party-axis vectors into _build_party_axis_figure
Data Flow
How data moves through the updated code path
- UI requests compass for window W and component C.
- Explorer calls load_positions(db_path) → gets positions_by_window.
- compute_party_coords builds per-party (x,y) means from positions_by_window[W].
- For parties with zero MPs in W, call load_party_axis_scores(window) and take the first chronological party vector as fallback; annotate hover that a fallback is used.
- Pass party_coords to _build_party_axis_figure which reads comp_sel and uses the explicit coordinate at index 0 or 1.
- Explorer trajectories tab already computes the same centroids from positions_by_window; therefore numbers match exactly.
Error Handling
Strategy for failures and edge cases
- If positions_by_window is missing or corrupted: surface a clear diagnostic message in the UI recommending running the SVD recompute pipeline, and avoid attempting to plot mismatched values.
- If a party has no MPs and load_party_axis_scores also returns no data: omit that party from the compass and add a tooltip note in the UI explaining why.
- If any coordinate is NaN/inf: skip plotting that party and log a debug message with the party id and window.
- Log a WARN when a fallback is used so we can find parties with no MPs across windows.
Testing Strategy
How we will verify correctness
-
Unit tests
- Synthetic positions_by_window: build a small fake positions_by_window with known MP coordinates and party→MP mappings. Assert compute_party_coords outputs expected means and that _build_party_axis_figure uses those exact numbers for components 1 and 2.
- Fallback behavior: create a window with a party that has no MPs and assert load_party_axis_scores is called and its first chronological vector is used.
-
Integration tests
- Run against a small real DB snapshot used in prior verification. Assert for a representative set of parties across several windows that compass numbers equal the trajectory centroids for components 1 and 2.
-
CI
- Run full test suite. Known pre-existing failures unrelated to this change may persist; document them separately but do not block this change on them.
-
Manual QA
- Run Explorer locally and spot-check compass tooltips vs trajectory hover values for multiple parties and windows.
Open Questions
Unresolved items (minor)
- None critical: the user selected the fallback preference (first chronological party vector) and agreed to update all callers without backward compatibility.
I'm proceeding to create the implementation plan. Interrupt if you want changes to this design.