"""Position reward: compare spatial layout of matched text blocks. Phase 2 of the Design2Code metrics extension. Reuses text block extraction from Phase 1 (text_block_rewards.py). For each matched block pair, computes the normalised distance between bounding-box centres and converts it to a similarity score in [0, 1]. """ from __future__ import annotations import logging import math from typing import Optional from openenv.server.rewards import extract_html from openenv.server.rewards.text_block_rewards import _get_text_blocks logger = logging.getLogger(__name__) _VIEWPORT_W = 640 _VIEWPORT_H = 480 # Diagonal of the viewport — used to normalise distances to [0, 1] _VIEWPORT_DIAG = math.sqrt(_VIEWPORT_W**2 + _VIEWPORT_H**2) def position_reward( completions: list[list[dict]], solution: Optional[list[str]] = None, ) -> list[float]: """Score positional accuracy of text blocks relative to the reference. Matches reference and predicted blocks using the Hungarian algorithm on normalised centre-to-centre distance, then averages the position scores (1 − normalised_distance) across all matched pairs. Args: completions: List of completion message lists. solution: List of reference HTML strings (one per completion). Returns: List of float scores in [0.0, 1.0]. """ results = [] for i, completion in enumerate(completions): content = completion[0]["content"] html = extract_html(content) ref_html = solution[i] if solution and i < len(solution) else None if not ref_html: results.append(0.0) continue try: import numpy as np from scipy.optimize import linear_sum_assignment ref_blocks = _get_text_blocks(ref_html) pred_blocks = _get_text_blocks(html) if not ref_blocks: results.append(1.0 if not pred_blocks else 0.5) continue if not pred_blocks: results.append(0.0) continue n_ref = len(ref_blocks) n_pred = len(pred_blocks) # Cost = normalised Euclidean distance between block centres cost_matrix = np.zeros((n_ref, n_pred), dtype=np.float64) for r, ref_block in enumerate(ref_blocks): ref_cx = ref_block["x"] + ref_block["width"] / 2 ref_cy = ref_block["y"] + ref_block["height"] / 2 for p, pred_block in enumerate(pred_blocks): pred_cx = pred_block["x"] + pred_block["width"] / 2 pred_cy = pred_block["y"] + pred_block["height"] / 2 dist = math.sqrt((ref_cx - pred_cx) ** 2 + (ref_cy - pred_cy) ** 2) cost_matrix[r, p] = dist / _VIEWPORT_DIAG row_ind, col_ind = linear_sum_assignment(cost_matrix) # Average positional similarity over ALL reference blocks # (unmatched blocks beyond n_pred count as distance=1, i.e. score=0) position_scores = [1.0 - cost_matrix[r, p] for r, p in zip(row_ind, col_ind)] # Pad zeros for any unmatched reference blocks if len(position_scores) < n_ref: position_scores += [0.0] * (n_ref - len(position_scores)) score = max(0.0, sum(position_scores) / n_ref) results.append(score) except Exception as exc: logger.warning("Position reward failed: %s", exc) results.append(0.0) return results