File size: 11,578 Bytes
3c0e2ec
 
 
 
 
 
ecf207c
 
 
 
363723c
ecf207c
 
 
3c0e2ec
 
 
ac4e07f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363723c
 
 
 
 
 
ecf207c
 
 
 
 
 
 
 
 
 
 
 
3c0e2ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177b9af
ecf207c
 
 
 
 
 
 
 
 
 
177b9af
 
ecf207c
 
 
363723c
ecf207c
177b9af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5317eb
 
177b9af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5317eb
 
177b9af
363723c
82c668e
363723c
e5317eb
363723c
ecf207c
82c668e
ecf207c
 
363723c
ecf207c
177b9af
82c668e
 
 
 
177b9af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c0e2ec
 
 
ac4e07f
3c0e2ec
 
 
 
 
82c668e
3c0e2ec
177b9af
 
 
 
 
 
 
 
 
3c0e2ec
 
 
 
ecf207c
3c0e2ec
ecf207c
ac4e07f
 
 
ecf207c
3c0e2ec
e5317eb
 
 
 
 
3c0e2ec
 
ac4e07f
 
 
ecf207c
ac4e07f
3c0e2ec
 
 
 
 
ecf207c
3c0e2ec
 
 
ac4e07f
ecf207c
 
 
 
 
 
ac4e07f
3c0e2ec
 
 
 
ac4e07f
3c0e2ec
ecf207c
 
 
 
 
 
 
82c668e
363723c
 
ecf207c
 
3c0e2ec
 
 
 
 
 
 
177b9af
 
3c0e2ec
ecf207c
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
from __future__ import annotations

import pandas as pd
import plotly.express as px
import streamlit as st

from src.assumptions import (
    BASELINE,
    BASELINE_REFERENCE_OPTIONS,
    DATA_QUALITY_NOTES,
    INCOME_THRESHOLD_OPTIONS_UAH,
    SALARY_ANCHORS_UAH,
    SOURCE_LINKS,
)
from src.model_pool import Criteria, estimate_pool, sensitivity_table


def format_count(value: int | float) -> str:
    return f"{value:,.0f}"


def parse_count(value: str, fallback: int) -> int:
    cleaned = value.replace(",", "").replace(" ", "").strip()
    if not cleaned:
        return fallback
    return int(cleaned)


def title_label(value: str) -> str:
    return value.replace("_", " ").title()


def income_threshold_label(value: int) -> str:
    if value == 0:
        return "Any income"
    return f"{format_count(value)} UAH"


def format_percent(value: int | float) -> str:
    if value == 0:
        return "0%"
    if value < 0.001:
        return "<0.001%"
    if value < 0.01:
        return f"{value:.4f}%"
    if value < 1:
        return f"{value:.3f}%"
    return f"{value:.2f}%"


st.set_page_config(
    page_title="Partner Pool Assumption Simulator",
    page_icon="S7",
    layout="wide",
)

st.title("Partner Pool Assumption Simulator")
st.caption("S7-K · Personal Relationship · transparent demo model")

st.info(
    "Prototype status: current numbers are demo assumptions. Use this app to test model logic, "
    "not to claim a factual count of available partners."
)

