---
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.
