Domain: Business Operations & Analytics

We have provided some input files under /tmp/files/. The data might be messy and inconsistent. Do analyse and filter out structurally inconsistent data as per the standard IPL format if needed. Use the cleaned dataset to compute:

For each match_id in the combined dataset compute legal runs/balls and run_rate rounded to exactly 2 decimal places, and represented as a 2-decimal string.
Select the maximum run-rate value that occurs for ≥2 distinct matches (fallback to overall max if none). Let S be matches at that rate; pick the champion by max legal runs, tie→min match_id. 

Output:
Print exactly one line containing only this JSON object with keys in this order:
{"rate_value_2dp":"<2dp_string>","group_size":<int>,"champion_match_id":<int>,"champion_legal_runs":<int>,"champion_legal_balls":<int>}
- rate_value_2dp must be a string formatted with exactly 2 digits after the decimal (example: "4.09").

Grading rubric:
criterion: The keys appear in the exact order: rate_value_2dp, group_size, champion_match_id, champion_legal_runs, champion_legal_balls.
weight: 10
criterion: rate_value_2dp equals the exact string "10.30" (must include exactly 2 digits after the decimal).
weight: 15
criterion: group_size equals 3
weight: 10
criterion: champion_match_id equals 1304080
weight: 15
criterion: champion_legal_runs equals 412
weight: 15
criterion: champion_legal_balls equals 240
weight: 15