with st.sidebar:
    st.header("Scenario")
    with st.expander("Core demographics", expanded=True):
        baseline_preset = st.selectbox(
            "Baseline preset",
            list(BASELINE_REFERENCE_OPTIONS),
            format_func=lambda value: BASELINE_REFERENCE_OPTIONS[value]["label"],
            help=(
                "Baseline is the starting universe before filters. It is not automatically the whole country; "
                "choose a national reference or a narrower custom pool depending on the scenario."
            ),
        )
        preset = BASELINE_REFERENCE_OPTIONS[baseline_preset]
        base_population_text = st.text_input(
            "Baseline population",
            value=format_count(preset["value"]),
            help=(
                f"{preset['note']} Formatted with commas. "
                "Demo is a fixed synthetic example. Custom means you choose your own starting audience."
            ),
        )
        try:
            base_population = parse_count(base_population_text, BASELINE.total_reference_population)
        except ValueError:
            st.warning("Use digits with optional commas, for example 10,000,000.")
            base_population = BASELINE.total_reference_population
        if not 10_000 <= base_population <= 50_000_000:
            st.warning("Baseline population should stay between 10,000 and 50,000,000 for this demo.")
            base_population = max(10_000, min(base_population, 50_000_000))

        target_population = st.selectbox(
            "Target population",
            ["all_adults", "women", "men"],
            format_func=title_label,
            help="Applies a demo sex-share coefficient before the other filters. Women: 53%, Men: 47%, All adults: 100%.",
        )
        age_min, age_max = st.slider(
            "Age range",
            18,
            70,
            (18, 70),
            help="Narrows the pool by the selected age-band overlap. The full 18-70 range is treated as no age filter.",
        )
        region_scope = st.selectbox(
            "Region scope",
            ["all_ukraine", "large_cities", "kyiv_region", "western_regions"],
            format_func=title_label,
            help="Applies the selected regional scope coefficient.",
        )
        relationship_status = st.selectbox(
            "Relationship status",
            ["any", "not_married", "single_or_divorced"],
            format_func=title_label,
            help="Demo availability proxy. Official marital status is not the same as real availability.",
        )
        min_height = st.slider(
            "Minimum height, cm",
            150,
            205,
            150,
            help="Interpolates a demo height-distribution coefficient. 150 cm is treated as no height filter.",
        )
        income_min_uah = st.select_slider(
            "Minimum monthly income, UAH",
            options=INCOME_THRESHOLD_OPTIONS_UAH,
            value=0,
            format_func=income_threshold_label,
            help=(
                "Scenario salary threshold. 0 means no income filter. Salary anchors: Work.ua current benchmark is about "
                f"{format_count(SALARY_ANCHORS_UAH['workua_current_average'])} UAH/month; "
                f"KSE cites Work.ua January 2026 median at {format_count(SALARY_ANCHORS_UAH['kse_workua_jan_2026_median'])} UAH/month. "
                "High thresholds up to 1,000,000 UAH/month are scenario stress-test cutoffs, not official maximum salary data."
            ),
        )
        st.caption(
            "Selected income threshold: "
            f"{'Any income' if income_min_uah == 0 else format_count(income_min_uah) + ' UAH/month'}"
        )
        education_level = st.selectbox(
            "Education filter",
            ["any", "higher_education", "graduate_plus"],
            format_func=title_label,
            help="Applies an estimated education-level coefficient.",
        )

    with st.expander("Family context"):
        children_status = st.selectbox(
            "Children status",
            ["any", "no_children", "has_children", "co_parenting_ready"],
            format_func=title_label,
            help="Scenario preference around existing children. These are demo assumptions, not value judgments.",
        )
        future_children = st.selectbox(
            "Future children",
            ["any", "wants_children", "does_not_want_children", "open_or_undecided"],
            format_func=title_label,
            help="Scenario preference around future children.",
        )

    with st.expander("War and mobility"):
        military_status = st.selectbox(
            "Military status",
            ["any", "civilian_or_not_serving", "active_service", "veteran_or_service_history"],
            format_func=title_label,
            help="War-related scenario filter. Active service and veteran/service-history shares are placeholders until sourced.",
        )
        relocation = st.selectbox(
            "Relocation",
            ["any", "same_city_only", "open_to_relocation", "remote_or_long_distance_ok"],
            format_func=title_label,
            help="Mobility and distance preference filter.",
        )

    with st.expander("Lifestyle and compatibility"):
        housing = st.selectbox(
            "Housing",
            ["any", "independent_living", "own_or_stable_housing"],
            format_func=title_label,
            help="Scenario proxy for independent or stable living setup.",
        )
        smoking = st.selectbox(
            "Smoking",
            ["any", "non_smoker", "ok_with_smoking"],
            format_func=title_label,
            help="Lifestyle preference around smoking.",
        )
        alcohol = st.selectbox(
            "Alcohol",
            ["any", "rare_or_none", "moderate_ok"],
            format_func=title_label,
            help="Lifestyle preference around alcohol use.",
        )
        language = st.selectbox(
            "Language comfort",
            ["any", "ukrainian_comfortable", "english_comfortable", "ukrainian_and_english"],
            format_func=title_label,
            help="Communication comfort filter.",
        )
        pets = st.selectbox(
            "Pets",
            ["any", "pet_friendly", "no_pets_preferred"],
            format_func=title_label,
            help="Household compatibility preference around pets.",
        )

