Skip to content

Commit 0bf3be4

Browse files
feat: add Interactive Multi-Bundle Evaluation Matrix
- Creates a fourth tab "Multi-Bundle Matrix" in the Streamlit app. - Supports multi-selection of constraint bundles to evaluate simultaneously. - Dynamically derives candidate inputs from the union of all active parameters across selected bundles globally on all tabs. - Renders Layer 1 (Summary Table) and Layer 2 (styled PASS/FAIL/INDET/— Constraint Grid). - Detects and triggers dynamic cross-therapeutic profile warnings on critical contradictions. - Provides separate CSV downloads for both summary and grid, and a full JSON export. - Includes comprehensive backend tests in `tests/test_multi_bundle.py`. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 5fb2059 commit 0bf3be4

3 files changed

Lines changed: 441 additions & 9 deletions

File tree

apps/console_streamlit/app.py

Lines changed: 303 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,21 @@ def modifier_fn(c):
249249
st.header("⚙️ Configuration")
250250

251251
# Bundle selection
252-
bundle_name = st.selectbox(
253-
"Constraint Bundle",
252+
selected_bundles = st.multiselect(
253+
"Constraint Bundles",
254254
list(BUNDLES.keys()),
255-
help="Select a predefined constraint set"
255+
default=list(BUNDLES.keys()),
256+
help="Select constraint bundles to include in multi-bundle analysis"
257+
)
258+
259+
if not selected_bundles:
260+
st.warning("Please select at least one constraint bundle.")
261+
st.stop()
262+
263+
bundle_name = st.selectbox(
264+
"Active Bundle for Single-Evaluation/Sweep",
265+
selected_bundles,
266+
help="Choose one of the selected bundles to focus on for Candidate Evaluation and Parameter Sweep"
256267
)
257268

258269
# Show bundle description
@@ -381,8 +392,11 @@ def modifier_fn(c):
381392
st.caption("Enter candidate properties below. Fields are derived from the selected constraint bundle.")
382393

383394
# Dynamic Form Generation
384-
constraints = bundle_info["fn"]()
385-
property_names = sorted(list(set(c.name for c in constraints)))
395+
all_selected_constraints = []
396+
for b_name in selected_bundles:
397+
all_selected_constraints.extend(BUNDLES[b_name]["fn"]())
398+
399+
property_names = sorted(list(set(c.name for c in all_selected_constraints)))
386400

387401
form_properties = {}
388402

@@ -413,10 +427,11 @@ def modifier_fn(c):
413427

414428
st.markdown("---")
415429

416-
tab_eval, tab_sweep, tab_custom = st.tabs([
430+
tab_eval, tab_sweep, tab_custom, tab_matrix = st.tabs([
417431
"🔍 Candidate Evaluation",
418432
"📈 Parameter Sweep & Boundary Mapping",
419-
"👥 Custom Population Profiles"
433+
"👥 Custom Population Profiles",
434+
"📊 Multi-Bundle Matrix"
420435
])
421436

422437
with tab_eval:
@@ -1305,6 +1320,287 @@ def _render_tree_node(node: Dict[str, Any], level: int = 0):
13051320
st.markdown(f"**Test Result:** `{test_result.status.value.upper()}`")
13061321
st.code(test_result.summary(), language="text")
13071322

1323+
1324+
# -----------------------------
1325+
# Tab: Multi-Bundle Matrix
1326+
# -----------------------------
1327+
1328+
with tab_matrix:
1329+
st.header("📊 Multi-Bundle Evaluation Matrix")
1330+
st.caption("Evaluate a single candidate against all (or multiple selected) constraint bundles simultaneously.")
1331+
1332+
col_btn, col_info = st.columns([1, 3])
1333+
with col_btn:
1334+
generate_matrix_button = st.button(
1335+
"📊 Generate Multi-Bundle Matrix",
1336+
type="primary",
1337+
use_container_width=True,
1338+
key="generate_matrix_btn"
1339+
)
1340+
with col_info:
1341+
st.caption(
1342+
"Runs evaluations across all selected bundles using the candidate properties defined above."
1343+
)
1344+
1345+
# We want to display the last generated results if they are in session state
1346+
if generate_matrix_button:
1347+
try:
1348+
# Parse candidate JSON
1349+
raw = json.loads(candidate_text)
1350+
cand = Candidate(
1351+
name=raw.get("name", "unnamed"),
1352+
properties=raw.get("properties", {}),
1353+
provenance=raw.get("provenance")
1354+
)
1355+
1356+
matrix_results = {}
1357+
for b_name in selected_bundles:
1358+
constraints = BUNDLES[b_name]["fn"]()
1359+
cura = CuraFrame(constraints, name=f"CuraFrame::{b_name}")
1360+
1361+
# Register population modifiers
1362+
if use_population and population:
1363+
if population in POPULATION_MODIFIERS:
1364+
pop_mods = {
1365+
k: v for k, v in POPULATION_MODIFIERS[population].items()
1366+
if k != "description"
1367+
}
1368+
cura.add_population(population, pop_mods)
1369+
else:
1370+
custom_pops = db_auth.get_custom_populations(st.session_state['user'])
1371+
selected_custom_pop = next((p for p in custom_pops if p["name"] == population), None)
1372+
if selected_custom_pop:
1373+
pop_mods = {}
1374+
for mod in selected_custom_pop["modifiers"]:
1375+
param = mod["parameter"]
1376+
op = mod["operator"]
1377+
val = mod["value"]
1378+
pop_mods[param] = make_custom_modifier(op, val)
1379+
if param == "clearance":
1380+
pop_mods["hepatic_clearance"] = make_custom_modifier(op, val)
1381+
elif param == "hepatic_clearance":
1382+
pop_mods["clearance"] = make_custom_modifier(op, val)
1383+
cura.add_population(population, pop_mods)
1384+
1385+
# Evaluate
1386+
pop_arg = population if use_population else None
1387+
result = cura.evaluate(cand, population=pop_arg, strict=strict)
1388+
matrix_results[b_name] = {
1389+
"result": result,
1390+
"cura": cura
1391+
}
1392+
1393+
# Save to session state
1394+
st.session_state['last_matrix_results'] = matrix_results
1395+
st.session_state['last_matrix_candidate'] = cand
1396+
st.session_state['last_matrix_bundles'] = selected_bundles
1397+
st.session_state['last_matrix_pop'] = population if use_population else None
1398+
st.session_state['last_matrix_strict'] = strict
1399+
1400+
except Exception as e:
1401+
st.error(f"❌ **Multi-bundle evaluation failed:** {e}")
1402+
st.exception(e)
1403+
1404+
if 'last_matrix_results' in st.session_state:
1405+
matrix_results = st.session_state['last_matrix_results']
1406+
cand = st.session_state['last_matrix_candidate']
1407+
active_bundles = st.session_state['last_matrix_bundles']
1408+
pop_arg = st.session_state['last_matrix_pop']
1409+
strict_val = st.session_state['last_matrix_strict']
1410+
1411+
import pandas as pd
1412+
1413+
# -----------------------------
1414+
# Layer 1: Summary Table
1415+
# -----------------------------
1416+
st.markdown("---")
1417+
st.subheader("📋 Layer 1: Summary Table")
1418+
1419+
summary_data = []
1420+
for b_name in active_bundles:
1421+
if b_name not in matrix_results:
1422+
continue
1423+
b_info = matrix_results[b_name]
1424+
res = b_info["result"]
1425+
1426+
if res.status == EvaluationStatus.ACCEPTED:
1427+
emoji_status = "🟢 ACCEPTED"
1428+
elif res.status == EvaluationStatus.REJECTED:
1429+
emoji_status = "🔴 REJECTED"
1430+
else:
1431+
emoji_status = "🟡 INDET"
1432+
1433+
violated_params = ", ".join(sorted(list(set(v.constraint for v in res.violations)))) if res.violations else "None"
1434+
summary_data.append({
1435+
"Bundle Name": b_name,
1436+
"Overall Status": emoji_status,
1437+
"Violations Count": len(res.violations),
1438+
"Violated Parameters": violated_params
1439+
})
1440+
1441+
df_summary = pd.DataFrame(summary_data)
1442+
st.dataframe(df_summary, use_container_width=True, hide_index=True)
1443+
1444+
# -----------------------------
1445+
# Layer 2: Constraint Grid
1446+
# -----------------------------
1447+
st.markdown("---")
1448+
st.subheader("🎯 Layer 2: Constraint Grid")
1449+
1450+
# Get dynamic union of parameters
1451+
all_constraints = []
1452+
for b_name in active_bundles:
1453+
all_constraints.extend(BUNDLES[b_name]["fn"]())
1454+
unique_params = sorted(list(set(c.name for c in all_constraints)))
1455+
1456+
grid_data = []
1457+
for prop in unique_params:
1458+
row = {"Parameter": prop}
1459+
for b_name in active_bundles:
1460+
if b_name not in matrix_results:
1461+
row[b_name] = "⚪ —"
1462+
continue
1463+
b_info = matrix_results[b_name]
1464+
cura = b_info["cura"]
1465+
res = b_info["result"]
1466+
1467+
c_obj = cura.get_constraint(prop)
1468+
if c_obj is None:
1469+
row[b_name] = "⚪ —"
1470+
else:
1471+
if cand.get(prop) is None:
1472+
row[b_name] = "🟡 INDET"
1473+
else:
1474+
is_violated = any(v.constraint == prop for v in res.violations)
1475+
if is_violated:
1476+
row[b_name] = "🔴 FAIL"
1477+
else:
1478+
row[b_name] = "🟢 PASS"
1479+
grid_data.append(row)
1480+
1481+
df_grid = pd.DataFrame(grid_data)
1482+
1483+
def style_cells(val):
1484+
if val == "🟢 PASS":
1485+
return "background-color: #d4edda; color: #155724; font-weight: bold;"
1486+
elif val == "🔴 FAIL":
1487+
return "background-color: #f8d7da; color: #721c24; font-weight: bold;"
1488+
elif val == "🟡 INDET":
1489+
return "background-color: #fff3cd; color: #856404; font-weight: bold;"
1490+
elif val == "⚪ —":
1491+
return "background-color: #e2e3e5; color: #383d41;"
1492+
return ""
1493+
1494+
if hasattr(df_grid.style, "map"):
1495+
styled_grid = df_grid.style.map(style_cells, subset=active_bundles)
1496+
else:
1497+
styled_grid = df_grid.style.applymap(style_cells, subset=active_bundles)
1498+
1499+
st.dataframe(styled_grid, use_container_width=True, hide_index=True)
1500+
1501+
# -----------------------------
1502+
# Cross-Therapeutic Profile Warnings
1503+
# -----------------------------
1504+
st.markdown("---")
1505+
st.subheader("⚠️ Cross-Therapeutic Profile Warnings")
1506+
1507+
cross_warnings = []
1508+
accepted_list = []
1509+
rejected_list = []
1510+
1511+
for b_name in active_bundles:
1512+
if b_name not in matrix_results:
1513+
continue
1514+
res = matrix_results[b_name]["result"]
1515+
if res.status == EvaluationStatus.ACCEPTED:
1516+
accepted_list.append(b_name)
1517+
elif res.status == EvaluationStatus.REJECTED:
1518+
rejected_list.append(b_name)
1519+
1520+
for acc in accepted_list:
1521+
for rej in rejected_list:
1522+
rej_info = matrix_results[rej]
1523+
crit_violations = [v for v in rej_info["result"].violations if v.severity == Severity.CRITICAL]
1524+
for cv in crit_violations:
1525+
title = "Cross-Therapeutic Warning"
1526+
if "Cardiol" in rej:
1527+
title = "Cardiac Risk Warning"
1528+
elif "CNS" in rej:
1529+
title = "CNS Safety Warning"
1530+
elif "Safety" in rej:
1531+
title = "Core Safety Warning"
1532+
1533+
warning_text = (
1534+
f"**{title}:** Candidate meets **{acc}** criteria but fails **{rej}** constraints "
1535+
f"due to a CRITICAL violation: **{cv.constraint}** (observed: {cv.observed}, required: {cv.threshold}). "
1536+
f"\n\n*Rationale:* {cv.rationale}"
1537+
)
1538+
cross_warnings.append(warning_text)
1539+
1540+
if cross_warnings:
1541+
for cw in cross_warnings:
1542+
st.warning(cw)
1543+
else:
1544+
st.success("No cross-therapeutic profile warnings detected. Candidate profile is consistent across all evaluated domains.")
1545+
1546+
# -----------------------------
1547+
# Export Capabilities
1548+
# -----------------------------
1549+
st.markdown("---")
1550+
st.subheader("💾 Export Options")
1551+
1552+
col_exp1, col_exp2, col_exp3 = st.columns(3)
1553+
1554+
with col_exp1:
1555+
# JSON Export
1556+
export_json_data = {
1557+
"candidate": {
1558+
"name": cand.name,
1559+
"properties": cand.properties,
1560+
"provenance": cand.provenance
1561+
},
1562+
"configuration": {
1563+
"population": pop_arg,
1564+
"strict": strict_val,
1565+
"evaluated_bundles": active_bundles
1566+
},
1567+
"matrix_summary": summary_data,
1568+
"grid": grid_data,
1569+
"cross_warnings": cross_warnings
1570+
}
1571+
st.download_button(
1572+
"⬇️ Download Matrix Results (JSON)",
1573+
data=json.dumps(export_json_data, indent=2),
1574+
file_name=f"curaframe_matrix_{cand.name}.json",
1575+
mime="application/json",
1576+
use_container_width=True,
1577+
key="download_json_matrix_btn"
1578+
)
1579+
1580+
with col_exp2:
1581+
# Summary Table CSV Export
1582+
csv_summary = df_summary.to_csv(index=False)
1583+
st.download_button(
1584+
"⬇️ Download Summary Table (CSV)",
1585+
data=csv_summary,
1586+
file_name=f"curaframe_matrix_summary_{cand.name}.csv",
1587+
mime="text/csv",
1588+
use_container_width=True,
1589+
key="download_csv_summary_btn"
1590+
)
1591+
1592+
with col_exp3:
1593+
# Constraint Grid CSV Export
1594+
csv_grid = df_grid.to_csv(index=False)
1595+
st.download_button(
1596+
"⬇️ Download Constraint Grid (CSV)",
1597+
data=csv_grid,
1598+
file_name=f"curaframe_matrix_grid_{cand.name}.csv",
1599+
mime="text/csv",
1600+
use_container_width=True,
1601+
key="download_csv_grid_btn"
1602+
)
1603+
13081604
# Credits
13091605
st.markdown("---")
13101606
st.caption(

streamlit_run.log

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11

22
Collecting usage statistics. To deactivate, set browser.gatherUsageStats to false.
33

4-
2026-07-16 12:38:14.313 Uvicorn server started on 127.0.0.1:8501
4+
2026-07-16 13:26:27.403 Uvicorn server started on 127.0.0.1:8501
55

66
You can now view your Streamlit app in your browser.
77

88
URL: http://127.0.0.1:8501
99

10-
Stopping...
10+
2026-07-16 13:28:48.756 Please replace `use_container_width` with `width`.
11+
12+
`use_container_width` will be removed after 2025-12-31.
13+
14+
For `use_container_width=True`, use `width='stretch'`. For `use_container_width=False`, use `width='content'`.
15+
2026-07-16 13:28:48.857 Please replace `use_container_width` with `width`.
16+
17+
`use_container_width` will be removed after 2025-12-31.
18+
19+
For `use_container_width=True`, use `width='stretch'`. For `use_container_width=False`, use `width='content'`.

0 commit comments

Comments
 (0)