--- date: 2026-03-30 topic: "compass-trajectory-consistency" status: 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 1. UI requests compass for window W and component C. 2. Explorer calls load_positions(db_path) → gets positions_by_window. 3. compute_party_coords builds per-party (x,y) means from positions_by_window[W]. 4. 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. 5. Pass party_coords to _build_party_axis_figure which reads comp_sel and uses the explicit coordinate at index 0 or 1. 6. 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.