criteria = Criteria(
    base_population=base_population,
    target_population=target_population,
    age_min=age_min,
    age_max=age_max,
    region_scope=region_scope,
    relationship_status=relationship_status,
    min_height_cm=min_height,
    income_min_uah=income_min_uah,
    education_level=education_level,
    children_status=children_status,
    future_children=future_children,
    military_status=military_status,
    relocation=relocation,
    housing=housing,
    smoking=smoking,
    alcohol=alcohol,
    language=language,
    pets=pets,
)

estimate = estimate_pool(criteria)
steps = sensitivity_table(criteria)
central_percent = (estimate.central / criteria.base_population) * 100

col_a, col_b, col_c, col_d = st.columns(4)
col_a.metric("Conservative estimate", format_count(estimate.conservative))
col_b.metric("Central estimate", format_count(estimate.central))
col_c.metric("Optimistic estimate", format_count(estimate.optimistic))
col_d.metric("Central share", format_percent(central_percent))

if central_percent == 100:
    st.caption("Neutral defaults are active: the central estimate equals 100% of the selected baseline.")
else:
    st.caption("Central share is the central estimate divided by the selected baseline after all active filters.")

st.subheader("What narrows the pool")
step_df = pd.DataFrame(steps)
display_df = step_df.assign(
    coefficient=step_df["coefficient"].map("{:.4f}".format),
    remaining=step_df["remaining"].map(format_count),
    percent_of_baseline=step_df["percent_of_baseline"].map(format_percent),
)
fig = px.bar(
    step_df,
    x="factor",
    y="remaining",
    text="remaining",
    custom_data=["coefficient", "percent_of_baseline"],
    title="Remaining estimated pool after each criterion",
)
fig.update_traces(texttemplate="%{text:,.0f}", textposition="outside")
fig.update_traces(
    hovertemplate=(
        "<b>%{x}</b><br>"
        "Remaining: %{y:,.0f}<br>"
        "Share of baseline: %{customdata[1]:.4f}%<br>"
        "Coefficient: %{customdata[0]:.4f}<extra></extra>"
    )
)
fig.update_layout(yaxis_title="Estimated remaining pool", xaxis_title="")
st.plotly_chart(fig, use_container_width=True)

st.subheader("Scenario details")
st.dataframe(display_df, use_container_width=True, hide_index=True)

st.subheader("Baseline and salary anchors")
st.write(
    "Baseline population is the starting reference pool before filters. The default 10,000,000 is a demo working pool, "
    "not the population of Ukraine. For a national pre-invasion reference, use the SSSU January 2022 option "
    f"({format_count(BASELINE_REFERENCE_OPTIONS['sssu_jan_2022_total']['value'])})."
)
st.write(
    f"The income slider uses {format_count(SALARY_ANCHORS_UAH['workua_current_average'])} UAH/month as the current public job-market benchmark. "
    "High values such as 200,000, 500,000, or 1,000,000 UAH/month are supported as scenario stress-test cutoffs. "
    "They are not official salary percentiles or a claimed real maximum."
)

st.subheader("Data quality notes")
for note in DATA_QUALITY_NOTES:
    st.write(f"- **{note['label']}**: {note['note']}")

st.subheader("Interpretation guardrails")
st.write(
    "This model estimates a demographic scenario, not compatibility, attraction, safety, or relationship success. "
    "A stricter filter can make a pool smaller, but it does not define a person's real-life chances. "
    "War, children, housing, and lifestyle filters are sensitive context variables; treat them as transparent assumptions."
)

st.subheader("Sources")
for source in SOURCE_LINKS:
    st.markdown(f"- [{source['label']}]({source['url']}) — {source['note']}")