cdotsanghvi's picture
add multi-head demo as 4th-6th tabs; restore Why Liquid + Integration
083b138
Raw
History Blame Contribute Delete
32.7 kB
"""Interactive inference demo for the LFM2 Transaction Foundation Model.
Demonstrates multi-head predictions (fraud, next merchant, amount range, MCC)
on synthetic payment sequences. Includes side-by-side pretrained-vs-random-init
comparison showing the value of self-supervised pretraining.
Usage:
python -m src.demo.app --checkpoint PATH [--data-dir PATH] [--port PORT]
Runs on CPU. No GPU or internet required. Inference < 100ms per customer.
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
from typing import Any
import gradio as gr
import numpy as np
import torch
import torch.nn.functional as F
from src.data.schema import SchemaConfig, load_schema
from src.demo.decode import TransactionDecoder
from src.demo.merchant_catalog import DemoMerchantCatalog
from src.demo.profile_inference import format_profile_html, infer_profile
from src.demo.render import (
format_amount_predictions,
format_fraud_score,
format_mcc_predictions,
format_merchant_predictions,
format_timeline,
render_comparison_header,
render_integration_guide,
render_production_architecture,
render_why_liquid,
)
from src.model.lfm2_small import LFM2Small, ModelConfig
from src.model.task_heads import (
AnyHead,
DownstreamHead,
HeadConfig,
MultiHeadModel,
TiedEmbeddingHead,
)
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
class DemoData:
"""Loads test-set sequences and labels into memory."""
def __init__(self, data_dir: Path, schema: SchemaConfig) -> None:
token_ids = np.load(data_dir / "token_ids.npy", mmap_mode="r")
seq_labels = np.load(data_dir / "sequence_labels.npy")
splits = np.load(data_dir / "split_indices.npz")
test_idx = splits["test"]
self.token_ids = np.array(token_ids[test_idx])
self.labels = seq_labels[test_idx].astype(int)
self.num_customers = len(test_idx)
self.fraud_indices = np.where(self.labels == 1)[0]
self.legit_indices = np.where(self.labels == 0)[0]
self._curated = self._find_curated_examples()
def _find_curated_examples(self) -> dict[str, int]:
"""Pick interesting examples for quick navigation.
Five legitimate profiles to show breadth of normal spending patterns,
three fraud profiles representing the main attack archetypes.
"""
examples: dict[str, int] = {}
# Legitimate customers — varied indices for behavioral variety
if len(self.legit_indices) > 0:
examples["Typical Customer"] = int(self.legit_indices[0])
if len(self.legit_indices) > 100:
examples["Frequent Shopper"] = int(self.legit_indices[100])
if len(self.legit_indices) > 200:
examples["Weekend Spender"] = int(self.legit_indices[200])
if len(self.legit_indices) > 300:
examples["High-Spend Loyalist"] = int(self.legit_indices[300])
if len(self.legit_indices) > 500:
examples["Low Activity"] = int(self.legit_indices[500])
# Fraud archetypes
if len(self.fraud_indices) > 0:
examples["Fraud: Card Testing"] = int(self.fraud_indices[0])
if len(self.fraud_indices) > 50:
examples["Fraud: Account Takeover"] = int(self.fraud_indices[50])
if len(self.fraud_indices) > 100:
examples["Fraud: High Value"] = int(self.fraud_indices[100])
return examples
@property
def curated_names(self) -> list[str]:
return list(self._curated.keys())
def get_curated_index(self, name: str) -> int:
return self._curated[name]
# ---------------------------------------------------------------------------
# Model loading
# ---------------------------------------------------------------------------
def build_model(
model_yaml: Path,
schema: SchemaConfig,
finetune_yaml: Path,
) -> MultiHeadModel:
"""Construct MultiHeadModel with 4 downstream heads."""
import yaml
backbone = LFM2Small(ModelConfig.from_yaml(model_yaml), schema)
with open(finetune_yaml) as f:
ft_config = yaml.safe_load(f)
heads: dict[str, AnyHead] = {}
for name, hcfg in ft_config["heads"].items():
config = HeadConfig(
name=name,
output_dim=hcfg["output_dim"],
loss_type=hcfg["loss"],
pool_strategy=hcfg["pool"],
target_type=hcfg["target"],
weight=hcfg.get("weight", 1.0),
mlp_hidden=hcfg.get("mlp_hidden", 128),
dropout=hcfg.get("dropout", 0.1),
)
if hcfg.get("tied", False):
heads[name] = TiedEmbeddingHead(
config, backbone.config.hidden_size, schema.num_features,
backbone.embedding.value_tables,
)
else:
heads[name] = DownstreamHead(
config, backbone.config.hidden_size, schema.num_features,
)
return MultiHeadModel(backbone, heads)
def load_model_checkpoint(model: MultiHeadModel, checkpoint_path: Path | None) -> str:
"""Load fine-tuned weights. Returns status message."""
if checkpoint_path is None:
return "No checkpoint loaded"
if not checkpoint_path.exists():
raise FileNotFoundError(
f"Checkpoint not found: {checkpoint_path}. "
f"Run with --checkpoint PATH or --checkpoint none to skip."
)
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model_state_dict"], strict=True)
step = ckpt.get("step", "?")
return f"step {step}"
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
@torch.no_grad()
def run_inference(
model: MultiHeadModel,
token_ids: np.ndarray,
) -> dict[str, np.ndarray]:
"""Run all 4 heads on a single customer sequence."""
tensor = torch.from_numpy(token_ids).unsqueeze(0).long()
predictions = model(tensor)
results: dict[str, np.ndarray] = {}
for name, logits in predictions.items():
if name == "fraud":
prob = torch.sigmoid(logits).squeeze().numpy()
results[name] = np.array([float(prob)])
else:
probs = F.softmax(logits, dim=-1).squeeze(0).numpy()
results[name] = probs
return results
# ---------------------------------------------------------------------------
# App builder
# ---------------------------------------------------------------------------
def create_app(
pretrained_model: MultiHeadModel,
random_model: MultiHeadModel,
data: DemoData,
decoder: TransactionDecoder,
merchant_catalog: DemoMerchantCatalog,
checkpoint_status: str,
) -> gr.Blocks:
"""Build the Gradio app with pretrained vs random-init comparison."""
pretrained_model.eval()
random_model.eval()
def on_customer_select(
curated_name: str | None,
customer_idx: int,
mode: str,
) -> tuple[str, str, str, str, str, str, str, str, str, str, str]:
"""Run both models, return all outputs for comparison."""
if mode == "Curated examples" and curated_name:
idx = data.get_curated_index(curated_name)
else:
idx = int(customer_idx)
idx = max(0, min(idx, data.num_customers - 1))
token_ids = data.token_ids[idx]
is_fraud = bool(data.labels[idx])
summary = decoder.summarize_customer(token_ids, is_fraud)
t0 = time.perf_counter()
pre_results = run_inference(pretrained_model, token_ids)
rand_results = run_inference(random_model, token_ids)
latency_ms = (time.perf_counter() - t0) * 1000
timeline_html = format_timeline(decoder, token_ids)
# Pretrained predictions
pre_fraud = format_fraud_score(float(pre_results["fraud"][0]))
pre_merchant = format_merchant_predictions(
pre_results["next_merchant"], merchant_catalog, k=5,
)
pre_amount = format_amount_predictions(pre_results["amount_range"], k=5)
pre_mcc = format_mcc_predictions(pre_results["mcc"], k=5)
# Random-init predictions
rand_fraud = format_fraud_score(float(rand_results["fraud"][0]))
rand_merchant = format_merchant_predictions(
rand_results["next_merchant"], merchant_catalog, k=5,
)
rand_amount = format_amount_predictions(rand_results["amount_range"], k=5)
rand_mcc = format_mcc_predictions(rand_results["mcc"], k=5)
# Behavioral profile
profile_match = infer_profile(token_ids)
profile_html = format_profile_html(profile_match)
# Combined comparison HTML for each head
fraud_compare = _side_by_side("Fraud Score", pre_fraud, rand_fraud)
merchant_compare = _side_by_side("Next Merchant", pre_merchant, rand_merchant)
amount_compare = _side_by_side("Amount Range", pre_amount, rand_amount)
mcc_compare = _side_by_side("Merchant Category", pre_mcc, rand_mcc)
latency_str = (
f"Inference: {latency_ms:.1f}ms (both models) on CPU | "
f"Customer #{idx} | Ground truth: {'FRAUD' if is_fraud else 'Legitimate'}"
)
return (
summary, timeline_html, profile_html,
fraud_compare, merchant_compare, amount_compare, mcc_compare,
latency_str,
)
with gr.Blocks(
title="LFM2 Transaction Foundation Model",
) as app:
gr.HTML("""
<div style="text-align: center; margin-bottom: 16px;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #171717;
letter-spacing: -0.02em;">
LFM2 Transaction Foundation Model
</h1>
<p style="color: #737373; margin: 6px 0 0 0; font-size: 13px;
font-family: JetBrains Mono, ui-monospace, monospace;">
Liquid AI &middot; LFM2.5 Architecture &middot; Multi-Head Inference Demo
</p>
</div>
""")
with gr.Tabs():
# ===== Tab 1: Interactive Predictions =====
with gr.Tab("Predictions"):
gr.HTML("""
<div style="padding: 10px 14px; background: #ffffff; border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px; margin: 8px 0; font-size: 12px; color: #525252;">
<b style="color: #171717;">How to read this:</b> Select a customer, see their
transaction history, then compare predictions from the pretrained model (left)
vs random initialization (right). Same architecture, same fine-tuning data.
The only difference is self-supervised pretraining on unlabeled sequences.
</div>
""")
with gr.Accordion("Reference model details", open=False):
gr.HTML(f"""
<div style="padding: 8px 12px; font-family: JetBrains Mono, ui-monospace, monospace;
font-size: 11px; color: #525252; display: flex; gap: 20px; flex-wrap: wrap;">
<span>arch: <b style="color: #171717;">LFM2-small</b> 9.8M params</span>
<span>layers: <b style="color: #10B981;">5 conv</b> + <b style="color: #7c3aed;">3 attn</b></span>
<span>input: 64 tx &times; 15 feat = 960 tokens</span>
<span>checkpoint: <b style="color: #171717;">{checkpoint_status}</b></span>
<span>data: 200K synthetic sequences, 15 features/tx</span>
</div>
""")
# Customer Selection. Use Radio (not Checkbox) for the
# curated-vs-browse toggle: the two modes are mutually exclusive
# and Radio's visual state is more reliable across Gradio versions.
_CURATED_MODE = "Curated examples"
_BROWSE_MODE = "Browse all customers"
with gr.Row():
with gr.Column(scale=1):
gr.HTML("<h3 style='margin: 0 0 8px 0;'>Select Customer</h3>")
selection_mode = gr.Radio(
choices=[_CURATED_MODE, _BROWSE_MODE],
value=_CURATED_MODE,
label="Selection mode",
info="Curated: hand-picked legitimate and fraud examples. "
"Browse: pick any of 20,000 test customers by index.",
elem_classes="liquid-radio",
)
curated_dropdown = gr.Dropdown(
choices=data.curated_names,
value=data.curated_names[0] if data.curated_names else None,
label="Curated Examples",
info="5 legitimate profiles, 3 fraud archetypes",
)
customer_slider = gr.Slider(
minimum=0,
maximum=data.num_customers - 1,
step=1,
value=0,
label=f"Customer Index (0-{data.num_customers - 1})",
info=f"Direct access to any of {data.num_customers:,} test-set customers. "
f"~3.7% are fraud, rest are legitimate.",
visible=False,
)
run_btn = gr.Button(
"Run Inference", variant="primary", size="lg",
elem_id="run-inference-btn",
)
with gr.Column(scale=2):
summary_text = gr.Textbox(
label="Customer Profile", interactive=False, lines=2,
)
profile_output = gr.HTML(label="Behavioral Profile")
latency_text = gr.Textbox(
label="Performance", interactive=False, lines=1,
)
def toggle_selector(mode: str) -> tuple[Any, Any]:
use_cur = (mode == _CURATED_MODE)
return gr.update(visible=use_cur), gr.update(visible=not use_cur)
selection_mode.change(
toggle_selector, inputs=[selection_mode],
outputs=[curated_dropdown, customer_slider],
)
# Transaction Timeline
gr.HTML("""<h3 style='margin: 16px 0 8px 0; color: #171717;'>Transaction History</h3>
<div style="font-size: 11px; color: #737373; margin-bottom: 4px;">
64 most recent transactions. Tx 63 (highlighted) is the most recent.
The model predicts what comes next based on this full sequence.
</div>""")
timeline_output = gr.HTML()
# Side-by-side predictions
gr.HTML("<h3 style='margin: 16px 0 4px 0; color: #171717; font-size: 18px; font-weight: 600; letter-spacing: -0.01em;'>Model Predictions: Pretrained vs Random Init</h3>")
gr.HTML(render_comparison_header())
fraud_output = gr.HTML()
merchant_output = gr.HTML()
amount_output = gr.HTML()
mcc_output = gr.HTML()
# Wire callbacks
outputs = [
summary_text, timeline_output, profile_output,
fraud_output, merchant_output, amount_output, mcc_output,
latency_text,
]
run_btn.click(
on_customer_select,
inputs=[curated_dropdown, customer_slider, selection_mode],
outputs=outputs,
)
curated_dropdown.change(
on_customer_select,
inputs=[curated_dropdown, customer_slider, selection_mode],
outputs=outputs,
)
# ===== Tab 2: Architecture Deep Dive =====
with gr.Tab("Architecture"):
gr.HTML(render_production_architecture())
# ===== Tab 3: Why Liquid =====
with gr.Tab("Why Liquid AI"):
gr.HTML(render_why_liquid())
# ===== Tab 4: Integration Guide =====
with gr.Tab("Integration"):
gr.HTML(render_integration_guide())
return app
def _side_by_side(title: str, pretrained_html: str, random_html: str) -> str:
"""Render pretrained vs random-init predictions side by side."""
_mono = "JetBrains Mono, ui-monospace, monospace"
return f"""
<div style="margin-bottom: 16px;">
<div style="font-size: 13px; font-weight: 600; color: #171717; margin-bottom: 8px;
letter-spacing: -0.01em;">
{title}
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div style="background: #ffffff; border: 1px solid rgba(16,185,129,0.25);
border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);">
<div style="font-family: {_mono}; font-size: 10px; color: #10B981;
font-weight: 600; margin-bottom: 8px; text-transform: uppercase;
letter-spacing: 0.05em;">
&#10003; Pretrained
</div>
{pretrained_html}
</div>
<div style="background: #ffffff; border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);">
<div style="font-family: {_mono}; font-size: 10px; color: #a3a3a3;
font-weight: 600; margin-bottom: 8px; text-transform: uppercase;
letter-spacing: 0.05em;">
&#10007; Random Init
</div>
{random_html}
</div>
</div>
</div>
"""
# ---------------------------------------------------------------------------
# CLI entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="LFM2 Transaction Foundation Model — Interactive Inference Demo",
)
parser.add_argument(
"--checkpoint", type=Path,
default=Path("experiments/v2_tied/finetune_20260516_190905/checkpoints/step_004999.pt"),
help="Path to fine-tuned MultiHeadModel checkpoint (.pt file)",
)
parser.add_argument(
"--data-dir", type=Path, default=Path("data/synthetic"),
help="Directory containing token_ids.npy, sequence_labels.npy, split_indices.npz",
)
parser.add_argument(
"--model-config", type=Path, default=Path("configs/model.yaml"),
help="Model backbone YAML config",
)
parser.add_argument(
"--schema", type=Path, default=Path("data/schema.yaml"),
help="Feature schema YAML",
)
parser.add_argument(
"--finetune-config", type=Path,
default=Path("experiments/v2_tied/finetune_20260516_190905/finetune_config.yaml"),
help="Fine-tune head config YAML",
)
parser.add_argument("--port", type=int, default=7860)
parser.add_argument("--share", action="store_true", help="Create public Gradio link")
args = parser.parse_args()
print("Loading schema and merchant catalog...")
schema = load_schema(args.schema)
merchant_catalog = DemoMerchantCatalog(schema)
print("Loading test data...")
demo_data = DemoData(args.data_dir, schema)
print(f" {demo_data.num_customers} test customers "
f"({len(demo_data.fraud_indices)} fraud, {len(demo_data.legit_indices)} legitimate)")
print("Building pretrained model...")
pretrained_model = build_model(args.model_config, schema, args.finetune_config)
checkpoint_status = load_model_checkpoint(pretrained_model, args.checkpoint)
print(f" Pretrained: {checkpoint_status}")
print("Building random-init baseline model...")
random_model = build_model(args.model_config, schema, args.finetune_config)
print(" Random-init: fresh weights (no checkpoint)")
total_params = sum(p.numel() for p in pretrained_model.parameters())
print(f" Parameters per model: {total_params:,}")
print("Building decoder...")
decoder = TransactionDecoder(schema, merchant_catalog)
print("Launching demo...")
app = create_app(
pretrained_model, random_model, demo_data,
decoder, merchant_catalog, checkpoint_status,
)
_liquid_theme = gr.themes.Soft(
primary_hue="neutral",
secondary_hue="neutral",
neutral_hue="neutral",
font=gr.themes.GoogleFont("Inter"),
font_mono=gr.themes.GoogleFont("JetBrains Mono"),
).set(
body_background_fill="#f5f5f5",
body_background_fill_dark="#f5f5f5",
body_text_color="#171717",
body_text_color_dark="#171717",
body_text_color_subdued="#737373",
body_text_color_subdued_dark="#737373",
block_background_fill="#ffffff",
block_background_fill_dark="#ffffff",
block_border_color="rgba(0,0,0,0.1)",
block_border_color_dark="rgba(0,0,0,0.1)",
block_label_background_fill="#f5f5f5",
block_label_background_fill_dark="#f5f5f5",
block_label_text_color="#525252",
block_label_text_color_dark="#525252",
block_title_text_color="#171717",
block_title_text_color_dark="#171717",
block_shadow="0 1px 3px rgba(0,0,0,0.04)",
block_shadow_dark="0 1px 3px rgba(0,0,0,0.04)",
input_background_fill="#ffffff",
input_background_fill_dark="#ffffff",
input_background_fill_focus="#ffffff",
input_background_fill_focus_dark="#ffffff",
input_border_color="rgba(0,0,0,0.1)",
input_border_color_dark="rgba(0,0,0,0.1)",
input_border_color_focus="#171717",
input_border_color_focus_dark="#171717",
input_placeholder_color="#a3a3a3",
input_placeholder_color_dark="#a3a3a3",
panel_background_fill="#fafafa",
panel_background_fill_dark="#fafafa",
panel_border_color="rgba(0,0,0,0.06)",
panel_border_color_dark="rgba(0,0,0,0.06)",
border_color_primary="rgba(0,0,0,0.1)",
border_color_primary_dark="rgba(0,0,0,0.1)",
button_primary_background_fill="#171717",
button_primary_background_fill_dark="#171717",
button_primary_background_fill_hover="#404040",
button_primary_background_fill_hover_dark="#404040",
button_primary_text_color="#ffffff",
button_primary_text_color_dark="#ffffff",
button_secondary_background_fill="#ffffff",
button_secondary_background_fill_dark="#ffffff",
button_secondary_text_color="#525252",
button_secondary_text_color_dark="#525252",
button_secondary_border_color="rgba(0,0,0,0.1)",
button_secondary_border_color_dark="rgba(0,0,0,0.1)",
checkbox_background_color="#ffffff",
checkbox_background_color_dark="#ffffff",
checkbox_border_color="rgba(0,0,0,0.2)",
checkbox_border_color_dark="rgba(0,0,0,0.2)",
checkbox_background_color_selected="#171717",
checkbox_background_color_selected_dark="#171717",
checkbox_label_background_fill="#ffffff",
checkbox_label_background_fill_dark="#ffffff",
checkbox_label_text_color="#171717",
checkbox_label_text_color_dark="#171717",
slider_color="#171717",
slider_color_dark="#171717",
table_border_color="rgba(0,0,0,0.06)",
table_border_color_dark="rgba(0,0,0,0.06)",
table_even_background_fill="#fafafa",
table_even_background_fill_dark="#fafafa",
table_odd_background_fill="#ffffff",
table_odd_background_fill_dark="#ffffff",
shadow_spread="0px",
shadow_spread_dark="0px",
color_accent_soft="rgba(0,0,0,0.04)",
color_accent_soft_dark="rgba(0,0,0,0.04)",
)
_liquid_css = """
/* Force light mode regardless of system preference */
:root, .dark { color-scheme: light !important; }
.gradio-container {
background: #f5f5f5 !important;
max-width: 1280px !important;
margin: auto !important;
padding: 1.5rem !important;
}
/* Tabs: pill-style matching Liquid design system */
.tabs { background: transparent !important; }
.tab-nav {
background: #f5f5f5 !important;
border: none !important;
border-bottom: 1px solid rgba(0,0,0,0.1) !important;
gap: 4px !important;
padding: 4px 0 !important;
}
.tab-nav button {
font-weight: 500 !important;
font-size: 14px !important;
color: #737373 !important;
background: transparent !important;
border: none !important;
border-bottom: 2px solid transparent !important;
padding: 8px 16px !important;
border-radius: 0 !important;
transition: all 0.15s ease !important;
}
.tab-nav button:hover {
color: #171717 !important;
background: rgba(0,0,0,0.03) !important;
}
.tab-nav button.selected {
color: #171717 !important;
font-weight: 600 !important;
border-bottom: 2px solid #171717 !important;
background: transparent !important;
}
/* Block/component overrides */
.block { border-radius: 12px !important; }
.block.padded { background: #ffffff !important; }
/* Input, textarea, dropdown */
input, textarea, select, .wrap {
background: #ffffff !important;
color: #171717 !important;
border-color: rgba(0,0,0,0.1) !important;
}
.secondary-wrap, .wrap-inner {
background: #ffffff !important;
}
/* Labels */
label, .label-wrap, span.svelte-1gfkn6j {
color: #171717 !important;
}
.info {
color: #737373 !important;
}
/* Action buttons: pill style applied only to explicit primary actions.
Scoped by elem_id so it doesn't bleed into Gradio radio/checkbox
options (which Gradio also renders as <button class="primary">). */
#run-inference-btn button,
button#run-inference-btn {
background: #171717 !important;
color: #ffffff !important;
border-radius: 9999px !important;
border: none !important;
font-weight: 500 !important;
letter-spacing: -0.02em !important;
}
#run-inference-btn button:hover,
button#run-inference-btn:hover {
background: #404040 !important;
}
/* Radio group: render as a clean vertical list with native circle
indicators. Scoped via elem_classes="liquid-radio" so we can target
reliably without depending on Gradio's internal Svelte-hashed class
names. Defeats Gradio's default of styling the selected option as a
depressed dark button -- a form selection should not look like an
action button. */
.liquid-radio,
.liquid-radio > * {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.liquid-radio .wrap,
.liquid-radio fieldset {
display: flex !important;
flex-direction: column !important;
gap: 2px !important;
padding: 0 !important;
}
/* Each option label -- override Gradio's button-like rendering */
.liquid-radio label {
display: flex !important;
align-items: center !important;
gap: 10px !important;
padding: 8px 10px !important;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
border: none !important;
border-radius: 8px !important;
cursor: pointer !important;
font-size: 13px !important;
font-weight: 400 !important;
color: #171717 !important;
box-shadow: none !important;
transition: background 0.1s ease !important;
}
.liquid-radio label:hover {
background: rgba(0,0,0,0.04) !important;
}
/* The radio input itself -- native circle, dark fill when checked */
.liquid-radio input[type="radio"] {
appearance: auto !important;
-webkit-appearance: radio !important;
accent-color: #171717 !important;
width: 16px !important;
height: 16px !important;
min-width: 16px !important;
margin: 0 !important;
cursor: pointer !important;
opacity: 1 !important;
}
/* Selected option: subtle background tint + slightly bolder text.
Three selectors because different Gradio versions mark the selected
option differently: .selected class, [aria-checked="true"], or
:has(input:checked). */
.liquid-radio label.selected,
.liquid-radio label[aria-checked="true"],
.liquid-radio label:has(input:checked) {
background: rgba(0,0,0,0.04) !important;
color: #171717 !important;
font-weight: 500 !important;
}
/* Kill any inherited button.primary/button.secondary styling that
Gradio may apply to radio option wrappers in some versions. */
.liquid-radio button,
.liquid-radio button.primary,
.liquid-radio button.secondary {
background: transparent !important;
color: #171717 !important;
border: none !important;
border-radius: 8px !important;
box-shadow: none !important;
font-weight: 400 !important;
text-align: left !important;
justify-content: flex-start !important;
}
.liquid-radio button.primary,
.liquid-radio button.selected {
background: rgba(0,0,0,0.04) !important;
font-weight: 500 !important;
}
/* Checkbox */
.checkbox-item { color: #171717 !important; }
/* Dropdown */
.dropdown-arrow { color: #525252 !important; }
ul.options { background: #ffffff !important; border-color: rgba(0,0,0,0.1) !important; }
ul.options li { color: #171717 !important; }
ul.options li:hover, ul.options li.selected {
background: #f5f5f5 !important;
}
/* Textbox display (non-editable) */
.textbox textarea[disabled], .textbox input[disabled] {
background: #fafafa !important;
color: #171717 !important;
opacity: 1 !important;
}
/* Remove dark shadows/borders */
.block { box-shadow: 0 1px 3px rgba(0,0,0,0.04) !important; }
/* Accordion/group headers */
.form { background: #ffffff !important; border-color: rgba(0,0,0,0.06) !important; }
/* Override any remaining dark backgrounds */
[class*="dark:"], .dark * {
--tw-bg-opacity: 1 !important;
}
"""
_force_light_js = """
() => {
document.documentElement.classList.remove('dark');
document.documentElement.style.colorScheme = 'light';
const meta = document.createElement('meta');
meta.name = 'color-scheme';
meta.content = 'light';
document.head.appendChild(meta);
}
"""
# Try the requested port first, then walk up to 10 ports.
# Gradio errors with OSError when a port is taken; we retry transparently
# so a stale background process doesn't block a fresh launch.
for port in range(args.port, args.port + 10):
try:
app.launch(
server_port=port,
share=args.share,
theme=_liquid_theme,
css=_liquid_css,
js=_force_light_js,
)
break
except OSError as e:
if "Cannot find empty port" in str(e) or "Address already in use" in str(e):
print(f" port {port} in use, trying {port + 1}...")
continue
raise
else:
raise RuntimeError(
f"No free port in range {args.port}-{args.port + 9}. "
f"Kill stale processes: lsof -ti:{args.port} | xargs kill"
)
if __name__ == "__main__":
main()