Team Ai
Apppublic

Kolaps27/UI-layout-optimizer

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
env.py518 linesDownload Raw Back to root
1"""2ui_env.py3---------4Environment Engine for an Adaptive UI Layout Optimization system.5"""6 7from __future__ import annotations8import random9from typing import Literal, Optional10from pydantic import BaseModel, Field, model_validator11 12# ---------------------------------------------------------------------------13# Task Class (Required by OpenEnv Validator)14# ---------------------------------------------------------------------------15 16EPS = 1e-617 18def safe_grader(fn):19    def wrapper(x=None):20        try:21            if x is None:22                val = fn()23            else:24                try:25                    val = fn(x)26                except TypeError:27                    val = fn()28 29            if not isinstance(val, (int, float)):30                val = 0.531 32            val = float(val)33 34            val = max(min(val, 1.0 - EPS), EPS)35 36            return val37 38        except Exception:39            return 0.540 41    return wrapper42 43def clamp_score(raw: float) -> float:44    # Retained as a stub just in case other direct internal refs exist,45    # though safe_grader is now the strict master gate46    if not isinstance(raw, (int, float)):47        return 0.548    val = float(raw)49    return max(min(val, 1.0 - EPS), EPS)50 51def normalize(x: float, lo: float, hi: float) -> float:52    """Min-max normalize x from [lo, hi] to [0, 1], clamped."""53    if hi <= lo:54        return 0.555    return max(0.0, min(1.0, (x - lo) / (hi - lo)))56 57 58def grade_easy(x=None) -> float:59    try:60        return clamp_score(UIEnv.grade_easy(x))61    except Exception:62        return 0.563 64 65def grade_medium(x=None) -> float:66    try:67        return clamp_score(UIEnv.grade_medium(x))68    except Exception:69        return 0.570 71 72def grade_hard(x=None) -> float:73    try:74        return clamp_score(UIEnv.grade_hard(x))75    except Exception:76        return 0.577 78 79class Task:80    def __init__(self, name: str, grader):81        self.name = name82        self.grader = grader83 84# ---------------------------------------------------------------------------85# Constants86# ---------------------------------------------------------------------------87 88BUTTON_SIZE_MIN: float = 0.589BUTTON_SIZE_MAX: float = 2.090FORM_LENGTH_MIN: int = 191FORM_LENGTH_MAX: int = 1092STEPS_MIN: int = 193STEPS_MAX: int = 1094 95BUTTON_SIZE_DELTA: float = 0.196FORM_LENGTH_DELTA: int = 197STEPS_DELTA: int = 198 99INVALID_ACTION_REWARD: float = -0.1100MAX_STEPS_PER_EPISODE: int = 20101 102BUTTON_SWEET_LOW: float = 0.9103BUTTON_SWEET_HIGH: float = 1.3104 105# ---------------------------------------------------------------------------106# Data Models107# ---------------------------------------------------------------------------108 109class Layout(BaseModel):110    """Represents the current UI layout configuration."""111    button_size: float = Field(112        default=1.0,113        ge=BUTTON_SIZE_MIN,114        le=BUTTON_SIZE_MAX,115        description="Size multiplier for UI buttons (0.5 - 2.0).",116    )117    form_length: int = Field(118        default=5,119        ge=FORM_LENGTH_MIN,120        le=FORM_LENGTH_MAX,121        description="Number of fields in the form (1 - 10).",122    )123    steps: int = Field(124        default=3,125        ge=STEPS_MIN,126        le=STEPS_MAX,127        description="Number of wizard / checkout steps (1 - 10).",128    )129 130class Observation(BaseModel):131    """Full observable state returned to the agent after every transition."""132    device: Literal["mobile", "desktop"] = Field(133        description="Device type the user is on.",134    )135    layout: Layout = Field(136        description="Current layout configuration.",137    )138    progress: float = Field(139        ge=0.0,140        le=1.0,141        description="User's task-completion progress in [0, 1].",142    )143    last_action: Optional[str] = Field(144        default=None,145        description="String name of the most recently applied action, or None.",146    )147    reward: float = Field(default=0.0, description="Step reward")148    done: bool = Field(default=False, description="Is episode done")149    info: dict = Field(default_factory=dict, description="Extra info")150 151class Action(BaseModel):152    """An action the agent can submit to the environment."""153    type: Literal[154        "increase_button",155        "decrease_form",156        "increase_steps",157        "decrease_steps",158        "reorder_sections",159        "set_button_size",160        "noop",161    ] = Field(description="Discrete action type.")162    value: Optional[float] = Field(163        default=None,164        description="Optional scalar payload (used by set_button_size).",165    )166 167    @model_validator(mode="after")168    def _value_required_for_set_button_size(self) -> "Action":169        """Ensure `value` is provided when action type requires it."""170        if self.type == "set_button_size" and self.value is None:171            raise ValueError("'value' must be provided for action type 'set_button_size'.")172        return self173 174# ---------------------------------------------------------------------------175# Environment Engine176# ---------------------------------------------------------------------------177 178class UIEnv:179    """Adaptive UI Layout Optimization - Environment Engine."""180 181    def __init__(self, seed: int = 42, task: str = "easy") -> None:182        self._seed: int = seed183        self.task: str = task184        self._rng: random.Random = random.Random(seed)185 186        # OpenEnv task list with graders — NO dynamic generation, NO conditionals187        self.tasks = [188            Task(name="easy", grader=safe_grader(self.grade_easy)),189            Task(name="medium", grader=safe_grader(self.grade_medium)),190            Task(name="hard", grader=safe_grader(self.grade_hard)),191        ]192        self.task_dict = {t.name: t for t in self.tasks}193 194        self._layout: Layout = Layout()195        self._device: Literal["mobile", "desktop"] = "desktop"196        self._progress: float = 0.0197        self._last_action: Optional[str] = None198        self._step_count: int = 0199        self._prev_score: float = 0.0200 201        self._prefers_short_forms: bool = False202        self._prefers_large_buttons: bool = False203        self._user_type: str = "new"204 205        self._ready: bool = False206 207    def reset(self) -> Observation:208        if self.task == "easy":209            steps = self._rng.randint(2, 3)210            form_length = self._rng.randint(2, 4)211            button_size = self._rng.uniform(0.9, 1.2)212        elif self.task == "medium":213            steps = self._rng.randint(3, 5)214            form_length = self._rng.randint(4, 6)215            button_size = self._rng.uniform(0.7, 1.5)216        elif self.task == "hard":217            steps = self._rng.randint(5, 8)218            form_length = self._rng.randint(6, 10)219            button_size = self._rng.uniform(0.5, 2.0)220        else:221            steps = self._rng.randint(3, 5)222            form_length = self._rng.randint(4, 6)223            button_size = 1.0224 225        self._layout = Layout(226            button_size=button_size,227            form_length=form_length,228            steps=steps,229        )230        self._clamp_layout()231 232        self._device = self._rng.choice(("mobile", "desktop"))233        self._progress = 0.0234        self._last_action = None235        self._step_count = 0236 237        self._prefers_short_forms = self._rng.choice([True, False])238        self._prefers_large_buttons = self._rng.choice([True, False])239        self._user_type = self._rng.choice(["impatient", "careful", "new"])240 241        self._ready = True242        # Initialize prev_score for delta-based reward shaping243        task_obj = next((t for t in self.tasks if t.name == self.task), self.tasks[0])244        self._prev_score = task_obj.grader(None)245        return self._get_observation()246 247    def step(self, action: Action) -> tuple[Observation, float, bool, dict]:248        if not self._ready:249            self.reset()250 251        action_reward_offset: float = self._apply_action(action)252        self._step_count += 1253 254        outcome, user_reward = self._simulate_user()255        done = False256 257        if outcome == "drop":258            done = True259        elif outcome == "distrust":260            pass261        else:262            self._progress += 1.0 / max(1, self._layout.steps)263            if self._progress >= 0.999:264                self._progress = 1.0265                outcome = "complete"266                done = True267 268        reward = user_reward + action_reward_offset269        if outcome == "complete":270            reward += 2.0271        elif outcome == "continue":272            reward += 0.1273 274        reward -= 0.05275 276        # Delta-based grader-aligned shaping: reward reflects improvement277        task_obj = next((t for t in self.tasks if t.name == self.task), self.tasks[0])278        current_score = task_obj.grader(None)279        score_delta = current_score - self._prev_score280        281        alpha = 10.0282        reward += alpha * score_delta  # reward improvement, penalize degradation283        284        self._prev_score = current_score285 286        if self._step_count >= MAX_STEPS_PER_EPISODE:287            done = True288 289        info: dict = {290            "completed": (outcome == "complete"),291            "outcome": outcome,292            "progress": self._progress,293            "step_count": self._step_count,294            "user_type": self._user_type,295        }296 297        if done:298            info["score"] = current_score299            # Terminal grader alignment boost300            reward += current_score301 302        return self._get_observation(), reward, done, info303 304    def state(self) -> Observation:305        if not self._ready:306            raise RuntimeError("Call reset() before state().")307        return self._get_observation()308 309    def close(self) -> None:310        pass311 312    async def reset_async(self) -> Observation:313        return self.reset()314 315    async def step_async(self, action: Action) -> Observation:316        obs, reward, done, info = self.step(action)317        obs.reward = reward318        obs.done = done319        obs.info = info320        return obs321 322    def _simulate_user(self) -> tuple[str, float]:323        if self._step_count <= 3:324            return "continue", 0.0325 326        layout = self._layout327        drop_chance = 0.0328        distrust_chance = 0.0329 330        if layout.steps > 3:331            drop_chance += 0.05 * (layout.steps - 3)332        if layout.form_length > 5:333            drop_chance += 0.04 * (layout.form_length - 5)334        if self._prefers_short_forms and layout.form_length > 4:335            drop_chance += 0.05336        if layout.steps < 2:337            distrust_chance += 0.20338        if layout.button_size < 0.9 or layout.button_size > 1.3:339            distrust_chance += 0.10340            drop_chance += 0.02341 342        if self._user_type == "impatient":343            drop_chance += 0.06344        elif self._user_type == "careful":345            distrust_chance += 0.08346 347        if self.task == "hard":348            drop_chance += 0.04349        elif self.task == "easy":350            drop_chance -= 0.05351            distrust_chance -= 0.05352 353        drop_chance = max(0.0, min(1.0, drop_chance))354        distrust_chance = max(0.0, min(1.0 - drop_chance, distrust_chance))355 356        roll = self._rng.random()357 358        if roll < drop_chance:359            return "drop", -1.0360        elif roll < drop_chance + distrust_chance:361            return "distrust", -0.2362        else:363            return "continue", 0.0364 365    def _apply_action(self, action: Action) -> float:366        reward: float = 0.0367        match action.type:368            case "increase_button":369                self._layout.button_size += BUTTON_SIZE_DELTA370            case "decrease_form":371                self._layout.form_length -= FORM_LENGTH_DELTA372            case "increase_steps":373                self._layout.steps += STEPS_DELTA374            case "decrease_steps":375                self._layout.steps -= STEPS_DELTA376            case "set_button_size":377                proposed: float = action.value378                if not (BUTTON_SIZE_MIN <= proposed <= BUTTON_SIZE_MAX):379                    reward = INVALID_ACTION_REWARD380                self._layout.button_size = proposed381            case "reorder_sections" | "noop":382                pass383 384        self._clamp_layout()385        self._last_action = action.type386        return reward387 388    def _clamp_layout(self) -> None:389        self._layout.button_size = max(BUTTON_SIZE_MIN, min(BUTTON_SIZE_MAX, self._layout.button_size))390        self._layout.form_length = max(FORM_LENGTH_MIN, min(FORM_LENGTH_MAX, self._layout.form_length))391        self._layout.steps = max(STEPS_MIN, min(STEPS_MAX, self._layout.steps))392 393    def _get_observation(self) -> Observation:394        return Observation(395            device=self._device,396            layout=self._layout.model_copy(),397            progress=self._progress,398            last_action=self._last_action,399        )400 401    # ---------------------------------------------------------------------------402    # Graders  (deterministic · partial-credit · strictly bounded in (0,1))403    # ---------------------------------------------------------------------------404 405    def grade_easy(self, *args, **kwargs) -> float:406        """Easy task — single objective: maximize completion progress.407 408        Sub-metrics (weighted sum):409          80 % completion progress410          20 % button-size proximity to sweet spot (1.1)411        A baseline agent can reach moderate scores easily.412        """413        progress = getattr(self, '_progress', 0.0)414        layout   = getattr(self, '_layout', Layout())415 416        # --- sub-metric 1: completion progress ---417        m_progress = normalize(progress, 0.0, 1.0)418 419        # --- sub-metric 2: button in sweet spot (peak at 1.1) ---420        bs_err = abs(layout.button_size - 1.1)421        m_button = 1.0 - normalize(bs_err, 0.0, 1.6)  # 1.6 = max possible error422 423        score = 0.80 * m_progress + 0.20 * m_button424        return clamp_score(score)425 426    def grade_medium(self, *args, **kwargs) -> float:427        """Medium task — multiple objectives, weighted sum, mild interactions.428 429        Sub-metrics:430          40 % completion progress431          25 % button-size proximity432          20 % form-length optimality  (ideal ≈ 3)433          15 % step-count optimality   (ideal ≈ 2)434        Requires coordinated improvements across dimensions.435        """436        progress = getattr(self, '_progress', 0.0)437        layout   = getattr(self, '_layout', Layout())438 439        m_progress = normalize(progress, 0.0, 1.0)440 441        bs_err = abs(layout.button_size - 1.1)442        m_button = 1.0 - normalize(bs_err, 0.0, 1.6)443 444        fl_err = abs(layout.form_length - 3)445        m_form = 1.0 - normalize(fl_err, 0.0, 9.0)  # range 1-10, ideal 3446 447        st_err = abs(layout.steps - 2)448        m_steps = 1.0 - normalize(st_err, 0.0, 9.0)  # range 1-10, ideal 2449 450        score = 0.40 * m_progress + 0.25 * m_button + 0.20 * m_form + 0.15 * m_steps451        return clamp_score(score)452 453    def grade_hard(self, *args, **kwargs) -> float:454        """Hard task — conflicting objectives, geometric mean.455 456        Trade-offs (CANNOT maximise all simultaneously):457          Conversion  — wants short forms + few steps458          Data quality — wants more fields + more steps459          Usability   — device-dependent button sweet-spot460          Progress    — completion rate461 462        Scoring: weighted geometric mean in log-space.463        Final score min-max stretched so worst ≈ 0.05, best ≈ 0.95.464        """465        import math466 467        progress = getattr(self, '_progress', 0.0)468        layout   = getattr(self, '_layout', Layout())469        device   = getattr(self, '_device', 'desktop')470 471        FLOOR = 0.05          # sub-metric floor (keeps log finite)472 473        # --- conversion: wants form_length ≤ 3 and steps ≤ 2 ---474        conv_raw = 1.0 - 0.08 * max(0, layout.form_length - 3) \475                       - 0.10 * max(0, layout.steps - 2)476        m_conv = max(FLOOR, min(1.0, conv_raw))477 478        # --- data quality: wants form_length ≥ 6 and steps ≥ 5 ---479        qual_raw = 0.10 * min(layout.form_length, 10) \480                 + 0.07 * min(layout.steps, 10)481        m_qual = max(FLOOR, normalize(qual_raw, 0.0, 1.7))482 483        # --- usability: device-dependent button sweet-spot ---484        optimal_bs = 1.3 if device == 'mobile' else 1.0485        usab_raw = 1.0 - abs(layout.button_size - optimal_bs) / 1.5486        m_usab = max(FLOOR, min(1.0, usab_raw))487 488        # --- progress ---489        m_prog = max(FLOOR, normalize(progress, 0.0, 1.0))490 491        # --- weighted geometric mean (log-space) ---492        log_score = (0.25 * math.log(m_prog)493                   + 0.30 * math.log(m_conv)494                   + 0.25 * math.log(m_qual)495                   + 0.20 * math.log(m_usab))496        raw = math.exp(log_score)497 498        # stretch so empirical range [~0.05, ~0.85] maps to [~0.05, ~0.95]499        score = normalize(raw, 0.05, 0.90)500        return clamp_score(score)501 502if __name__ == "__main__":503    env = UIEnv()504    print("\n--- self-test ---")505    for t in env.tasks:506        # test with 0 args, None, and {}507        for label, call_args in [("None", (None,)), ("dict", ({},)), ("text", ("test",))]:508            val = t.grader(*call_args)509            ok = isinstance(val, float) and 0.0 < val < 1.0510            print(f"  {t.name:8s} grader({label:6s}) = {val:.6f}  {'OK' if ok else 'FAIL'}")511    # variation: change state and re-grade512    env._progress = 0.8513    env._layout = Layout(button_size=1.1, form_length=3, steps=2)514    print("\n--- after optimized state ---")515    for t in env.tasks:516        val = t.grader(None)517        print(f"  {t.name:8s} = {val:.6f}")518