--- date: 2026-03-24 topic: "Welk tweede kamerlid ben jij?" status: draft --- ## Problem Statement We need a new Streamlit tab in explorer.py titled **"Welk tweede kamerlid ben jij?"** that interactively narrows the list of 2026 MPs by asking the user a sequence of yes/no/abstain questions (motions). The goal: find the minimal set of motions (questions) that uniquely identify a single MP, or determine that no unique MP exists because two or more MPs have identical voting records. **Why:** This is a guided identification quiz that helps users discover which MP they most resemble by iteratively comparing their answers to historic MP votes. ## Constraints - Work inside the existing Streamlit explorer (single-file UI: **explorer.py**). - Use existing data models/tables: **mp_votes**, **mp_metadata**, **motions** (DuckDB / MotionDatabase). No new external services. - Keep reads read-only: do not modify the DB from the UI flow. - YAGNI: minimal viable UX first (linear question flow, basic results table), extensible later. ## Approach (chosen) I recommend a two-stage approach that balances simplicity and correctness: - **Stage A (Batch-match + ranking):** Ask the user a small curated set of motions (e.g., high-controversy / high-discriminative score). Collect answers into a map motion_id -> vote and compute per-MP agreement counts using a new read-only DB helper. Show ranked candidates and whether any are unique. - **Stage B (Minimal distinguishing set):** If multiple candidates tie (or more than one remain), compute a minimal discriminating set of additional motions by greedily selecting motions that best split the remaining candidate set and present them as follow-up questions until a unique MP or impossibility is reached. Alternatives considered (rejected): - Asking motions adaptively from the start using an information-gain search over the entire motion space. Rejected because it’s heavier to implement and harder to explain to users; we can implement a greedy information-gain variant later. - Building a full decision tree offline for all MPs. Rejected for now because the dataset and party churn make maintenance cumbersome. Effort estimate (rough): - Backend: add one DB method to MotionDatabase (match_mps_for_votes) + helper to compute split scores — 2–4 hours. - Frontend: add new Streamlit builder, UI state, and wiring into tabs — 2–4 hours. - Testing & polish: 2–3 hours. Risks & dependencies - **Data quality:** If mp_votes.party or mp_metadata are incomplete, matching may be imperfect. We rely on existing backfill scripts to improve party fields. - **Performance:** Joins over mp_votes can be large; we'll limit candidate motion set and use read-only DuckDB queries, with caching where appropriate. ## Architecture High-level components (all in-process Streamlit app): - **Explorer UI (explorer.py)** — new tab builder **build_mp_quiz_tab**. Presents questions and displays results. - **MotionDatabase (database.py)** — new read-only method **match_mps_for_votes(user_votes, limit)** that returns per-MP agreement and overlap counts. Also a helper **choose_discriminating_motions(candidates, excluded_motion_ids, k=1)** that scores motions by how well they split candidate MPs. - **DuckDB (data)** — existing tables: motions, mp_votes, mp_metadata. All calls stay local — the Streamlit UI instantiates MotionDatabase(db_path) and calls the new read methods. ## Components and Responsibilities - **build_mp_quiz_tab (explorer.py)** - Render intro and instructions. - Load an initial pool of candidate motions (curated by controversy or SVD components via existing load_motions_df). - Present one question at a time, store answers in st.session_state (motion_id -> vote). - After each answer (or on demand), call MotionDatabase.match_mps_for_votes to get ranked candidates. - If multiple candidates remain, call the discriminating-motion helper to pick the next question. - Show final result (unique MP or note that multiple MPs are indistinguishable). - **MotionDatabase.match_mps_for_votes (database.py)** - Input: user_votes dict {motion_id: vote_str} - Output: ordered list of {mp_name, party, matched, total, agreement_pct} - Implementation: create an in-memory relation of user_votes, join with mp_votes where mp_name LIKE '%,%' and aggregate matched / overlap counts. Order by agreement_pct, matched desc. - **MotionDatabase.choose_discriminating_motions (database.py)** - Input: remaining candidate mp_names, excluded_motion_ids - Output: motion_id(s) ranked by split-score (e.g., entropy or max-min split) - Implementation: for a small candidate set, compute how many MPs vote 'voor'/'tegen'/'onthouden' on each motion and pick motion with best split. Files to modify (concrete) - explorer.py - Add function: build_mp_quiz_tab(...) near other build_*_tab functions (e.g., after build_svd_components_tab). - Add new tab label to the tab_labels list and wire into the st.tabs and fallback radio branches. (See existing tab pattern at explorer.py around lines ~626-779.) - database.py - Add methods: match_mps_for_votes and choose_discriminating_motions near calculate_party_matches / mp_votes helpers. ## Data Flow 1. UI loads candidate motion list via existing load_motions_df(db_path). 2. User answers a question => stored in st.session_state['mp_quiz_votes'] mapping motion_id -> vote_token. 3. UI calls MotionDatabase.match_mps_for_votes(user_votes) (read-only DuckDB). Returns sorted candidate MPs with matched/total/agreement_pct. 4. If >1 candidate remains, UI calls MotionDatabase.choose_discriminating_motions(candidates, excluded) to pick next motion(s). 5. Repeat until one candidate remains OR no motion splits candidates (tie by identical voting histories). ## Error Handling - Validation: normalize UI votes to the canonical tokens used in mp_votes (lowercase Dutch tokens like 'voor','tegen','onthouden','afwezig'). - Empty or missing data: if user_votes is empty or no overlaps exist, show helpful message and fall back to top-ranked MPs by similarity. - Division-by-zero: in match computations, treat zero-overlap MPs as excluded from ranking and surface a clear message. - Timeouts / heavy queries: restrict candidate set and use read-only DuckDB and caching (@st.cache_data) to avoid repeated heavy queries. ## Testing Strategy - Unit tests for database methods (new tests/test_match_mps.py): - small synthetic mp_votes fixture to assert matched/total/agreement_pct logic. - tests for choose_discriminating_motions producing expected splits. - Integration test for explorer tab (tests/test_explorer_quiz.py): render the builder function in a headless mode and assert UI state updates and DB calls succeed (similar to existing tests/test_explorer_import.py). ## Open Questions 1. Do we want an initial curated motion set (top-10 controversial), or start fully adaptive? I'll implement a small curated seed and make adaptive/discovery optional. 2. UX: Should we let users skip a question (abstain) and count abstain as a valid token? I assume yes and will treat abstain as a normal vote that matches mp_votes 'onthouden' or 'afwezig' values. 3. Performance limits: how many motions should we allow the user to answer (arbitrary cap e.g., 20)? I suggest 20 to keep interactions snappy. ## Next steps I'm proceeding to create the design doc file at thoughts/shared/designs/2026-03-24-welk-tweede-kamerlid-ben-jij-design.md and commit it. Interrupt if you want changes. After that I'll spawn the planner to create a detailed implementation plan based on this design.