elector-dp-app / app.py
michael
Update the map
b582581
Raw
History Blame Contribute Delete
86.7 kB
"""
app.py β€” ΧœΧ•Χ— Χ‘Χ—Χ™Χ¨Χ•Χͺ גם Χ©ΧžΧ™Χ¨Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ (Streamlit)
==================================================
Χ”Χ¨Χ¦Χ”: streamlit run app.py
Χ“Χ¨Χ™Χ©Χ•Χͺ: voting_dp.py באוΧͺΧ” ΧͺΧ™Χ§Χ™Χ™Χ”.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import streamlit as st
import streamlit.components.v1 as components
import plotly.graph_objects as go
import population
import geo
from voting_dp import (
randomized_response,
estimate_rr_frequency,
k_randomized_response,
estimate_krr_frequency,
rr_margin_of_error,
)
# The offline privacy–utility analysis (experiments/privacy_utility.py) is pure
# numpy/pandas (no matplotlib needed for the sweep itself); we reuse its Monte-
# Carlo sweep + aggregation so the UI's tradeoff tab matches the paper exactly.
_EXP_DIR = Path(__file__).resolve().parent / "experiments"
if str(_EXP_DIR) not in sys.path:
sys.path.insert(0, str(_EXP_DIR))
import privacy_utility as pu # noqa: E402
# =============================================================================
# UI TEXT (Hebrew) β€” all constant, data-independent user-facing strings.
# -----------------------------------------------------------------------------
# Every title, button label, tooltip, section header and static message lives
# here, separated from the code, so the text is easy to edit and the app's
# language can be swapped by replacing this one block. Strings that interpolate
# runtime values (counts, Ξ΅, party names) stay inline at their call sites, since
# they depend on the data.
# =============================================================================
# ── Browser tab ─────────────────────────────────────────────────────────────
PAGE_TITLE = "ElectorApp β€” ΧœΧ•Χ— DP"
# ── Sidebar navigation ──────────────────────────────────────────────────────
NAV_MENU_HEADER = "#### πŸ—³οΈ ΧͺΧ€Χ¨Χ™Χ˜Χ™Χ"
NAV_DASHBOARD = "πŸ“Š ΧœΧ•Χ— ראשי"
NAV_VOTERS = "πŸ“‹ Χ€Χ Χ§Χ‘ הבוחרים"
NAV_TRADEOFF = "βš–οΈ Χ Χ™ΧͺΧ•Χ— Χ€Χ¨Χ˜Χ™Χ•Χͺ-ΧͺΧ•Χ’ΧœΧͺ-Χ’ΧœΧ•Χͺ"
# ── Spinners / cache messages ───────────────────────────────────────────────
SPINNER_BUILD_POPULATION = "Χ‘Χ•Χ Χ” ΧΧ•Χ›ΧœΧ•Χ‘Χ™Χ™Χͺ בוחרים מנΧͺΧ•Χ Χ™ אמΧͺ…"
SPINNER_RUN_SIM = "ΧžΧ¨Χ™Χ₯ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”: ΧžΧ—Χ™Χœ Χ”Χ’Χ Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ גל Χ”Χ“Χ•Χ—Χ•Χͺ…"
SPINNER_RUN_TRADEOFF = "ΧžΧ¨Χ™Χ₯ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ™Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ–ΧͺΧ•Χ’ΧœΧͺβ€“Χ’ΧœΧ•Χͺ…"
# ── Accuracy banner ─────────────────────────────────────────────────────────
ACC_TITLE = "Χ”Χ’Χ¨Χ›Χͺ Χ“Χ™Χ•Χ§ Χ”Χ Χͺונים" # expander header (shown with icon + level)
ACC_LEVEL_HIGH = "Χ“Χ™Χ•Χ§ Χ’Χ‘Χ•Χ” (Χ”Χ’Χ Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ—ΧœΧ©Χ”)"
ACC_LEVEL_MEDIUM = "Χ“Χ™Χ•Χ§ Χ‘Χ™Χ Χ•Χ Χ™"
ACC_LEVEL_LOW = "Χ“Χ™Χ•Χ§ Χ ΧžΧ•Χš (Χ”Χ’Χ Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ—Χ–Χ§Χ”)"
ACC_STATUS_HEADER = "πŸ“Š **Χ‘Χ˜Χ˜Χ•Χ‘ Χ”Χ¦Χ‘Χ’Χ”** "
ACC_STATUS_TOOLTIP = (
"Randomized Response (RR) β€” ΧžΧ Χ’Χ Χ•ΧŸ Χ€Χ¨Χ˜Χ™Χ•Χͺ ΧžΧ§Χ•ΧžΧ™Χͺ. "
"Χ›Χœ Χ“Χ™Χ•Χ•Χ— ΧžΧ•Χ¨Χ’Χ© ΧœΧ€Χ Χ™ Χ©ΧžΧ™Χ¨ΧͺΧ•: Χ‘Χ”Χ‘ΧͺΧ‘Χ¨Χ•Χͺ p Χ”Χ“Χ™Χ•Χ•Χ— Χ–Χ”Χ” לאמΧͺ, "
"Χ‘Χ”Χ‘ΧͺΧ‘Χ¨Χ•Χͺ 1-p הוא מΧͺΧ”Χ€Χš אקראיΧͺ Χ›Χ“Χ™ ΧœΧ”Χ’ΧŸ גל Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ”ΧžΧ©Χͺמש."
)
ACC_CITY_HEADER = "πŸ™οΈ **Χ‘Χ€Χ™Χ¨Χ•Χͺ Χ’Χ™Χ¨** "
ACC_CITY_TOOLTIP = (
"Χ”Χ‘Χ€Χ™Χ¨Χ•Χͺ ΧžΧ—Χ•Χ©Χ‘Χ•Χͺ גל Χ™Χ“Χ™ Χ‘Χ›Χ™ΧžΧͺ Χ“Χ™Χ•Χ•Χ—Χ™ Χ”Χ”Χ¦Χ‘Χ’Χ” Χ”Χ©ΧžΧ•Χ¨Χ™Χ (Χ©Χ›Χ‘Χ¨ Χ”Χ•Χ¨Χ’Χ©Χ• Χ‘Χ’Χͺ Χ”Χ§ΧœΧ™Χ˜Χ”)."
)
ACC_TIP_TEXT = (
"πŸ’‘ Ξ΅ Χ’Χ‘Χ•Χ” Χ™Χ•ΧͺΧ¨ = Χ“Χ™Χ•Χ§ Χ’Χ‘Χ•Χ” Χ™Χ•ΧͺΧ¨, Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ ΧžΧ•Χ›Χ” Χ™Χ•ΧͺΧ¨. "
"Ξ΅ Χ ΧžΧ•Χš Χ™Χ•ΧͺΧ¨ = Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ’Χ‘Χ•Χ”Χ” Χ™Χ•ΧͺΧ¨, שגיאה Χ’Χ“Χ•ΧœΧ” Χ™Χ•ΧͺΧ¨."
)
# ── Table / DataFrame column headers (also shown in charts) ─────────────────
COL_CITY = "Χ’Χ™Χ¨"
COL_RECRUITED = "ΧžΧ’Χ•Χ™Χ‘Χ™Χ"
COL_VOTED_DP = "Χ”Χ¦Χ‘Χ™Χ’Χ• (משמר-Χ€Χ¨Χ˜Χ™Χ•Χͺ)"
COL_VOTED_TRUE = "Χ”Χ¦Χ‘Χ™Χ’Χ• ΧΧžΧ™ΧͺΧ™"
COL_REPORTS = "Χ“Χ•Χ—Χ•Χͺ Χ©Χ”ΧͺΧ§Χ‘ΧœΧ•"
COL_PENDING = "Χ’Χ“Χ™Χ™ΧŸ ממΧͺינים"
# ── Charts ──────────────────────────────────────────────────────────────────
PLOT_CITY_VOTED_TRACE = "Χ”Χ¦Χ‘Χ™Χ’Χ• (משמר-Χ€Χ¨Χ˜Χ™Χ•Χͺ)"
PLOT_CITY_PENDING_TRACE = "ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ ממΧͺינים"
PLOT_CITY_TITLE = "Χ Χ•Χ›Χ—Χ•Χͺ ΧœΧ€Χ™ Χ’Χ™Χ¨ (משמר-Χ€Χ¨Χ˜Χ™Χ•Χͺ)"
PLOT_CITY_XAXIS = "ΧžΧ¦Χ‘Χ™Χ’Χ™Χ"
PLOT_PARTY_YAXIS = "ΧžΧ¦Χ‘Χ™Χ’Χ™Χ ΧžΧ©Χ•Χ’Χ¨Χ™Χ"
PLOT_TO_RECALL = "ΧͺΧ•Χ’ΧœΧͺ: Χ©Χ™Χ’Χ•Χ¨ Χ”ΧͺΧ•ΧžΧ›Χ™Χ שטרם Χ”Χ¦Χ‘Χ™Χ’Χ• אשר Χ”ΧžΧ€ΧœΧ’Χ” Χ”Χ¦ΧœΧ™Χ—Χ” ΧœΧ”ΧžΧ¨Χ™Χ₯ ΧœΧ”Χ¦Χ‘Χ™Χ’, מΧͺΧ•Χš Χ›ΧœΧœ Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ”Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ (↑ Χ˜Χ•Χ‘ Χ™Χ•ΧͺΧ¨)."
PLOT_TO_PRECISION = "ΧͺΧ•Χ’ΧœΧͺ: Χ©Χ™Χ’Χ•Χ¨ Χ€Χ Χ™Χ•Χͺ Χ”Χ€Χ’Χ™ΧœΧ™Χ Χ©Χ”Χ¦ΧœΧ™Χ—Χ• ΧœΧ”ΧžΧ¨Χ™Χ₯ ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ, מΧͺΧ•Χš Χ›ΧœΧœ Χ”Χ€Χ Χ™Χ•Χͺ Χ©Χ‘Χ•Χ¦Χ’Χ• (↑ Χ˜Χ•Χ‘ Χ™Χ•ΧͺΧ¨)."
PLOT_TO_RISK = "Χ‘Χ™Χ›Χ•ΧŸ ΧœΧ‘Χ•Χ—Χ¨ (Χ—Χ©Χ™Χ€Χͺ Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ”): Χ”Χ™ΧͺΧ¨Χ•ΧŸ Χ©Χ™Χ© ΧœΧΧ“Χ Χ—Χ™Χ¦Χ•Χ Χ™ Χ‘Χ Χ™Χ—Χ•Χ© Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ” ΧžΧ”Χ‘ΧͺΧ›ΧœΧ•Χͺ Χ‘Χ“Χ•Χ— Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ©Χ”Χ•Χ“ΧœΧ£ ΧœΧ’Χ•ΧžΧͺ ΧœΧ Χ™Χ—Χ•Χ© Χ›ΧœΧœΧ™ (↓ Χ˜Χ•Χ‘ Χ™Χ•ΧͺΧ¨)"
PLOT_TO_XAXIS = "ΧͺΧ§Χ¦Χ™Χ‘ Χ€Χ¨Χ˜Χ™Χ•Χͺ Ξ΅ (Ξ΅ Χ’Χ‘Χ•Χ” ⇐ Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ—ΧœΧ©Χ” Χ™Χ•ΧͺΧ¨)"
PLOT_TO_YAXIS = "Χ©Χ™Χ’Χ•Χ¨ (0–1)"
# ── Dashboard page ──────────────────────────────────────────────────────────
DASH_PAGE_TITLE = "πŸ“Š ΧœΧ•Χ— ראשי"
DASH_SUBTITLE = "ביכום Χ”Χ§ΧžΧ€Χ™Χ™ΧŸ β€” Χ Χ•Χ›Χ—Χ•Χͺ, גרים, Χ•ΧžΧ¦Χ‘Χ™Χ’Χ™Χ ממΧͺינים."
DASH_SETTINGS_EXPANDER = "βš™οΈ Χ”Χ’Χ“Χ¨Χ•Χͺ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”"
DASH_EPS_SLIDER = "Ξ΅ β€” ΧͺΧ§Χ¦Χ™Χ‘ Χ€Χ¨Χ˜Χ™Χ•Χͺ (Randomized Response)"
DASH_FILL_SLIDER = "Χ©Χ™Χ’Χ•Χ¨ ΧžΧ™ΧœΧ•Χ™ Χ”Χ¨Χ©Χ™ΧžΧ”: אחוז Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ‘Χ€Χ Χ§Χ‘ הבוחרים Χ©Χ”Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ” Χͺמלא גבורם אΧͺ Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ”"
DASH_FILL_HELP = (
"Χ”Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ” ΧžΧ’Χ¨Χ™ΧœΧ” Χ‘Χ˜Χ˜Χ•Χ‘ Χ”Χ¦Χ‘Χ’Χ” (משמר-Χ€Χ¨Χ˜Χ™Χ•Χͺ) Χ¨Χ§ ΧœΧ—ΧœΧ§ Χ–Χ” ΧžΧ”Χ¨Χ©Χ™ΧžΧ” "
"(השאר Χ Χ•Χͺרים ללא Χ‘Χ˜ΧΧ˜Χ•Χ‘)."
)
DASH_RUN_BTN = "⚑ Χ”Χ¨Χ₯ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”"
DASH_RUN_BTN_HELP = "Χ”Χ¦Χ‘Χͺ Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ” ΧœΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ מ׀נקב הבוחרים"
DASH_RESET_BTN = "πŸ”„ א׀ב אΧͺ Χ›Χœ Χ”Χ“Χ•Χ—Χ•Χͺ"
DASH_RESET_DONE = "Χ›Χœ Χ”Χ“Χ•Χ—Χ•Χͺ או׀בו."
DASH_METRIC_MOBILIZED = "ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ Χ©Χ”Χ•ΧžΧ¨Χ¦Χ•"
DASH_METRIC_VOTED = "Χ”Χ¦Χ‘Χ™Χ’Χ• (משמרΧͺ-Χ€Χ¨Χ˜Χ™Χ•Χͺ)"
DASH_METRIC_PENDING = "ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ ממΧͺינים"
DASH_ALL_MOBILIZED = "Χ›Χœ Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ”Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ Χ”Χ•ΧžΧ¨Χ¦Χ• βœ“"
DASH_NO_REPORTS_INFO = (
"טרם Χ”Χ•Χ’Χ©Χ• Χ“Χ•Χ—Χ•Χͺ. Χ’Χ‘Χ•Χ¨ ΧœΧ’ΧžΧ•Χ“ **Χ¨Χ©Χ™ΧžΧͺ ΧžΧ¦Χ‘Χ™Χ’Χ™Χ** ΧœΧ‘Χ™ΧžΧ•ΧŸ ΧžΧ¦Χ‘Χ™Χ’Χ™Χ, "
"או ΧœΧ—Χ₯ **Χ”Χ¨Χ₯ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”** ΧœΧžΧ’ΧœΧ”."
)
DASH_CITY_CHART_HEADER = "#### Χ Χ•Χ›Χ—Χ•Χͺ Χ•ΧžΧ¦Χ‘Χ™Χ’Χ™Χ ממΧͺינים ΧœΧ€Χ™ Χ’Χ™Χ¨"
# ── Map (geographic view under the per-city panel) ──────────────────────────
MAP_HEADER = "#### מ׀Χͺ Χ Χ•Χ›Χ—Χ•Χͺ ארציΧͺ (משמר-Χ€Χ¨Χ˜Χ™Χ•Χͺ)"
MAP_VIEW_LABEL = "ΧͺΧ¦Χ•Χ’Χͺ ΧžΧ€Χ”"
MAP_VIEW_AGG = "מ׀Χͺ Χ¦Χ‘Χ™Χ¨Χ” (Χ”Χ’Χ¨Χ›Χ•Χͺ DP)"
MAP_VIEW_DOTS = "מ׀Χͺ Χ¨Χ©Χ•ΧžΧ•Χͺ ΧžΧ•Χ’Χ Χ•Χͺ (Χ Χ§Χ•Χ“Χ•Χͺ)"
MAP_METRIC_LABEL = "ΧžΧ“Χ“ לΧͺΧ¦Χ•Χ’Χ”"
MAP_METRIC_TURNOUT = "אחוז Χ”Χ¦Χ‘Χ’Χ” ΧžΧ©Χ•Χ’Χ¨"
MAP_METRIC_PENDING = "ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ ממΧͺינים"
MAP_METRIC_VOTED = "Χ”Χ¦Χ‘Χ™Χ’Χ• (ΧžΧ©Χ•Χ’Χ¨)"
MAP_DOT_VOTED = "Χ“Χ•Χ•Χ—: Χ”Χ¦Χ‘Χ™Χ’"
MAP_DOT_NOTVOTED = "Χ“Χ•Χ•Χ—: טרם Χ”Χ¦Χ‘Χ™Χ’"
MAP_AGG_TITLE = ""
MAP_DOTS_TITLE = (
"Χ›Χœ Χ Χ§Χ•Χ“Χ” היא Χ¨Χ©Χ•ΧžΧ” משמרΧͺ-Χ€Χ¨Χ˜Χ™Χ•Χͺ (**ΧžΧ•Χ¨Χ’Χ©Χͺ**) אחΧͺ."
)
MAP_MISSING_COORDS = "⚠️ ל־{n} יישובים ΧΧ™ΧŸ Χ§Χ•ΧΧ•Χ¨Χ“Χ™Χ Χ˜Χ•Χͺ והם אינם ΧžΧ•Χ¦Χ’Χ™Χ גל Χ”ΧžΧ€Χ” (הם Χ’Χ“Χ™Χ™ΧŸ ΧžΧ•Χ€Χ™Χ’Χ™Χ Χ‘Χ˜Χ‘ΧœΧ” Χ•Χ‘Χͺרשים)."
MAP_NO_COORDS_FILE = (
"ΧžΧ€Χ” אינה Χ–ΧžΧ™Χ Χ”: Χ§Χ•Χ‘Χ₯ Χ”Χ§Χ•ΧΧ•Χ¨Χ“Χ™Χ Χ˜Χ•Χͺ `settlement_coords.csv` Χ—Χ‘Χ¨. "
"Χ”Χ¨Χ₯ `python experiments/prepare_geo.py` Χ›Χ“Χ™ ΧœΧ™Χ™Χ¦Χ¨ אוΧͺΧ•."
)
MAP_DOTS_CAP = 5000 # max individual dots rendered (random sample beyond this)
MAP_DOTS_CAPPED_MSG = "ΧžΧ•Χ¦Χ’Χͺ Χ“Χ’Χ™ΧžΧ” אקראיΧͺ של {shown:,} מΧͺΧ•Χš {total:,} Χ¨Χ©Χ•ΧžΧ•Χͺ ΧžΧ•Χ¨Χ’Χ©Χ•Χͺ."
# ── Voter-list page ─────────────────────────────────────────────────────────
VOTERS_PAGE_TITLE = "πŸ“‹ Χ€Χ Χ§Χ‘ הבוחרים"
VOTERS_CAPTION = (
"במן ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ (Χ”Χ©ΧžΧ•Χͺ אינם ΧΧžΧ™Χͺיים). "
"Χ›Χœ Χ“Χ™Χ•Χ•Χ— ΧžΧ•Χ’ΧŸ גל Χ™Χ“Χ™ ΧžΧ Χ’Χ Χ•ΧŸ Randomized Response Χ”ΧžΧ‘Χ€Χ§ Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ“Χ™Χ€Χ¨Χ Χ¦Χ™ΧΧœΧ™Χͺ (Differential Privacy). "
)
VOTERS_FILTER_CITY = "בנן ΧœΧ€Χ™ Χ’Χ™Χ¨"
VOTERS_FILTER_ALL = "Χ”Χ›Χœ"
VOTERS_FILTER_STATUS = "בנן ΧœΧ€Χ™ Χ‘Χ˜Χ˜Χ•Χ‘ Χ“Χ•Χ—"
VOTERS_STATUS_NOT_REPORTED = "טרם Χ“Χ•Χ•Χ—"
VOTERS_STATUS_REPORTED_VOTED = "Χ“Χ•Χ•Χ—"
VOTERS_SEARCH = "Χ—Χ€Χ© שם (שם Χ€Χ¨Χ˜Χ™ ושם ΧžΧ©Χ€Χ—Χ”)"
VOTERS_REPORTED_BADGE = "<span style='color:green'>Χ“Χ•Χ•Χ—: Χ”Χ¦Χ‘Χ™Χ’ βœ“</span>"
VOTERS_REPORTED_CAPTION = "(Χ“Χ•Χ— משמר-Χ€Χ¨Χ˜Χ™Χ•Χͺ נשמר)"
VOTERS_VOTE_BTN = "βœ… Χ”Χ¦Χ‘Χ™Χ’"
VOTERS_UNDO_BTN = "↩ Χ‘Χ˜Χœ"
VOTERS_NO_MATCH_INFO = "ΧΧ™ΧŸ ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ”ΧͺΧ•ΧΧžΧ™Χ אΧͺ Χ”Χ‘Χ™Χ Χ•ΧŸ Χ”Χ Χ•Χ›Χ—Χ™."
# ── Privacy–utility–cost (tradeoff) page ────────────────────────────────────
TRADEOFF_PAGE_TITLE = "βš–οΈ Χ€Χ¨Χ˜Χ™Χ•Χͺ-ΧͺΧ•Χ’ΧœΧͺ-Χ’ΧœΧ•Χͺ"
TRADEOFF_INTRO = (
"Χ Χ™ΧͺΧ•Χ— Χ”Χ©Χ€Χ’Χͺ רמΧͺ Χ”Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ©Χ Χ§Χ‘Χ’Χ” Χ‘Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ” גל Χ”Χ‘Χ™Χ›Χ•ΧŸ ΧœΧžΧ¦Χ‘Χ™Χ’ (׀רמטר Ξ΅), Χ”ΧͺΧ•Χ’ΧœΧͺ Χ”Χ›ΧœΧœΧ™Χͺ ΧœΧžΧ€ΧœΧ’Χ” Χ”ΧžΧͺ׀גלΧͺ אΧͺ Χ”ΧžΧ’Χ¨Χ›Χͺ Χ•Χ”Χ’Χ¨Χ›Χͺ Χ”Χ’ΧœΧ•Χͺ ΧœΧžΧ€ΧœΧ’Χ”."
)
TRADEOFF_PARAMS_EXPANDER = "βš™οΈ Χ€Χ¨ΧžΧ˜Χ¨Χ™Χ של Χ”Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”"
TRADEOFF_SEC_PARTY_POWER = "**Χ›Χ•Χ— Χ”ΧžΧ€ΧœΧ’Χ”**"
TRADEOFF_FRAC_ACTIVISTS = "אחוז Χ”ΧͺΧ•ΧžΧ›Χ™Χ שהם Χ€Χ’Χ™ΧœΧ™Χ Χ‘ΧžΧ€ΧœΧ’Χ” (Χ™ΧžΧ¨Χ™Χ¦Χ• אΧͺ Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ”Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ)"
TRADEOFF_FRAC_ACTIVISTS_HELP = "מב׀ר Χ”Χ€Χ’Χ™ΧœΧ™Χ Χ Χ’Χ–Χ¨ ΧžΧ’Χ•Χ“Χœ Χ”ΧžΧ€ΧœΧ’Χ”: (אחוז Γ— ΧͺΧ•ΧžΧ›Χ™Χ)."
TRADEOFF_CALLS = "Χ§Χ™Χ‘Χ•ΧœΧͺ הוצאΧͺ Χ©Χ™Χ—Χ•Χͺ ΧœΧ›Χœ Χ€Χ’Χ™Χœ"
TRADEOFF_CALLS_HELP = "Χ§Χ•Χ‘Χ’ אΧͺ מב׀ר Χ”Χ©Χ™Χ—Χ•Χͺ Χ”Χ›Χ•ΧœΧœ Χ©Χ Χ™Χͺן ΧœΧ”Χ•Χ¦Χ™Χ ΧœΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ™Χ Χ›Χ“Χ™ ΧœΧ”ΧžΧ¨Χ™Χ₯ אוΧͺם (מב׀ר Χ”ΧžΧ€Χ’Χ™ΧœΧ™Χ Γ— Χ§Χ™Χ‘Χ•ΧœΧͺ Χ©Χ™Χ—Χ•Χͺ ΧœΧ›Χœ Χ€Χ’Χ™Χœ)."
TRADEOFF_SEC_COST_RUNS = "**Χ’ΧœΧ•Χͺ Χ•Χ›ΧžΧ•Χͺ Χ”Χ¨Χ¦Χ•Χͺ**"
TRADEOFF_COST = "Χ’ΧœΧ•Χͺ ΧœΧ©Χ™Χ—Χ” ($)"
TRADEOFF_COST_HELP = "Χ’ΧœΧ•Χͺ הוצאΧͺ Χ©Χ™Χ—Χ” ΧœΧ”ΧžΧ¨Χ¦Χͺ ΧžΧ¦Χ‘Χ™Χ’ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™."
TRADEOFF_REPEATS = "מב׀ר Χ”ΧΧ™Χ˜Χ¨Χ¦Χ™Χ•Χͺ (Χ“Χ’Χ™ΧžΧ•Χͺ Χ—Χ•Χ–Χ¨Χ•Χͺ) Χ‘Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”"
TRADEOFF_REPEATS_HELP = "מב׀ר Χ”Χ”Χ’Χ¨ΧœΧ•Χͺ Χ”ΧžΧ‘Χ•Χ¦Χ’Χ•Χͺ ΧœΧ›Χœ Χ”Χ’Χ“Χ¨Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ Ξ΅."
TRADEOFF_SEC_POP_MODEL = "**ΧΧ•Χ›ΧœΧ•Χ‘Χ™Χ™Χ” Χ•ΧžΧ•Χ“Χœ**"
TRADEOFF_FRACTION = "אחוז Χ“Χ’Χ™ΧžΧͺ ΧΧ•Χ›ΧœΧ•Χ‘Χ™Χ™Χ”"
TRADEOFF_FRACTION_HELP = "Χ’Χ•Χ“Χœ Χ”Χ“Χ’Χ™ΧžΧ” ΧžΧ”ΧΧ•Χ›ΧœΧ•Χ‘Χ™Χ™Χͺ הבוחרים (Χ€Χ Χ§Χ‘ הבוחרים) ΧœΧ‘Χ™Χ¦Χ•Χ’ Χ”Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ” (Χ“Χ’Χ™ΧžΧ” Χ§Χ˜Χ Χ” Χ™Χ•ΧͺΧ¨ β†’ Χ¨Χ¦Χ•Χ’Χ•Χͺ שגיאה Χ¨Χ—Χ‘Χ•Χͺ Χ™Χ•ΧͺΧ¨)."
TRADEOFF_SUPPORT_NOISE = "Χ¨Χ’Χ© ΧžΧ•Χ“Χœ Χ”ΧͺΧžΧ™Χ›Χ”"
TRADEOFF_SUPPORT_NOISE_HELP = "ΧžΧ Χ‘Χ ΧͺΧžΧ™Χ›Χ” של ΧžΧ¦Χ‘Χ™Χ’ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™ Χ‘ΧžΧ€ΧœΧ’Χ” (Χ”Χ¦Χ‘Χ™Χ’ ΧœΧžΧ€ΧœΧ’Χ”). 0 = Χ›Χœ ΧžΧ¦Χ‘Χ™Χ’ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™ Χ™Χ‘Χ¦Χ™Χ’ ΧœΧžΧ€ΧœΧ’Χ”, גרך > 1 = ΧžΧ¦Χ‘Χ™Χ’ Χ’ΧœΧ•Χœ לא ΧœΧ”Χ¦Χ‘Χ™Χ’ ΧœΧžΧ€ΧœΧ’Χ”."
TRADEOFF_SEC_CONSTRAINTS = "##### ΧΧ™ΧœΧ•Χ¦Χ™ Χ”ΧžΧ€ΧœΧ’Χ”"
TRADEOFF_MIN_RECALL = "recall ΧžΧ™Χ Χ™ΧžΧœΧ™"
TRADEOFF_NO_CONSTRAINT_HELP = "0 = ללא ΧΧ™ΧœΧ•Χ₯."
TRADEOFF_MIN_PRECISION = "precision ΧžΧ™Χ Χ™ΧžΧœΧ™"
TRADEOFF_MAX_LOSS = "Χ”Χ€Χ‘Χ“ ΧžΧ§Χ‘Χ™ΧžΧœΧ™ ($)"
TRADEOFF_MAX_LOSS_HELP = "Χ”Χ€Χ‘Χ“ Χ›Χ‘Χ€Χ™ ΧžΧ§Χ‘Χ™ΧžΧœΧ™ ΧžΧ©Χ™Χ—Χ•Χͺ ΧžΧ•Χ˜Χ’Χ•Χͺ Χ’Χ§Χ‘ Χ“Χ™Χ•Χ•Χ—Χ™ Χ”Χ¦Χ‘Χ’Χ” ΧžΧ©ΧžΧ¨Χ™ Χ€Χ¨Χ˜Χ™Χ•Χͺ (Χ™Χ™ΧͺΧ›ΧŸ Χ©ΧžΧ¦Χ‘Χ™Χ’ Χ›Χ‘Χ¨ Χ”Χ¦Χ‘Χ™Χ’, ΧΧ‘Χœ Χ”Χ•ΧžΧ¨Χ₯ Χ‘Χ›Χœ זאΧͺ, Χ’Χ§Χ‘ Χ“Χ™Χ•Χ•Χ— Χ”Χ¦Χ‘Χ’Χ” לא Χ Χ›Χ•ΧŸ). 0 = Χ—Χ™Χ©Χ•Χ‘ ΧΧ•Χ˜Χ•ΧžΧ˜Χ™."
TRADEOFF_RUN_BTN = "πŸš€ Χ”Χ¨Χ₯ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”"
TRADEOFF_CONFIGURE_INFO = (
"Χ”Χ’Χ“Χ¨ Χ€Χ¨ΧžΧ˜Χ¨Χ™Χ Χ•ΧœΧ—Χ₯ **Χ”Χ¨Χ₯ Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ”** Χ›Χ“Χ™ ΧœΧ—Χ©Χ‘ אΧͺ Χ”Χ€Χ©Χ¨Χ” "
"Χ‘Χ™ΧŸ Χ€Χ¨Χ˜Χ™Χ•Χͺ, ΧͺΧ•Χ’ΧœΧͺ Χ•Χ’ΧœΧ•Χͺ."
)
TRADEOFF_METRIC_PARTY = "ΧžΧ€ΧœΧ’Χ”"
TRADEOFF_METRIC_SUPPORTERS = "מב׀ר Χ”ΧͺΧ•ΧžΧ›Χ™Χ Χ‘ΧΧ•Χ›ΧœΧ•Χ‘Χ™Χ™Χ” (ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ)"
TRADEOFF_METRIC_ACTIVISTS = "Χ€Χ’Χ™ΧœΧ™Χ Γ— Χ©Χ™Χ—Χ•Χͺ"
TRADEOFF_METRIC_BUDGET = "ΧͺΧ§Χ¦Χ™Χ‘ Χ›Χ‘Χ€Χ™ Χ›Χ•ΧœΧœ Χ Χ“Χ¨Χ©"
TRADEOFF_NO_FEASIBLE_WARN = (
"⚠️ אף Ξ΅ אינו Χ’Χ•ΧžΧ“ Χ‘Χ›Χœ Χ”ΧΧ™ΧœΧ•Χ¦Χ™Χ. Χ”Χ§Χœ אΧͺ Χ”Χ“Χ¨Χ™Χ©Χ•Χͺ "
"(recall / precision / Χ”Χ€Χ‘Χ“ ΧžΧ§Χ‘Χ™ΧžΧœΧ™) או Χ”Χ’Χ“Χœ אΧͺ Χ”ΧͺΧ§Χ¦Χ™Χ‘."
)
TRADEOFF_EPS_SLIDER = "במן Ξ΅ גל Χ”Χ’Χ¨Χ£"
TRADEOFF_LEGEND_CAPTION = (
"πŸ’‘ ΧœΧ—Χ™Χ¦Χ” גל Χ€Χ¨Χ™Χ˜ Χ‘ΧžΧ§Χ¨Χ מבΧͺΧ™Χ¨Χ” / ΧžΧ¦Χ™Χ’Χ” אΧͺ Χ”Χ’Χ§Χ•ΧžΧ” Χ”ΧžΧͺΧΧ™ΧžΧ”. "
"Ξ΅ Χ’Χ‘Χ•Χ” = Χ“Χ™Χ•Χ§ ΧͺΧ€Χ’Χ•ΧœΧ™ Χ’Χ‘Χ•Χ” אך Χ€Χ¨Χ˜Χ™Χ•Χͺ Χ—ΧœΧ©Χ” Χ™Χ•ΧͺΧ¨ ΧœΧ‘Χ•Χ—Χ¨."
)
TRADEOFF_METRIC_RISK = "Χ‘Χ™Χ›Χ•ΧŸ ΧœΧ‘Χ•Χ—Χ¨ (Χ—Χ©Χ™Χ€Χͺ Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ”)"
TRADEOFF_METRIC_RISK_HELP = "Χ‘Χ™Χ›Χ•ΧŸ ΧœΧ‘Χ•Χ—Χ¨ (Χ—Χ©Χ™Χ€Χͺ Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ”): Χ”Χ™ΧͺΧ¨Χ•ΧŸ Χ©Χ™Χ© ΧœΧΧ“Χ Χ—Χ™Χ¦Χ•Χ Χ™ Χ‘Χ Χ™Χ—Χ•Χ© Χ‘Χ˜ΧΧ˜Χ•Χ‘ Χ”Χ”Χ¦Χ‘Χ’Χ” ΧžΧ”Χ‘ΧͺΧ›ΧœΧ•Χͺ Χ‘Χ“Χ•Χ— Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ©Χ”Χ•Χ“ΧœΧ£ ΧœΧ’Χ•ΧžΧͺ ΧœΧ Χ™Χ—Χ•Χ© Χ›ΧœΧœΧ™ (↓ Χ˜Χ•Χ‘ Χ™Χ•ΧͺΧ¨)"
TRADEOFF_METRIC_RECALL = "Recall"
TRADEOFF_METRIC_RECALL_HELP = "Χ©Χ™Χ’Χ•Χ¨ Χ”ΧͺΧ•ΧžΧ›Χ™Χ שטרם Χ”Χ¦Χ‘Χ™Χ’Χ• אשר Χ”ΧžΧ€ΧœΧ’Χ” Χ”Χ¦ΧœΧ™Χ—Χ” ΧœΧ”ΧžΧ¨Χ™Χ₯ ΧœΧ”Χ¦Χ‘Χ™Χ’, מΧͺΧ•Χš Χ›ΧœΧœ Χ”ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ”Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ (↑ Χ˜Χ•Χ‘ Χ™Χ•ΧͺΧ¨)."
TRADEOFF_METRIC_PRECISION = "Precision"
TRADEOFF_METRIC_PRECISION_HELP = "Χ©Χ™Χ’Χ•Χ¨ Χ€Χ Χ™Χ•Χͺ Χ”Χ€Χ’Χ™ΧœΧ™Χ Χ©Χ”Χ¦ΧœΧ™Χ—Χ• ΧœΧ”ΧžΧ¨Χ™Χ₯ ΧžΧ¦Χ‘Χ™Χ’Χ™Χ Χ€Χ•Χ˜Χ Χ¦Χ™ΧΧœΧ™Χ, מΧͺΧ•Χš Χ›ΧœΧœ Χ”Χ€Χ Χ™Χ•Χͺ Χ©Χ‘Χ•Χ¦Χ’Χ• (↑ Χ˜Χ•Χ‘ Χ™Χ•ΧͺΧ¨)."
TRADEOFF_METRIC_COST = "Χ”Χ€Χ‘Χ“ Χ›Χ‘Χ€Χ™ ΧžΧ©Χ™Χ—Χ•Χͺ ΧžΧ•Χ˜Χ’Χ•Χͺ Χ’Χ§Χ‘ Χ“Χ™Χ•Χ•Χ—Χ™ Χ”Χ¦Χ‘Χ’Χ” ΧžΧ©ΧžΧ¨Χ™ Χ€Χ¨Χ˜Χ™Χ•Χͺ"
# =============================================================================
# PAGE CONFIG (must be first Streamlit call)
# =============================================================================
st.set_page_config(
page_title=PAGE_TITLE,
page_icon="πŸ—³οΈ",
layout="wide",
initial_sidebar_state="expanded",
)
# =============================================================================
# CONSTANTS
# =============================================================================
SEED = 42
# ── Population source (real CEC aggregates β†’ synthetic individuals) ──────────
# The settlement list, cities, and party names are DERIVED from the real data
# (population.py) at session init β€” not hard-coded. Tune these knobs to trade
# fidelity against a responsive UI.
APP_SETTLEMENTS = None # None β†’ population.DEFAULT_SETTLEMENTS (6 big + 6 small)
APP_FRACTION = 1.0 # proportional down-sample (1.0 = full real size)
APP_MAX_PER_SETTLEMENT = 200 # cap per settlement so big cities stay responsive
APP_KEEP_TOP_PARTIES = 8 # collapse minor parties into "אחר" (smaller k-RR)
APP_ORGANISER = "Χ”ΧœΧ™Χ›Χ•Χ“" # party running the Elector (gold highlight).
# None β†’ largest party by support in the data.
VOTER_LIST_DISPLAY_CAP = 200 # max voter cards rendered at once (UI guard)
# ── ONE privacy budget Ξ΅ (binary Randomized Response). No Laplace anywhere ──
# The same Ξ΅ protects each capture AND every aggregate derived from it (counts,
# turnout) by DP post-processing. The optional party signal (k-RR) reuses this
# same Ξ΅ β€” there is no longer a second, separate privacy knob in the UI.
DEFAULT_EPS = 1.0
# ── Partial fill of the voter list ──────────────────────────────────────────
# Realistic scenario: the Elector is only *partly* populated β€” activists have
# captured a voting status for only a fraction of the list; the rest are left
# with no status at all. The bulk simulation randomizes the status of only this
# fraction of the (still-unreported) voters. 1.0 = mark everyone (old behaviour).
APP_SIM_FILL_FRACTION = 0.5
# ── Privacy–utility–cost analysis (tradeoff tab) defaults ───────────────────
ANALYSIS_FRACTION = 0.3 # population down-sample for the offline sweep
ANALYSIS_FRAC_ACTIVISTS = 0.05 # share of the party's supporters fielded as activists
ANALYSIS_CALLS = 200 # calls each activist can make
ANALYSIS_COST_PER_CALL = 0.5 # $ per activist contact (prices misdirected calls)
ANALYSIS_REPEATS = 20 # Monte-Carlo repetitions per Ξ΅
ANALYSIS_MIN_RECALL = 0.70 # party's minimum acceptable GOTV recall
ANALYSIS_MIN_PRECISION = 0.85 # party's minimum acceptable GOTV precision
ANALYSIS_MAX_LOSS = 1000.0 # max tolerated $ loss from misdirected calls (0 = auto)
# Colours are assigned to whatever parties the data yields (see assign_party_colors).
PARTY_PALETTE = [
"#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B2",
"#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD",
"#5975A4", "#B07AA1",
]
STATUS_VOTED = "#27ae60"
STATUS_PENDING = "#e74c3c"
STATUS_MISSING = "#e67e22"
# =============================================================================
# CONFIGURATION FLAGS
# =============================================================================
SHOW_PRIVACY_SETTINGS = True
ACTIVIST_NAME = f" משΧͺמש: א. Χ›Χ”ΧŸ | ΧžΧ˜Χ” Χͺל-אביב {APP_ORGANISER}"
# =============================================================================
# GLOBAL STYLES
# =============================================================================
def inject_styles():
"""
CSS strategy:
- Heebo Hebrew font
- Full light theme (no dark colours on any component)
- Targeted text-align:right for ALL Hebrew content without touching layout
- <p> tags and widget labels explicitly right-aligned
- Title on LEFT of top bar; user badge on RIGHT
- Stationary sidebar (collapse button hidden)
"""
st.markdown(
'<link href="https://fonts.googleapis.com/css2?family=Heebo'
':wght@300;400;500;600;700;800&display=swap" rel="stylesheet">',
unsafe_allow_html=True,
)
st.markdown("""
<style>
/* ── Font β€” text elements only, never * ─────────────────────────────
Using * would override Streamlit's icon fonts (Material Icons),
turning the expander arrow SVG into its text fallback "arrow_down".
We list every text-bearing element explicitly instead. */
html, body,
p, div, label, input, textarea, select, button,
h1, h2, h3, h4, h5, h6, li, td, th, caption, figcaption,
[data-testid="stMarkdownContainer"],
[data-testid="stCaptionContainer"],
[data-testid="stMetricLabel"],
[data-testid="stMetricValue"],
[data-testid="stWidgetLabel"],
.page-title, .user-name, .top-bar-title,
.top-bar-subtitle {
font-family: 'Heebo', 'Segoe UI', Arial, sans-serif !important;
}
/* ── Light theme: backgrounds ───────────────────────────────────── */
[data-testid="stApp"],
[data-testid="stAppViewContainer"] {
background-color: #f5f7fa !important;
color: #1a2340 !important;
}
[data-testid="stMain"] {
background-color: #f5f7fa !important;
color: #1a2340 !important;
}
[data-testid="stSidebar"] {
background-color: #f0f4f8 !important;
border-right: 1px solid #dde4ee !important;
}
/* ── Light theme: sidebar text ───────────────────────────────────── */
[data-testid="stSidebar"],
[data-testid="stSidebar"] p,
[data-testid="stSidebar"] span,
[data-testid="stSidebar"] label,
[data-testid="stSidebar"] div {
color: #1a2340 !important;
}
/* ── Sidebar section headings (####) ─────────────────────────────── */
[data-testid="stSidebar"] h1,
[data-testid="stSidebar"] h2,
[data-testid="stSidebar"] h3,
[data-testid="stSidebar"] h4,
[data-testid="stSidebar"] h5,
[data-testid="stSidebar"] h6 {
color: #2d4a8a !important;
font-size: 11px !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.8px !important;
margin: 18px 0 8px 0 !important;
padding-bottom: 4px !important;
border-bottom: 1px solid #c8d5e8 !important;
}
/* ── Light theme: metrics ────────────────────────────────────────── */
[data-testid="metric-container"] {
background: #ffffff !important;
border: 1px solid #dde4ee !important;
border-radius: 10px !important;
padding: 16px !important;
}
[data-testid="stMetricValue"] { color: #1a2340 !important; }
[data-testid="stMetricLabel"] { color: #1a2340 !important; }
[data-testid="stMetricDelta"] {
color: #3b6cb7 !important;
display: flex !important;
flex-direction: row-reverse !important;
justify-content: flex-start !important;
width: 100% !important;
}
[data-testid="stMetricDelta"] > div {
display: flex !important;
flex-direction: row-reverse !important;
}
/* ── Light theme: alerts ─────────────────────────────────────────── */
[data-testid="stAlert"], div[role="alert"] {
background-color: #eef4ff !important;
color: #1a2340 !important;
border-color: #a8c0e0 !important;
}
/* ── Light theme: inputs ─────────────────────────────────────────── */
.stTextInput input {
background-color: #ffffff !important;
color: #1a2340 !important;
border: 1px solid #c0ccde !important;
}
.stSelectbox div[data-baseweb="select"],
.stSelectbox div[data-baseweb="select"] * {
background-color: #ffffff !important;
color: #1a2340 !important;
border-color: #c0ccde !important;
}
/* ── Light theme: sliders, dataframes, borders ───────────────────── */
[data-testid="stSlider"] * { color: #1a2340 !important; }
[data-testid="stDataFrame"],
[data-testid="stDataFrame"] * {
background-color: #ffffff !important;
color: #1a2340 !important;
}
[data-testid="stVerticalBlockBorderWrapper"] {
background-color: #ffffff !important;
border-color: #dde4ee !important;
}
hr { border-color: #dde4ee !important; }
/* ── Captions and labels: black (not grey), slightly larger ──────── */
[data-testid="stCaptionContainer"],
[data-testid="stCaptionContainer"] p,
[data-testid="stCaptionContainer"] span,
[data-testid="stCaptionContainer"] small,
small, caption {
color: #1a2340 !important;
direction: rtl !important;
font-size: 0.95rem !important;
}
/* ── Hide default Streamlit chrome ───────────────────────────────── */
header[data-testid="stHeader"] { display: none !important; }
[data-testid="stToolbar"] { display: none !important; }
[data-testid="stDecoration"] { display: none !important; }
#MainMenu { display: none !important; }
footer { display: none !important; }
/* ── Stationary sidebar ──────────────────────────────────────────── */
[data-testid="stSidebarCollapseButton"] { display: none !important; }
[data-testid="collapsedControl"] { display: none !important; }
/* ── Push content below top bar ──────────────────────────────────── */
[data-testid="stAppViewContainer"] > section:first-child {
padding-top: 68px !important;
}
[data-testid="stSidebar"] > div:first-child {
padding-top: 68px !important;
}
/* ── Top bar: title LEFT, user badge RIGHT ────────────────────────── */
.top-bar {
position: fixed; top: 0; left: 0; right: 0; height: 58px;
background: linear-gradient(90deg, #2d4a8a 0%, #3b6cb7 100%);
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px; z-index: 999999;
box-shadow: 0 2px 8px rgba(45,74,138,0.25);
}
.top-bar-brand {
display: flex; align-items: center; gap: 10px;
}
.top-bar-title {
font-size: 21px; font-weight: 700; color: #ffffff;
display: flex; align-items: center; gap: 10px;
}
.top-bar-subtitle { font-size: 12px; color: #c0d4f0; margin-top: 2px; }
.user-badge {
display: flex; align-items: center; gap: 9px;
background: rgba(255,255,255,0.15);
border-radius: 28px; padding: 6px 14px 6px 10px;
}
.user-avatar {
width: 30px; height: 30px; border-radius: 50%; background: #5a8fd8;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #ffffff; font-weight: 700; flex-shrink: 0;
}
.user-name { font-size: 13px; color: #e4edff; font-weight: 500; }
/* ── Sidebar brand ───────────────────────────────────────────────── */
.sidebar-brand {
font-size: 17px; font-weight: 700; color: #2d4a8a;
padding: 0 4px 12px 4px;
border-bottom: 2px solid #c8d5e8; margin-bottom: 8px;
}
/* ── Page title ──────────────────────────────────────────────────── */
.page-title {
font-size: 2.3rem !important; font-weight: 800 !important;
color: #1a2340 !important; margin-bottom: 4px; line-height: 1.2;
text-align: right !important;
}
/* ── All buttons: light theme ────────────────────────────────────── */
button,
.stButton > button,
button[data-testid="baseButton-secondary"],
button[data-testid="baseButton-primary"] {
background-color: #dde8f8 !important;
color: #1a2340 !important;
border: 1px solid #a8bede !important;
border-radius: 8px !important;
font-family: 'Heebo', sans-serif !important;
font-weight: 500 !important;
box-shadow: none !important;
transition: background-color 0.15s, border-color 0.15s !important;
}
button:hover, .stButton > button:hover {
background-color: #c4d6f2 !important;
border-color: #3b6cb7 !important;
color: #1a2340 !important;
}
/* ── Sidebar nav buttons ─────────────────────────────────────────── */
div[data-testid="stSidebar"] .stButton > button {
background-color: #ffffff !important;
border: 1px solid #dde4ee !important;
color: #1a2340 !important;
font-size: 14px !important;
padding: 9px 12px !important;
text-align: left !important;
width: 100% !important;
margin-bottom: 3px !important;
}
div[data-testid="stSidebar"] .stButton > button:hover {
background-color: #e8f0fc !important;
border-color: #3b6cb7 !important;
}
div[data-testid="stSidebar"] .active-nav .stButton > button {
background-color: #e4edfb !important;
border: 1px solid #3b6cb7 !important;
border-left: 4px solid #3b6cb7 !important;
color: #1a2340 !important;
font-weight: 700 !important;
}
/* ══════════════════════════════════════════════════════════════════
HEBREW TEXT ALIGNMENT β€” right-aligns content text.
Scope: content elements only; layout containers are untouched
so Streamlit's sidebar / column engine is unaffected.
To switch to left-aligned: change every "right" to "left" below.
══════════════════════════════════════════════════════════════════ */
/* All <p> tags β€” catches every paragraph in the app */
p { text-align: right !important; }
/* Metric cards β€” align BOTH label and value to the right */
[data-testid="stMetricLabel"],
[data-testid="stMetricLabel"] p,
[data-testid="stMetricLabel"] div {
text-align: right !important;
display: block !important;
}
[data-testid="stMetricValue"],
[data-testid="stMetricValue"] div {
text-align: right !important;
}
[data-testid="stMetricDelta"],
[data-testid="stMetricDelta"] div {
text-align: right !important;
display: flex !important;
flex-direction: row-reverse !important;
}
[data-testid="metric-container"] {
text-align: right !important;
}
/* Markdown containers (st.markdown, st.write) */
[data-testid="stMarkdownContainer"] p,
[data-testid="stMarkdownContainer"] h1,
[data-testid="stMarkdownContainer"] h2,
[data-testid="stMarkdownContainer"] h3,
[data-testid="stMarkdownContainer"] h4,
[data-testid="stMarkdownContainer"] h5,
[data-testid="stMarkdownContainer"] h6,
[data-testid="stMarkdownContainer"] li,
[data-testid="stMarkdownContainer"] blockquote,
[data-testid="stMarkdownContainer"] ul,
[data-testid="stMarkdownContainer"] ol {
text-align: right !important;
direction: rtl !important;
}
.top-bar {
direction: rtl !important;
}
/* Captions */
[data-testid="stCaptionContainer"] p {
text-align: right !important;
}
/* Widget labels (slider, selectbox, text input) */
[data-testid="stWidgetLabel"] {
display: flex !important;
flex-direction: row-reverse !important;
justify-content: flex-start !important;
width: 100% !important;
text-align: right !important;
}
[data-testid="stWidgetLabel"] p,
[data-testid="stWidgetLabel"] label {
text-align: right !important;
}
/* Sidebar widget labels specifically */
[data-testid="stSidebar"] [data-testid="stWidgetLabel"],
[data-testid="stSidebar"] [data-testid="stWidgetLabel"] p {
text-align: right !important;
}
/* Custom HTML elements */
.page-title, .sidebar-brand { text-align: right !important; }
/* ── Expander: title right-aligned; content p/li right-aligned ──────
We use direction:rtl ONLY on the summary element so the title text
reads right-to-left. This does not affect the overall layout.
The expand/collapse icon is an SVG; direction:rtl moves it to the
left, which is the correct position for Hebrew/RTL expanders. */
[data-testid="stExpander"] details summary,
[data-testid="stExpander"] details summary p {
direction: rtl !important;
text-align: right !important;
}
/* Hide the Material Icons span (keyboard_arrow_right) and replace
with a pure-CSS chevron β€” no icon font dependency at all. */
[data-testid="stExpanderToggleIcon"],
[data-testid="stExpander"] details summary span[role="img"],
[data-testid="stExpander"] details summary svg {
display: none !important;
font-size: 0 !important;
}
[data-testid="stExpander"] details summary::before {
content: "β–Ά";
font-size: 11px;
color: #3b6cb7;
margin-left: 8px;
margin-right: 2px;
display: inline-block;
font-family: inherit !important;
}
[data-testid="stExpander"] details[open] summary::before {
content: "β–Ό";
}
/* Content inside the expander */
[data-testid="stExpander"] [data-testid="stMarkdownContainer"] p,
[data-testid="stExpander"] [data-testid="stMarkdownContainer"] li,
[data-testid="stExpander"] [data-testid="stMarkdownContainer"] ul,
[data-testid="stExpander"] [data-testid="stMarkdownContainer"] h4,
[data-testid="stExpander"] [data-testid="stCaptionContainer"] p,
[data-testid="stExpander"] p,
[data-testid="stExpander"] li {
text-align: right !important;
direction: rtl !important;
width: 100%
}
/* ── Info tooltip icon ──────────────────────────────────────────── */
[data-testid="stVerticalBlockBorderWrapper"] { overflow: visible !important; }
.info-tooltip {
position: relative;
display: inline-block;
cursor: help;
vertical-align: middle;
}
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px; height: 15px;
background: #3b6cb7;
color: #ffffff;
border-radius: 50%;
font-size: 10px; font-weight: 700;
line-height: 1;
vertical-align: middle;
margin-right: 5px;
font-style: normal;
}
.tooltip-text {
visibility: hidden;
background: #1a2340;
color: #ffffff;
border-radius: 8px;
padding: 10px 14px;
position: absolute;
z-index: 9999;
bottom: 130%; right: 0;
width: 310px;
font-size: 14px; line-height: 1.6;
opacity: 0;
transition: opacity 0.2s ease;
direction: rtl; text-align: right;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
pointer-events: none;
}
.info-tooltip:hover .tooltip-text { visibility: visible; opacity: 1; }
/* ── Native help "?" icon β€” keep it adjacent to its label (RTL) ──────
Streamlit shows a hover "?" icon whenever a widget/metric has help=.
By default a full-width label paragraph (forced to width:100%, e.g.
inside expanders) pushes the icon to the far edge, away from its text.
We let the label text shrink to its content and add a small gap, so the
icon sits just beside the label. Placed AFTER the expander rule above so
its width:100% on label paragraphs is overridden here.
Applies to every labelled widget and metric in the app. */
[data-testid="stWidgetLabel"],
[data-testid="stMetricLabel"] {
display: flex !important;
flex-direction: row-reverse !important;
justify-content: flex-start !important;
align-items: center !important;
gap: 8px !important; /* the gap between the "?" and the label */
}
[data-testid="stWidgetLabel"] > div:first-child,
[data-testid="stWidgetLabel"] [data-testid="stMarkdownContainer"],
[data-testid="stWidgetLabel"] [data-testid="stMarkdownContainer"] p,
[data-testid="stMetricLabel"] [data-testid="stMarkdownContainer"],
[data-testid="stMetricLabel"] [data-testid="stMarkdownContainer"] p {
flex: 0 1 auto !important; /* shrink to content so the icon stays next to it */
width: auto !important;
}
/* The "?" hover target / icon itself never shrinks. */
[data-testid="stTooltipHoverTarget"],
[data-testid="stTooltipIcon"] {
flex: 0 0 auto !important;
}
/* ── Centered loading spinner (used during simulations) ──────────── */
.dp-spin-wrap {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
width: 100%; padding: 44px 0;
}
.dp-spinner {
width: 48px; height: 48px;
border: 5px solid #dde8f8;
border-top-color: #3b6cb7;
border-radius: 50%;
animation: dp-spin 0.85s linear infinite;
}
.dp-spin-text {
margin-top: 14px; color: #2d4a8a;
font-weight: 600; font-size: 15px; text-align: center;
}
@keyframes dp-spin { to { transform: rotate(360deg); } }
/* ══════════════════════════════════════════════════════════════════ */
</style>
""", unsafe_allow_html=True)
# Block Ctrl+C from triggering Streamlit's "Clear cache" shortcut.
# stopImmediatePropagation prevents Streamlit's React handlers from seeing
# the event; preventDefault is intentionally omitted so the browser's
# native copy action still works normally.
components.html("""
<script>
try {
window.parent.document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'c'
&& !e.shiftKey && !e.altKey) {
e.stopImmediatePropagation();
}
}, true);
} catch(err) {}
</script>
""", height=0)
# =============================================================================
# TOP BAR (title LEFT, user badge RIGHT)
# =============================================================================
def render_top_bar(activist_name):
"""Fixed top bar: ElectorApp title on the LEFT, user badge on the RIGHT."""
initials = "".join(w[0] for w in activist_name.split()
if w and w[0].isalpha())[:2].upper() or "U"
st.markdown(
f"""
<div class="top-bar">
<div class="top-bar-brand">
<div class="top-bar-title">πŸ—³οΈ Differentially Private Elector (PoC)</div>
<div class="top-bar-subtitle"></div>
</div>
<div class="user-badge">
<div class="user-avatar">{initials}</div>
<div class="user-name">{activist_name}</div>
</div>
</div>
""",
unsafe_allow_html=True,
)
def _static_bar(value, label):
"""Pure HTML progress bar β€” one-way render, cannot trigger reruns."""
pct = int(round(min(value, 1.0) * 100))
color = "#27ae60" if pct >= 70 else "#e67e22" if pct >= 40 else "#e74c3c"
st.markdown(
f'<div style="margin:6px 0 12px 0;">'
f'<div style="display:flex;justify-content:space-between;'
f'font-size:12px;margin-bottom:3px;">'
f'<span>{label}</span><span>{pct}%</span></div>'
f'<div style="background:#e0e4ec;border-radius:6px;height:8px;">'
f'<div style="background:{color};width:{pct}%;height:8px;'
f'border-radius:6px;"></div></div></div>',
unsafe_allow_html=True,
)
def centered_spinner_html(text):
"""A CSS-only spinner centred in its container (see .dp-spinner styles)."""
return (f'<div class="dp-spin-wrap"><div class="dp-spinner"></div>'
f'<div class="dp-spin-text">{text}</div></div>')
# =============================================================================
# DP ACCURACY BANNER
# =============================================================================
def render_accuracy_banner(eps_vote, n_reported):
p_rr = np.exp(eps_vote) / (1.0 + np.exp(eps_vote))
flip = 1.0 - p_rr
n_total = len(st.session_state.voters)
n_cities = max(1, len(st.session_state.cities))
expected_city_size = max(1, int(round(n_total / n_cities)))
# City / turnout counts are de-biased sums of the stored RR reports β€” the
# same Ξ΅ as capture, by post-processing, with NO Laplace added. Their error
# is the RR sampling error; show the worst-case 95% margin for a typical city.
count_margin = rr_margin_of_error(expected_city_size, eps_vote)
count_accuracy = max(0.0, 1.0 - count_margin / expected_city_size)
if float(eps_vote) >= 2.0:
level_icon, level_txt = "🟒", ACC_LEVEL_HIGH
elif float(eps_vote) >= 1.0:
level_icon, level_txt = "🟑", ACC_LEVEL_MEDIUM
else:
level_icon, level_txt = "πŸ”΄", ACC_LEVEL_LOW
with st.expander(f"{level_icon} {ACC_TITLE} β€” {level_txt}", expanded=False):
# ── 1) Voting-status accuracy (the RR mechanism itself) ──────────
with st.container(border=True):
st.markdown(
ACC_STATUS_HEADER
+ "<span class='info-tooltip'><span class='info-icon'>i</span>"
"<span class='tooltip-text'>"
+ ACC_STATUS_TOOLTIP
+ "</span></span>",
unsafe_allow_html=True,
)
st.markdown(f"**ΧͺΧ§Χ¦Χ™Χ‘ Χ€Χ¨Χ˜Χ™Χ•Χͺ: Ξ΅ = {eps_vote}**")
st.divider()
n_correct = int(round(n_reported * p_rr))
n_flipped = n_reported - n_correct
if n_reported > 0:
st.markdown(
f"- βœ… **דיווחים נכונים:** {p_rr:.1%} "
f"({n_correct} מΧͺΧ•Χš {n_reported} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ ΧžΧ“Χ•Χ•Χ—Χ™Χ)\n"
f"- πŸ”€ **ה׀וכים Χ’Χ§Χ‘ Χ”Χ’Χ Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ:** {flip:.1%} "
f"({n_flipped} מΧͺΧ•Χš {n_reported} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ ΧžΧ“Χ•Χ•Χ—Χ™Χ)\n"
f"- **Χ“Χ™Χ•Χ§ Χ›Χ•ΧœΧœ: {p_rr:.0%}**"
)
else:
st.markdown(
f"- βœ… **Χ©Χ™Χ’Χ•Χ¨ דיווחים נכונים:** {p_rr:.1%}\n"
f"- πŸ”€ **Χ©Χ™Χ’Χ•Χ¨ ה׀וכים Χ’Χ§Χ‘ Χ”Χ’Χ Χͺ Χ€Χ¨Χ˜Χ™Χ•Χͺ:** {flip:.1%}\n"
f"- **Χ“Χ™Χ•Χ§ Χ›Χ•ΧœΧœ: {p_rr:.0%}**"
)
_static_bar(p_rr, f"Χ“Χ™Χ•Χ§: {p_rr:.0%}")
# ── 2) City-count accuracy (post-processing of the same Ξ΅) ───────
with st.container(border=True):
st.markdown(
ACC_CITY_HEADER
+ "<span class='info-tooltip'><span class='info-icon'>i</span>"
"<span class='tooltip-text'>"
+ ACC_CITY_TOOLTIP
+ "</span></span>",
unsafe_allow_html=True,
)
st.markdown(f"**ΧͺΧ§Χ¦Χ™Χ‘ Χ€Χ¨Χ˜Χ™Χ•Χͺ: Ξ΅ = {eps_vote}** (אוΧͺΧ• Ξ΅ β€” Χ’Χ™Χ‘Χ•Χ“-Χ”ΧžΧ©Χš)")
st.divider()
example = expected_city_size
lo_ex = max(0, round(example - count_margin))
hi_ex = round(example + count_margin)
st.markdown(
f"- πŸ“ **שגיאΧͺ Χ“Χ’Χ™ΧžΧ”:** Β±{count_margin:.0f} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ "
f"(Χ¨Χ•Χ•Χ— במך 95%, Χ’Χ™Χ¨ Χ‘Χ’Χ•Χ“Χœ ~{example})\n"
f"- πŸ“Š **Χ“Χ•Χ’ΧžΧ”:** Χ’Χ‘Χ•Χ¨ Χ‘Χ€Χ™Χ¨Χ” ΧžΧ•Χ’Χ¨Χ›Χͺ של {example} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ β€” "
f"Χ”Χ’Χ¨Χš Χ”ΧΧžΧ™ΧͺΧ™ Χ Χ’ Χ‘Χ™ΧŸ {lo_ex} ל-{hi_ex} Χ‘Χ‘Χ‘Χ™Χ¨Χ•Χͺ 95%\n"
f"- **Χ“Χ™Χ•Χ§ Χ›Χ•ΧœΧœ: {count_accuracy:.0%}**"
)
_static_bar(count_accuracy, f"Χ“Χ™Χ•Χ§: {count_accuracy:.0%}")
st.markdown(
"<div style='direction:rtl;text-align:right;margin-top:8px;margin-bottom:8px;padding:8px;"
"background:#fffbe6;border-radius:8px;border:1px solid #ffe58f;'>"
"<strong style='font-size:16px;'>"
+ ACC_TIP_TEXT
+ "</strong></div>",
unsafe_allow_html=True,
)
# =============================================================================
# SIDEBAR
# =============================================================================
def render_sidebar():
"""
Navigation only. The privacy budget Ξ΅ and the simulation controls live in
the page body (see `page_dashboard`), mirroring the tradeoff page layout.
Returns the current page key.
"""
if "current_page" not in st.session_state:
st.session_state.current_page = "dashboard"
nav_items = [
("dashboard", NAV_DASHBOARD),
("voters", NAV_VOTERS),
("tradeoff", NAV_TRADEOFF),
]
with st.sidebar:
st.markdown(NAV_MENU_HEADER, unsafe_allow_html=True)
for page_key, label in nav_items:
is_active = st.session_state.current_page == page_key
open_tag = '<div class="active-nav">' if is_active else "<div>"
st.markdown(open_tag, unsafe_allow_html=True)
if st.button(label, key=f"nav_{page_key}", use_container_width=True):
st.session_state.current_page = page_key
st.rerun()
st.markdown("</div>", unsafe_allow_html=True)
return st.session_state.current_page
# =============================================================================
# DATA SOURCE (real CEC aggregates β†’ synthetic individual voters)
# =============================================================================
@st.cache_data(show_spinner=SPINNER_BUILD_POPULATION)
def build_population(settlements, fraction, max_per_settlement,
keep_top_parties, seed):
"""
Synthetic individual-level voter population grounded in the real CEC
results (see population.py). Cached so it is built once per parameter set,
not on every Streamlit rerun. Returns the schema population.SCHEMA, with a
`latent_support` column (modelled preference) used as the party signal.
"""
return population.synthesize_population(
settlements=settlements,
fraction=fraction,
max_per_settlement=max_per_settlement,
keep_top_parties=keep_top_parties,
seed=seed,
)
def assign_party_colors(parties):
"""Map each party label to a stable colour from PARTY_PALETTE."""
return {p: PARTY_PALETTE[i % len(PARTY_PALETTE)] for i, p in enumerate(parties)}
# =============================================================================
# SESSION STATE
# =============================================================================
def init_session_state():
if "voters" not in st.session_state:
pop = build_population(
APP_SETTLEMENTS, APP_FRACTION, APP_MAX_PER_SETTLEMENT,
APP_KEEP_TOP_PARTIES, SEED,
)
st.session_state.voters = pop
# Cities and parties are DERIVED from the data, not hard-coded.
st.session_state.cities = population.settlement_names(pop)
st.session_state.party_names = population.party_candidates(pop)
# Pin the organising party if configured and present; else largest by support.
party_names = st.session_state.party_names
st.session_state.organiser = (
APP_ORGANISER if APP_ORGANISER in party_names else party_names[0]
)
st.session_state.party_colors = assign_party_colors(party_names)
if "reported_voted" not in st.session_state:
st.session_state.reported_voted = {}
if "reported_party" not in st.session_state:
st.session_state.reported_party = {}
# Single privacy budget Ξ΅, shared across pages. Pre-seeding the key lets the
# in-body slider (key="eps") use it as its initial value.
st.session_state.setdefault("eps", DEFAULT_EPS)
# Fraction of the list the bulk simulation populates with a status (the rest
# are left without one). Pre-seeded so the in-body slider (key="fill_fraction")
# uses it as its initial value.
st.session_state.setdefault("fill_fraction", APP_SIM_FILL_FRACTION)
# =============================================================================
# DP REPORTING ACTIONS
# =============================================================================
def record_voter_report(voter_id, true_voted, party, eps):
"""
Χ”Χ—Χœ Χ¨Χ’Χ© DP Χ•Χ©ΧžΧ•Χ¨ אΧͺ Χ”Χ’Χ¨Χ‘Χ” Χ”ΧžΧ•Χ’Χ Χͺ β€” לא אΧͺ Χ”ΧΧžΧͺ.
A single Ξ΅ protects both the participation status (binary RR) and the
optional party signal (k-RR). `party` is `latent_support` (defined for every
individual and always a real candidate) β€” never `true_party`, which is <NA>
for non-voters and "Χ€Χ‘Χ•Χœ" for invalid ballots.
"""
dp_voted = randomized_response(true_voted, eps)
dp_party = k_randomized_response(party, st.session_state.party_names, eps)
st.session_state.reported_voted[voter_id] = dp_voted
st.session_state.reported_party[voter_id] = dp_party
def bulk_simulate_unreported(eps, fill_fraction=1.0):
"""
Simulate a *partial* fill of the Elector: only a random `fill_fraction` of the
not-yet-reported voters get a DP-protected voting status; the rest are left
with no status at all (the realistic case where the system is only partly
populated). The DP mechanism is still applied at the point of capture β€” only
the noised value is stored, never the truth.
"""
df = st.session_state.voters
pending_ids = [vid for vid in df["voter_id"].tolist()
if vid not in st.session_state.reported_voted]
n_fill = int(round(max(0.0, min(1.0, fill_fraction)) * len(pending_ids)))
if n_fill >= len(pending_ids):
fill_ids = set(pending_ids)
else:
rng = np.random.default_rng(SEED)
fill_ids = set(rng.choice(pending_ids, size=n_fill, replace=False).tolist())
for _, row in df.iterrows():
vid = row["voter_id"]
if vid in fill_ids:
record_voter_report(
vid, row["true_voted"], row["latent_support"], eps
)
# =============================================================================
# AGGREGATE HELPERS
# =============================================================================
def compute_city_dp_counts(eps_vote):
df = st.session_state.voters
rv = st.session_state.reported_voted
rows = []
for city in st.session_state.cities:
city_ids = df.loc[df.city == city, "voter_id"].tolist()
n = len(city_ids)
reported_flags = [rv[vid] for vid in city_ids if vid in rv]
# De-bias the sum of the stored RR reports to recover the true "voted"
# count. Summing/de-biasing the already-perturbed reports is
# post-processing of the same capture-time Ξ΅ β€” no Laplace noise added.
if reported_flags:
est_rate = estimate_rr_frequency(reported_flags, eps_vote)
dp_count = max(0, int(round(est_rate * len(reported_flags))))
else:
dp_count = 0
true_count = int(df.loc[df.city == city, "true_voted"].sum())
rows.append({
COL_CITY : city,
COL_RECRUITED : n,
COL_VOTED_DP : dp_count,
COL_VOTED_TRUE : true_count,
COL_REPORTS : len(reported_flags),
COL_PENDING : n - len(reported_flags),
})
return pd.DataFrame(rows)
def compute_party_dp_estimates(eps):
rp = st.session_state.reported_party
party_names = st.session_state.party_names
if not rp:
return {p: 0 for p in party_names}
reported_parties = list(rp.values())
freqs = estimate_krr_frequency(reported_parties, party_names, eps)
total = len(reported_parties)
return {p: max(0, int(round(freqs[p] * total))) for p in party_names}
def compute_overall_turnout_estimate(eps_vote):
rv = st.session_state.reported_voted
if not rv:
return 0.0, 0, 0
flags = list(rv.values())
est_rate = estimate_rr_frequency(flags, eps_vote)
est_count = int(round(est_rate * len(flags)))
return est_rate, est_count, len(flags)
# =============================================================================
# PLOT HELPERS (all Hebrew strings wrapped with heb() for matplotlib)
# =============================================================================
def plot_city_bars(city_df):
"""Χͺרשים Χ’ΧžΧ•Χ“Χ•Χͺ או׀קי ΧΧ™Χ Χ˜Χ¨ΧΧ§Χ˜Χ™Χ‘Χ™: Χ”Χ¦Χ‘Χ™Χ’Χ• (DP) ΧœΧ’Χ•ΧžΧͺ ממΧͺינים ΧœΧ€Χ™ Χ’Χ™Χ¨."""
cities = city_df[COL_CITY].tolist()
voted = city_df[COL_VOTED_DP].tolist()
pending = city_df[COL_PENDING].tolist()
fig = go.Figure()
fig.add_trace(go.Bar(
y=cities, x=voted, orientation='h',
name=PLOT_CITY_VOTED_TRACE, marker_color=STATUS_VOTED, opacity=0.85,
))
fig.add_trace(go.Bar(
y=cities, x=pending, orientation='h',
name=PLOT_CITY_PENDING_TRACE, marker_color=STATUS_PENDING, opacity=0.60,
))
fig.update_layout(
barmode='stack',
title=dict(text=PLOT_CITY_TITLE, font=dict(size=13, color='#1a2340')),
xaxis_title=PLOT_CITY_XAXIS,
legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1,
font=dict(color='#1a2340')),
height=320, margin=dict(l=10, r=10, t=55, b=10),
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
font=dict(family='Heebo, sans-serif', color='#1a2340'),
modebar=dict(bgcolor='rgba(240,244,248,0.9)', color='#1a2340', activecolor='#3b6cb7'),
)
fig.update_xaxes(showgrid=True, gridcolor='#e0e4ec', zeroline=False,
tickfont=dict(color='#1a2340'), title_font=dict(color='#1a2340'),
linecolor='#c0ccde', tickcolor='#c0ccde')
fig.update_yaxes(showgrid=False,
tickfont=dict(color='#1a2340'), linecolor='#c0ccde')
return fig
def _map_layout(fig):
"""Shared MapLibre layout: Israel-framed, token-free tiles, light theme."""
fig.update_layout(
map=dict(style="carto-positron",
center=dict(lat=geo.ISRAEL_CENTER[0], lon=geo.ISRAEL_CENTER[1]),
zoom=geo.ISRAEL_ZOOM),
height=460, margin=dict(l=0, r=0, t=10, b=0),
paper_bgcolor='rgba(0,0,0,0)',
font=dict(family='Heebo, sans-serif', color='#1a2340'),
legend=dict(orientation='h', yanchor='bottom', y=1.01, xanchor='right', x=1,
bgcolor='rgba(255,255,255,0.7)', font=dict(color='#1a2340')),
modebar=dict(bgcolor='rgba(240,244,248,0.9)', color='#1a2340', activecolor='#3b6cb7'),
)
return fig
def plot_settlement_bubble_map(city_df, metric):
"""
One bubble per settlement at its centroid; colour = a de-biased DP aggregate,
size ∝ settlement size. Reads only `city_df` (already de-biased by
compute_city_dp_counts) β€” post-processing of the stored RR reports, no extra
privacy budget. Returns (figure, n_settlements_without_coords).
"""
latlon = geo.city_latlon()
lats, lons, sizes, values, texts, hover = [], [], [], [], [], []
n_missing = 0
for _, row in city_df.iterrows():
city = row[COL_CITY]
if city not in latlon:
n_missing += 1
continue
reports = row[COL_REPORTS]
if metric == MAP_METRIC_TURNOUT:
val = (row[COL_VOTED_DP] / reports) if reports else 0.0
hov = f"{val:.0%}"
elif metric == MAP_METRIC_PENDING:
val = row[COL_PENDING]
hov = f"{int(val):,}"
else: # MAP_METRIC_VOTED
val = row[COL_VOTED_DP]
hov = f"{int(val):,}"
lat, lon = latlon[city]
lats.append(lat); lons.append(lon)
sizes.append(row[COL_RECRUITED])
values.append(val)
texts.append(city)
hover.append(hov)
colorscale = "YlOrRd" if metric == MAP_METRIC_PENDING else "YlGn"
n_max = max(sizes) if sizes else 1
marker_sizes = [8 + 34 * (s / n_max) ** 0.5 for s in sizes]
fig = go.Figure(go.Scattermap(
lat=lats, lon=lons, mode='markers',
marker=dict(
size=marker_sizes,
color=values, colorscale=colorscale, showscale=True,
colorbar=dict(title=dict(text=metric, font=dict(color='#1a2340')),
tickfont=dict(color='#1a2340')),
opacity=0.85,
),
text=texts, customdata=hover,
hovertemplate="<b>%{text}</b><br>" + metric + ": %{customdata}<extra></extra>",
))
return _map_layout(fig), n_missing
def plot_voter_dot_map():
"""
One dot per REPORTED voter, jittered around its settlement centroid, coloured
by the STORED (RR-perturbed) status β€” never `true_voted`. This is exactly the
noised record a leak would expose: individually deniable, yet accurate in
aggregate. Returns (figure_or_None, note_or_None).
"""
rv = st.session_state.reported_voted
df = st.session_state.voters
latlon = geo.city_latlon()
rep = df.loc[df.voter_id.isin(rv), ["voter_id", "city"]].copy()
rep = rep[rep.city.isin(latlon)]
if rep.empty:
return None, None
note = None
total = len(rep)
if total > MAP_DOTS_CAP:
rep = rep.sample(MAP_DOTS_CAP, random_state=SEED)
note = MAP_DOTS_CAPPED_MSG.format(shown=MAP_DOTS_CAP, total=total)
rep["voted"] = rep.voter_id.map(rv)
rng = np.random.default_rng(SEED)
lat_v, lon_v, lat_n, lon_n = [], [], [], []
for city, grp in rep.groupby("city", sort=False):
lat, lon = latlon[city]
la, lo = geo.jitter(lat, lon, len(grp), rng)
flags = grp["voted"].to_numpy()
lat_v.extend(la[flags]); lon_v.extend(lo[flags])
lat_n.extend(la[~flags]); lon_n.extend(lo[~flags])
fig = go.Figure()
fig.add_trace(go.Scattermap(
lat=lat_n, lon=lon_n, mode='markers', name=MAP_DOT_NOTVOTED,
marker=dict(size=6, color=STATUS_PENDING, opacity=0.55),
hovertemplate=MAP_DOT_NOTVOTED + "<extra></extra>",
))
fig.add_trace(go.Scattermap(
lat=lat_v, lon=lon_v, mode='markers', name=MAP_DOT_VOTED,
marker=dict(size=6, color=STATUS_VOTED, opacity=0.75),
hovertemplate=MAP_DOT_VOTED + "<extra></extra>",
))
return _map_layout(fig), note
def render_city_map(city_df):
"""Geographic view under the per-city panel: aggregate bubbles or DP dots."""
if not geo.coords_available():
st.info(MAP_NO_COORDS_FILE)
return
st.markdown(MAP_HEADER)
view = st.radio(MAP_VIEW_LABEL, [MAP_VIEW_AGG, MAP_VIEW_DOTS],
horizontal=True, key="map_view")
if view == MAP_VIEW_AGG:
metric = st.selectbox(
MAP_METRIC_LABEL,
[MAP_METRIC_TURNOUT, MAP_METRIC_PENDING, MAP_METRIC_VOTED],
key="map_metric",
)
fig, n_missing = plot_settlement_bubble_map(city_df, metric)
st.plotly_chart(fig, use_container_width=True)
st.caption(MAP_AGG_TITLE)
if n_missing:
st.caption(MAP_MISSING_COORDS.format(n=n_missing))
else:
fig, note = plot_voter_dot_map()
if fig is None:
st.info(DASH_NO_REPORTS_INFO)
return
st.plotly_chart(fig, use_container_width=True)
st.markdown(MAP_DOTS_TITLE)
if note:
st.caption(note)
def plot_party_estimates(party_counts):
"""Χͺרשים Χ’ΧžΧ•Χ“Χ•Χͺ ΧΧ™Χ Χ˜Χ¨ΧΧ§Χ˜Χ™Χ‘Χ™: Χ”Χ’Χ¨Χ›Χͺ Χ”Χ¦Χ‘Χ’Χ•Χͺ ΧœΧ€Χ™ ΧžΧ€ΧœΧ’Χ” (k-RR)."""
organiser = st.session_state.organiser
party_colors = st.session_state.party_colors
parties = list(party_counts.keys())
counts = [party_counts[p] for p in parties]
colors = [party_colors.get(p, "#888") for p in parties]
org_idx = parties.index(organiser)
fig = go.Figure(go.Bar(
x=parties, y=counts,
marker_color=colors,
marker_line_color=['gold' if i == org_idx else 'black' for i in range(len(parties))],
marker_line_width=[2.5 if i == org_idx else 0.8 for i in range(len(parties))],
text=counts, textposition='outside',
))
fig.update_layout(
title=dict(
text=f"Χ”Χ’Χ¨Χ›Χͺ DP β€” Χ”Χ¦Χ‘Χ’Χ•Χͺ ΧœΧ€Χ™ ΧžΧ€ΧœΧ’Χ” (ΧžΧ‘Χ’Χ¨Χͺ Χ–Χ”Χ‘ = {organiser})",
font=dict(size=13, color='#1a2340'),
),
yaxis_title=PLOT_PARTY_YAXIS,
height=320, margin=dict(l=10, r=10, t=55, b=10),
paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
font=dict(family='Heebo, sans-serif', color='#1a2340'),
showlegend=False,
modebar=dict(bgcolor='rgba(240,244,248,0.9)', color='#1a2340', activecolor='#3b6cb7'),
)
fig.update_yaxes(showgrid=True, gridcolor='#e0e4ec', zeroline=False,
tickfont=dict(color='#1a2340'), title_font=dict(color='#1a2340'),
linecolor='#c0ccde', tickcolor='#c0ccde')
fig.update_xaxes(showgrid=False,
tickfont=dict(color='#1a2340'), linecolor='#c0ccde')
return fig
# =============================================================================
# PAGE: MERGED DASHBOARD (overview + cities + parties)
# =============================================================================
def page_dashboard():
st.markdown(f'<div class="page-title">{DASH_PAGE_TITLE}</div>',
unsafe_allow_html=True)
st.markdown(DASH_SUBTITLE)
st.info(DASH_NO_REPORTS_INFO)
# ── Controls (Ξ΅ slider + simulation actions) β€” in the page body, ────
# mirroring the privacy-utility-cost page layout. ──────────────────
with st.expander(DASH_SETTINGS_EXPANDER, expanded=True):
col_eps, col_run = st.columns([2, 1])
with col_eps:
if SHOW_PRIVACY_SETTINGS:
st.slider(DASH_EPS_SLIDER, 0.1, 5.0, step=0.1, key="eps")
st.slider(
DASH_FILL_SLIDER,
0.0, 1.0, step=0.05, key="fill_fraction",
help=DASH_FILL_HELP,
format="%.2f",
)
with col_run:
run_sim = st.button(DASH_RUN_BTN, use_container_width=True,
type="primary", help=DASH_RUN_BTN_HELP)
reset_sim = st.button(DASH_RESET_BTN, use_container_width=True)
eps = st.session_state.eps
if run_sim:
spinner = st.empty()
spinner.markdown(
centered_spinner_html(SPINNER_RUN_SIM),
unsafe_allow_html=True,
)
bulk_simulate_unreported(eps, st.session_state.fill_fraction)
spinner.empty()
n_now = len(st.session_state.reported_voted)
st.success(
f"Χ”Χ‘Χ™ΧžΧ•ΧœΧ¦Χ™Χ” Χ”Χ‘ΧͺΧ™Χ™ΧžΧ” β€” Χ Χ§ΧœΧ˜Χ• Χ“Χ•Χ—Χ•Χͺ Χ’Χ‘Χ•Χ¨ {n_now:,} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ "
f"({st.session_state.fill_fraction:.0%} ΧžΧ”Χ¨Χ©Χ™ΧžΧ”); השאר Χ Χ•ΧͺΧ¨Χ• ללא Χ‘Χ˜Χ˜Χ•Χ‘."
)
if reset_sim:
st.session_state.reported_voted = {}
st.session_state.reported_party = {}
st.success(DASH_RESET_DONE)
n_total = len(st.session_state.voters)
n_reported = len(st.session_state.reported_voted)
est_rate, est_count, _ = compute_overall_turnout_estimate(eps)
# ── Accuracy banner (shows impact of current Ξ΅ selection) ──────────
render_accuracy_banner(eps, n_reported)
st.divider()
# ── Top metric cards ────────────────────────────────────────────────
n_pending = n_total - n_reported
c1, c2, c3 = st.columns(3)
c1.metric(DASH_METRIC_MOBILIZED, f"{n_reported:,}",
delta=f"{n_reported/n_total:.0%} ΧžΧ”Χ¨Χ©Χ™ΧžΧ”")
c2.metric(DASH_METRIC_VOTED,
f"{est_count:,}" if n_reported > 0 else "β€”",
delta=f"~{est_rate:.0%} ΧžΧ”ΧžΧ“Χ•Χ•Χ—Χ™Χ" if n_reported > 0 else None)
c3.metric(DASH_METRIC_PENDING,
f"{n_pending:,}",
delta=f"Χ Χ•ΧͺΧ¨Χ• Χ’Χ•Χ“ {n_pending} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ ΧœΧ”ΧžΧ¨Χ™Χ₯" if n_pending > 0 else DASH_ALL_MOBILIZED,
delta_color="inverse")
if n_reported == 0:
return
city_df = compute_city_dp_counts(eps)
# ── Per-city panel: bar chart and map side-by-side ──────────────────
# Left: chased (voted, DP) vs. still-pending per city.
# Right: geographic view (aggregate bubbles, default, or DP-record dots).
st.divider()
col_bars, col_map = st.columns(2, gap="large")
with col_bars:
st.markdown(DASH_CITY_CHART_HEADER)
st.plotly_chart(plot_city_bars(city_df), use_container_width=True)
with col_map:
render_city_map(city_df)
st.divider()
# =============================================================================
# PAGE: VOTER LIST (name on the right, buttons on the left)
# =============================================================================
def page_voter_list():
eps = st.session_state.eps
st.markdown(f'<div class="page-title">{VOTERS_PAGE_TITLE}</div>',
unsafe_allow_html=True)
st.info(VOTERS_CAPTION)
df = st.session_state.voters
rv = st.session_state.reported_voted
col_f1, col_f2, col_f3 = st.columns([2, 2, 1])
with col_f1:
city_filter = st.selectbox(VOTERS_FILTER_CITY,
[VOTERS_FILTER_ALL] + st.session_state.cities)
with col_f2:
status_filter = st.selectbox(
VOTERS_FILTER_STATUS,
[VOTERS_FILTER_ALL, VOTERS_STATUS_NOT_REPORTED, VOTERS_STATUS_REPORTED_VOTED],
)
with col_f3:
search = st.text_input(VOTERS_SEARCH, "")
filtered = df.copy()
if city_filter != VOTERS_FILTER_ALL:
filtered = filtered[filtered.city == city_filter]
if search:
filtered = filtered[filtered.name.str.contains(search, case=False)]
if status_filter == VOTERS_STATUS_NOT_REPORTED:
filtered = filtered[~filtered.voter_id.isin(rv)]
elif status_filter == VOTERS_STATUS_REPORTED_VOTED:
filtered = filtered[filtered.voter_id.isin(rv)]
# The synthetic population can be large; cap how many cards we render so the
# page stays responsive. Filters above narrow the list to find specific voters.
total_match = len(filtered)
shown = filtered.head(VOTER_LIST_DISPLAY_CAP)
if total_match > len(shown):
st.markdown(
f"**ΧžΧ•Χ¦Χ’Χ™Χ {len(shown)} מΧͺΧ•Χš {total_match} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ** β€” "
f"Χ”Χ©Χͺמש Χ‘Χ‘Χ™Χ Χ•ΧŸ Χ›Χ“Χ™ לצמצם אΧͺ Χ”Χ¨Χ©Χ™ΧžΧ”."
)
else:
st.markdown(f"**ΧžΧ•Χ¦Χ’Χ™Χ {total_match} ΧžΧ¦Χ‘Χ™Χ’Χ™Χ**")
st.divider()
for _, row in shown.iterrows():
vid = row["voter_id"]
already = vid in rv
with st.container(border=True):
# Layout (left β†’ right): buttons | status | name
# Hebrew reading order (right β†’ left): name | status | buttons
col_btns, col_status, col_name = st.columns([2, 2, 3])
# Rightmost column: voter name and city
with col_name:
icon = "βœ…" if vid in rv else "⏳"
st.markdown(f"**{icon} {row['name']}**")
st.caption(f"πŸ“ {row['city']}")
# Middle column: report status
with col_status:
if already:
st.markdown(VOTERS_REPORTED_BADGE, unsafe_allow_html=True)
st.caption(VOTERS_REPORTED_CAPTION)
else:
st.caption(VOTERS_STATUS_NOT_REPORTED)
# Leftmost column: action buttons
with col_btns:
if not already:
if st.button(VOTERS_VOTE_BTN, key=f"v_yes_{vid}",
use_container_width=False):
record_voter_report(vid, True, row["latent_support"], eps)
st.rerun()
else:
if st.button(VOTERS_UNDO_BTN, key=f"v_undo_{vid}",
width='content'):
del st.session_state.reported_voted[vid]
if vid in st.session_state.reported_party:
del st.session_state.reported_party[vid]
st.rerun()
if total_match == 0:
st.info(VOTERS_NO_MATCH_INFO)
# =============================================================================
# PRIVACY–UTILITY–COST ANALYSIS (offline sweep reused from experiments/)
# =============================================================================
@st.cache_data(show_spinner=False)
def build_analysis_population(fraction, keep_top_parties, support_noise, seed):
"""
Population for the offline sweep β€” same default settlements as the dashboard
but WITHOUT the per-settlement cap, so party sizes stay realistic. A noisy
support model (>0) makes the party's predictor imperfect (research Q A3).
"""
support_model = ("observed" if not support_noise or support_noise <= 0
else population.make_noisy_support_model(support_noise))
return population.synthesize_population(
settlements=None,
fraction=fraction,
keep_top_parties=keep_top_parties,
support_model=support_model,
seed=seed,
)
@st.cache_data(show_spinner=False)
def run_analysis(fraction, keep_top_parties, support_noise, organiser, repeats,
frac_activists, calls_per_activist, cost_per_call, seed):
"""
Run the full Ξ΅ sweep and return (agg, organiser, n_pop, n_supporters,
n_activists, effective_budget). Cached on every argument, so re-tweaking the
Ξ΅ highlight slider is instant β€” only changing a parameter re-runs the sweep.
"""
pop = build_analysis_population(fraction, keep_top_parties, support_noise, seed)
candidates = population.party_candidates(pop)
org = organiser if organiser in candidates else candidates[0]
n_supporters = int((pop["latent_support"] == org).sum())
n_activists = max(1, round(frac_activists * n_supporters))
effective_budget = n_activists * calls_per_activist
raw = pu.run_sweep(pop, org, candidates, pu.DEFAULT_EPSILONS,
repeats=repeats, budget=effective_budget, seed=seed + 1)
agg = pu.aggregate(raw)
# Price the privacy-attributable misdirected calls (same column the script writes).
agg["cost_misdirected_calls"] = agg["wasted_due_to_privacy"] * cost_per_call
return agg, org, len(pop), n_supporters, n_activists, effective_budget
def _hex_to_rgba(hex_color, alpha):
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"rgba({r},{g},{b},{alpha})"
def plot_tradeoff(agg, organiser, cost_per_call, min_recall, min_precision,
max_loss, optimal_eps, feasible_left, selected_eps):
"""
Interactive Plotly reproduction of the paper's `_combined_rrerr` figure.
LEFT axis (rate 0–1): recall ↑, precision ↑, voter-risk ↓ β€” each with a 95%
RR-sampling band (Β±1.96Οƒ). RIGHT axis (USD): cost of misdirected calls ↓.
Constraint lines (min recall / min precision / max loss), the party-feasible
Ξ΅ region (green band) and the privacy-optimal feasible Ξ΅ (β˜… + dashed line)
are drawn on top. Legend entries toggle each curve (with its band).
"""
finite = agg[np.isfinite(agg["epsilon"])].sort_values("epsilon")
x = finite["epsilon"].to_numpy(dtype=float)
span = float(x.max() - x.min()) or 1.0
x_lo, x_hi = x.min() - 0.04 * span, x.max() + 0.04 * span
C_RISK, C_REC, C_PREC, C_COST, C_FEAS = (
"#C44E52", "#3b6cb7", "#55A868", "#DD8452", "#2E8B57")
fig = go.Figure()
def band(y, e, color, group, axis="y"):
fig.add_trace(go.Scatter(x=x, y=y + e, mode="lines", line=dict(width=0),
hoverinfo="skip", showlegend=False,
legendgroup=group, yaxis=axis))
fig.add_trace(go.Scatter(x=x, y=y - e, mode="lines", line=dict(width=0),
fill="tonexty", fillcolor=_hex_to_rgba(color, 0.15),
hoverinfo="skip", showlegend=False,
legendgroup=group, yaxis=axis))
def curve(y, e, color, name, group, symbol, dash=None, axis="y", fmt=".3f"):
band(y, e, color, group, axis)
fig.add_trace(go.Scatter(
x=x, y=y, mode="lines+markers", name=name, legendgroup=group,
line=dict(color=color, dash=dash, width=2),
marker=dict(color=color, symbol=symbol, size=8), yaxis=axis,
hovertemplate="Ξ΅=%{x:g}<br>" + name + ": %{y:" + fmt + "}<extra></extra>"))
rec = finite["recall_mean"].to_numpy()
prec = finite["precision_mean"].to_numpy()
risk = finite["status_disclosure_advantage"].to_numpy()
cost = finite["cost_misdirected_calls"].to_numpy()
rec_e = 1.96 * finite["recall_std"].to_numpy()
prec_e = 1.96 * finite["precision_std"].to_numpy()
risk_e = 1.96 * finite["status_attacker_std"].to_numpy()
cost_e = 1.96 * finite["wasted_std"].to_numpy() * cost_per_call
curve(rec, rec_e, C_REC, PLOT_TO_RECALL, "rec", "triangle-down")
curve(prec, prec_e, C_PREC, PLOT_TO_PRECISION, "prec", "triangle-up")
curve(risk, risk_e, C_RISK, PLOT_TO_RISK, "risk",
"square", dash="dash")
curve(cost, cost_e, C_COST, f"Χ’ΧœΧ•Χͺ: Χ©Χ™Χ—Χ•Χͺ ΧžΧ•Χ˜Χ’Χ•Χͺ ↓ (@ ${cost_per_call:g})",
"cost", "diamond", axis="y2", fmt=",.0f")
# ── party-feasible Ξ΅ region + privacy-optimal Ξ΅ (β˜…) ──────────────────
if optimal_eps is not None and feasible_left is not None:
fig.add_vrect(x0=feasible_left, x1=x_hi, fillcolor=_hex_to_rgba(C_FEAS, 0.12),
line_width=0, layer="below")
fig.add_vline(x=optimal_eps, line=dict(color=C_FEAS, dash="dash", width=2))
fig.add_trace(go.Scatter(
x=[optimal_eps], y=[0.985], mode="markers+text",
marker=dict(symbol="star", size=20, color=C_FEAS,
line=dict(color="white", width=1)),
text=[f" Ξ΅={optimal_eps:g}"], textposition="middle right",
textfont=dict(color=C_FEAS, size=13),
name=f"β˜… Ξ΅ ΧΧ•Χ€Χ˜Χ™ΧžΧœΧ™ ΧœΧ€Χ¨Χ˜Χ™Χ•Χͺ = {optimal_eps:g}",
hovertemplate=f"Ξ΅ ΧΧ•Χ€Χ˜Χ™ΧžΧœΧ™ (Χ”Χ›Χ™ Χ€Χ¨Χ˜Χ™ Χ©Χ’Χ“Χ™Χ™ΧŸ ישים) = {optimal_eps:g}<extra></extra>"))
# ── Ξ΅ highlight from the slider ──────────────────────────────────────
if selected_eps is not None:
fig.add_vline(x=selected_eps, line=dict(color="#666", dash="dot", width=1.5),
annotation_text=f"Ξ΅ Χ Χ‘Χ—Χ¨ = {selected_eps:g}",
annotation_position="top",
annotation_font=dict(color="#666", size=11))
# ── constraint threshold lines ───────────────────────────────────────
if min_recall:
fig.add_hline(y=min_recall, line=dict(color=C_REC, dash="dot", width=1),
annotation_text=f"recall ΧžΧ™Χ ' {min_recall:g}",
annotation_position="bottom left",
annotation_font=dict(color=C_REC, size=11))
if min_precision:
fig.add_hline(y=min_precision, line=dict(color=C_PREC, dash="dot", width=1),
annotation_text=f"precision ΧžΧ™Χ ' {min_precision:g}",
annotation_position="top left",
annotation_font=dict(color=C_PREC, size=11))
if max_loss is not None:
fig.add_trace(go.Scatter(
x=[x_lo, x_hi], y=[max_loss, max_loss], mode="lines", yaxis="y2",
line=dict(color=C_COST, dash="dot", width=1),
showlegend=False, hoverinfo="skip"))
fig.add_annotation(x=x_hi, y=max_loss, yref="y2", text=f"Χ”Χ€Χ‘Χ“ מקב' ${max_loss:,.0f}",
font=dict(color=C_COST, size=11), showarrow=False,
xanchor="right", yanchor="bottom")
fig.update_layout(
title=dict(text=f"Χ€Χ¨Χ˜Χ™Χ•Χͺ Β· ΧͺΧ•Χ’ΧœΧͺ Β· Χ’ΧœΧ•Χͺ ({organiser})",
font=dict(size=16, color="#1a2340")),
xaxis=dict(
title=PLOT_TO_XAXIS,
tickmode="array", tickvals=list(x), ticktext=[f"{v:g}" for v in x],
range=[x_lo, x_hi], showgrid=True, gridcolor="#e0e4ec", zeroline=False,
tickfont=dict(color="#1a2340"), title_font=dict(color="#1a2340"),
linecolor="#c0ccde", tickcolor="#c0ccde"),
yaxis=dict(
title=PLOT_TO_YAXIS, range=[-0.02, 1.02], showgrid=True,
gridcolor="#e0e4ec", zeroline=False, tickfont=dict(color="#1a2340"),
title_font=dict(color="#1a2340"), linecolor="#c0ccde", tickcolor="#c0ccde"),
yaxis2=dict(
title=f"Χ’ΧœΧ•Χͺ Χ©Χ™Χ—Χ•Χͺ ΧžΧ•Χ˜Χ’Χ•Χͺ (USD @ ${cost_per_call:g})", overlaying="y",
side="right", rangemode="tozero", showgrid=False,
tickfont=dict(color=C_COST), title_font=dict(color=C_COST),
linecolor=C_COST, tickcolor=C_COST),
legend=dict(orientation="h", yanchor="top", y=-0.20, xanchor="center", x=0.5,
groupclick="togglegroup", font=dict(color="#1a2340")),
height=580, margin=dict(l=20, r=20, t=60, b=130),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(family="Heebo, sans-serif", color="#1a2340"),
hovermode="x unified",
modebar=dict(bgcolor="rgba(240,244,248,0.9)", color="#1a2340", activecolor="#3b6cb7"),
)
return fig
# =============================================================================
# PAGE: PRIVACY–UTILITY–COST TRADEOFF
# =============================================================================
def page_tradeoff():
st.markdown(f'<div class="page-title">{TRADEOFF_PAGE_TITLE}</div>',
unsafe_allow_html=True)
st.markdown(TRADEOFF_INTRO)
st.info(TRADEOFF_CONFIGURE_INFO)
with st.expander(TRADEOFF_PARAMS_EXPANDER, expanded=True):
c1, c2, c3 = st.columns(3)
with c1:
st.markdown(TRADEOFF_SEC_PARTY_POWER)
frac_activists = st.slider(
TRADEOFF_FRAC_ACTIVISTS, 0.01, 0.50, ANALYSIS_FRAC_ACTIVISTS, 0.01,
help=TRADEOFF_FRAC_ACTIVISTS_HELP)
calls_per_activist = st.number_input(
TRADEOFF_CALLS, min_value=1, max_value=1_000_000,
value=ANALYSIS_CALLS, step=50,
help=TRADEOFF_CALLS_HELP)
with c2:
st.markdown(TRADEOFF_SEC_COST_RUNS)
cost_per_call = st.number_input(
TRADEOFF_COST, min_value=0.0, max_value=100.0,
value=ANALYSIS_COST_PER_CALL, step=0.1,
help=TRADEOFF_COST_HELP)
repeats = st.slider(
TRADEOFF_REPEATS, 1, 50, ANALYSIS_REPEATS,
help=TRADEOFF_REPEATS_HELP)
with c3:
st.markdown(TRADEOFF_SEC_POP_MODEL)
fraction = st.slider(
TRADEOFF_FRACTION, 0.05, 1.0, ANALYSIS_FRACTION, 0.05,
help=TRADEOFF_FRACTION_HELP)
support_noise = st.slider(
TRADEOFF_SUPPORT_NOISE, 0.0, 1.0, 0.0, 0.05,
help=TRADEOFF_SUPPORT_NOISE_HELP)
st.markdown(TRADEOFF_SEC_CONSTRAINTS)
d1, d2, d3 = st.columns(3)
with d1:
min_recall = st.slider(TRADEOFF_MIN_RECALL, 0.0, 1.0, ANALYSIS_MIN_RECALL, 0.05,
help=TRADEOFF_NO_CONSTRAINT_HELP)
with d2:
min_precision = st.slider(TRADEOFF_MIN_PRECISION, 0.0, 1.0,
ANALYSIS_MIN_PRECISION, 0.05,
help=TRADEOFF_NO_CONSTRAINT_HELP)
with d3:
max_loss = st.number_input(
TRADEOFF_MAX_LOSS, min_value=0.0, max_value=1e9,
value=ANALYSIS_MAX_LOSS, step=100.0,
help=TRADEOFF_MAX_LOSS_HELP)
run = st.button(TRADEOFF_RUN_BTN, use_container_width=True, type="primary")
if run:
st.session_state.tradeoff_params = dict(
fraction=fraction, support_noise=support_noise, repeats=int(repeats),
frac_activists=frac_activists, calls_per_activist=int(calls_per_activist),
cost_per_call=cost_per_call, min_recall=min_recall,
min_precision=min_precision, max_loss=max_loss,
)
if "tradeoff_params" not in st.session_state:
return
p = st.session_state.tradeoff_params
spinner = st.empty()
spinner.markdown(
centered_spinner_html(SPINNER_RUN_TRADEOFF),
unsafe_allow_html=True,
)
agg, org, n_pop, n_sup, n_act, eff_budget = run_analysis(
p["fraction"], APP_KEEP_TOP_PARTIES, p["support_noise"], APP_ORGANISER,
p["repeats"], p["frac_activists"], p["calls_per_activist"],
p["cost_per_call"], SEED,
)
spinner.empty()
# ── constraints + feasible region + privacy-optimal Ξ΅ ───────────────
mr = p["min_recall"] if p["min_recall"] > 0 else None
mp = p["min_precision"] if p["min_precision"] > 0 else None
finite = agg[np.isfinite(agg["epsilon"])].sort_values("epsilon").reset_index(drop=True)
if p["max_loss"] > 0:
ml = p["max_loss"]
else:
fc = finite["cost_misdirected_calls"]
ml = pu._round_sig(0.5 * (float(fc.min()) + float(fc.max()))) if len(fc) else None
feas = np.ones(len(finite), dtype=bool)
if mr is not None:
feas &= finite["recall_mean"].to_numpy() >= mr
if mp is not None:
feas &= finite["precision_mean"].to_numpy() >= mp
if ml is not None:
feas &= finite["cost_misdirected_calls"].to_numpy() <= ml
xv = finite["epsilon"].to_numpy(dtype=float)
span = float(xv.max() - xv.min()) or 1.0
if feas.any():
star = int(np.flatnonzero(feas).min())
optimal_eps = float(xv[star])
feasible_left = (float(xv.min() - 0.04 * span) if star == 0
else float(0.5 * (xv[star - 1] + xv[star])))
else:
optimal_eps, feasible_left = None, None
# ── campaign-capacity summary ────────────────────────────────────────
monetary_budget = eff_budget * p["cost_per_call"]
s1, s2, s3, s4 = st.columns(4)
s1.metric(TRADEOFF_METRIC_SUPPORTERS, f"{n_sup:,}")
s2.metric(TRADEOFF_METRIC_ACTIVISTS, f"{n_act:,} Γ— {p['calls_per_activist']:,}")
s3.metric(TRADEOFF_METRIC_BUDGET, f"${monetary_budget:,.0f}")
s4.metric(TRADEOFF_METRIC_PARTY, org)
# ── Ξ΅ highlight slider ───────────────────────────────────────────────
st.markdown(TRADEOFF_LEGEND_CAPTION)
eps_opts = [float(v) for v in xv]
default_eps = optimal_eps if optimal_eps in eps_opts else eps_opts[len(eps_opts) // 2]
selected_eps = st.select_slider(
TRADEOFF_EPS_SLIDER, options=eps_opts, value=default_eps,
format_func=lambda v: f"{v:g}",
)
fig = plot_tradeoff(agg, org, p["cost_per_call"], mr, mp, ml,
optimal_eps, feasible_left, selected_eps)
st.plotly_chart(fig, use_container_width=True)
if optimal_eps is not None:
st.success(
f"πŸ’‘ ⭐**Ξ΅ ΧΧ•Χ€Χ˜Χ™ΧžΧœΧ™ ΧœΧ€Χ¨Χ˜Χ™Χ•Χͺ = {optimal_eps:g}** β€” Χ”Χ’Χ¨Χš Χ”Χ€Χ¨Χ˜Χ™ Χ‘Χ™Χ•ΧͺΧ¨ "
f"Χ©Χ’Χ“Χ™Χ™ΧŸ Χ’Χ•ΧžΧ“ Χ‘Χ›Χœ ΧΧ™ΧœΧ•Χ¦Χ™ Χ”ΧžΧ€ΧœΧ’Χ” Χ”Χ€Χ’Χ™ΧœΧ™Χ."
)
else:
st.warning(TRADEOFF_NO_FEASIBLE_WARN)
# ── read-out at the selected Ξ΅ ───────────────────────────────────────
row = finite[finite["epsilon"] == selected_eps].iloc[0]
st.markdown(f"#### גרכים Χ‘-Ξ΅ = {selected_eps:g}")
m1, m2, m3, m4 = st.columns(4)
m1.metric(TRADEOFF_METRIC_RISK, f"{row['status_disclosure_advantage']:.2f}",
help=TRADEOFF_METRIC_RISK_HELP)
m2.metric(TRADEOFF_METRIC_RECALL, f"{row['recall_mean']:.0%}",
help=TRADEOFF_METRIC_RECALL_HELP)
m3.metric(TRADEOFF_METRIC_PRECISION, f"{row['precision_mean']:.0%}",
help=TRADEOFF_METRIC_PRECISION_HELP)
m4.metric(TRADEOFF_METRIC_COST, f"${row['cost_misdirected_calls']:,.0f}")
# =============================================================================
# MAIN
# =============================================================================
def main():
inject_styles()
init_session_state()
render_top_bar(ACTIVIST_NAME.split(":")[-1])
page = render_sidebar()
if page == "dashboard": page_dashboard()
elif page == "voters": page_voter_list()
elif page == "tradeoff": page_tradeoff()
if __name__ == "__main__":
main()