Golden answer:
```python
import os
import json
import pandas as pd
from decimal import Decimal, ROUND_HALF_UP
import re

BASE_DIR = ""
FILES = [f"IPL-{y}.xlsx" for y in [2021, 2022, 2023, 2024, 2025]]

BALLS_PER_OVER = 6  # strict IPL
BALLNO_COLS = ["ball_no"]
INNINGS_COLS = ["innings"]
BALLNO_RE = re.compile(r"^\s*(\d{1,2})\.(\d)\s*$")

def round_half_up_2dp_str(x: Decimal) -> str:
    q = x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    s = format(q, "f")
    if "." not in s:
        return s + ".00"
    whole, frac = s.split(".", 1)
    return whole + "." + (frac + "00")[:2]

def pick_col(df, candidates):
    for c in candidates:
        if c in df.columns:
            return c
    return None

def normalize_ball_no(x):
    if pd.isna(x):
        return None
    s = str(x).strip()
    m = BALLNO_RE.match(s)
    if m:
        return f"{int(m.group(1))}.{int(m.group(2))}"
    try:
        f = float(s)
        ov = int(f)
        b = int(round((f - ov) * 10))
        return f"{ov}.{b}"
    except Exception:
        return None

def ball_index(ov_ball: str) -> int:
    try:
        ov_s, b_s = ov_ball.split(".")
        ov = int(ov_s)
        b = int(b_s)
        if 0 <= ov <= 19 and 1 <= b <= 6:
            return ov * 6 + b  # 1..120
    except Exception:
        pass
    return -1

def innings_ok_prefix(ballnos) -> bool:
    # allow early finish, but no missing from start or in-between; duplicates ok
    idxs = {ball_index(x) for x in ballnos}
    idxs.discard(-1)
    if not idxs:
        return False
    last = max(idxs)
    return set(range(1, last + 1)).issubset(idxs)

def match_ok(g: pd.DataFrame) -> bool:
    inns = set(g["_innings"].unique().tolist())
    if inns != {1, 2}:
        return False
    for i in (1, 2):
        if not innings_ok_prefix(g.loc[g["_innings"] == i, "_ball_no"].tolist()):
            return False
    return True


dfs = []
for fn in FILES:
    path = os.path.join(BASE_DIR, fn)
    df = pd.read_excel(path, engine="openpyxl")

    # minimal required columns
    for col in ["match_id", "valid_ball", "runs_total"]:
        if col not in df.columns:
            raise ValueError(f"{fn}: missing required column {col}")

    inn_col = pick_col(df, INNINGS_COLS)
    bn_col = pick_col(df, BALLNO_COLS)
    if inn_col is None or bn_col is None:
        raise ValueError(f"{fn}: missing innings/ball_no column")

    # parse numeric basics
    df["match_id"] = pd.to_numeric(df["match_id"], errors="coerce")
    df["valid_ball"] = pd.to_numeric(df["valid_ball"], errors="coerce")
    df["runs_total"] = pd.to_numeric(df["runs_total"], errors="coerce")

    # normalize innings + ball_no for match-structure validation
    df["_innings"] = pd.to_numeric(df[inn_col], errors="coerce")
    df["_ball_no"] = df[bn_col].apply(normalize_ball_no)

    # drop unusable rows for structure + stats
    df = df.dropna(subset=["match_id", "valid_ball", "runs_total", "_innings", "_ball_no"])
    df = df[df["valid_ball"].isin([0, 1])]
    df = df[df["_innings"].isin([1, 2])]
    df = df[df["_ball_no"].apply(lambda x: ball_index(x) != -1)]  # must be within 0.1..19.6

    # discard matches failing the per-innings prefix rule (duplicates ok, early finish ok)
    valid_match_ids = (
        df.groupby("match_id")
          .filter(match_ok)["match_id"]
          .drop_duplicates()
          .tolist()
    )
    df = df[df["match_id"].isin(valid_match_ids)].copy()

    # finalize dtypes
    df["match_id"] = df["match_id"].astype(int)
    df["valid_ball"] = df["valid_ball"].astype(int)
    df["runs_total"] = df["runs_total"].astype(int)

    dfs.append(df[["match_id", "valid_ball", "runs_total"]])


all_df = pd.concat(dfs, ignore_index=True)

# legal-only per match
legal_only = all_df[all_df["valid_ball"] == 1].copy()

agg = legal_only.groupby("match_id", as_index=False).agg(
    champion_legal_runs=("runs_total", "sum"),
    champion_legal_balls=("valid_ball", "size"),
)

# run-rate 2dp string (round-half-up)
def rr_2dp(r, b) -> str:
    if int(b) == 0:
        return "0.00"
    rr = (Decimal(int(r)) / Decimal(int(b))) * Decimal(BALLS_PER_OVER)
    return round_half_up_2dp_str(rr)

agg["rate_value_2dp"] = [rr_2dp(r, b) for r, b in zip(agg["champion_legal_runs"], agg["champion_legal_balls"])]

# pick max rate with >=2 matches else overall max
counts = agg.groupby("rate_value_2dp")["match_id"].nunique().to_dict()
repeat_rates = [rv for rv, c in counts.items() if c >= 2]

if repeat_rates:
    chosen_rate = max(repeat_rates, key=lambda s: Decimal(s))
else:
    chosen_rate = max(agg["rate_value_2dp"].tolist(), key=lambda s: Decimal(s))

S = agg[agg["rate_value_2dp"] == chosen_rate].copy()
group_size = int(S["match_id"].nunique())

# champion: max legal runs, tie -> min match_id
S = S.sort_values(by=["champion_legal_runs", "match_id"], ascending=[False, True]).reset_index(drop=True)
champ = S.iloc[0]

out = {
    "rate_value_2dp": str(chosen_rate),
    "group_size": group_size,
    "champion_match_id": int(champ["match_id"]),
    "champion_legal_runs": int(champ["champion_legal_runs"]),
    "champion_legal_balls": int(champ["champion_legal_balls"]),
}

print(json.dumps(out, separators=(",", ":"), ensure_ascii=False))
```

Explanation:
- Loads IPL-2021.xlsx…IPL-2025.xlsx and ensures required columns exist (match_id, valid_ball, runs_total, innings and ball_no).
- Normalizes innings and ball_no, maps each ball_no to a 1..120 index (0.1→1 … 19.6→120), and discards rows outside the standard IPL ball range.
- Drops entire matches that don’t have exactly innings {1,2} or where either innings is not a gap-free prefix from 0.1 (duplicates allowed for extras; early finishes allowed; missing from start/in-between rejected).
- Concatenates all remaining rows, then for each match computes legal_runs (sum of runs_total on valid_ball==1) and legal_balls (count of legal balls).
- Computes run_rate = (legal_runs/legal_balls)*6, rounds to 2 decimals using round-half-up, picks the highest 2dp rate occurring in ≥2 matches (else overall max), chooses the champion by max legal_runs (tie → min match_id), and prints the final JSON.

GTFA: {"rate_value_2dp":"10.30","group_size":3,"champion_match_id":1304080,"champion_legal_runs":412,"champion_legal_balls":240}