| |
| """Validate the six HaloProbe claim records from paper-owned source assets. |
| |
| This is deliberately a small, CPU-only validator. It does not import an LVLM, |
| train a model, call a proprietary API, or run an author demo. The figure |
| checks parse the vector SVG exports of the two paper figures; the table checks |
| recompute quantities from the paper's printed values. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import re |
| from pathlib import Path |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| OUT = ROOT / "outputs" |
|
|
|
|
| def sha256(path: Path) -> str: |
| h = hashlib.sha256() |
| h.update(path.read_bytes()) |
| return h.hexdigest() |
|
|
|
|
| def attrs(tag: str) -> dict[str, str]: |
| return dict(re.findall(r'([A-Za-z_:][-A-Za-z0-9_.:]*)="([^"]*)"', tag)) |
|
|
|
|
| def path_records(svg: str): |
| for match in re.finditer(r"<path\b([^>]*)>", svg): |
| a = attrs(match.group(1)) |
| if "d" in a: |
| yield a |
|
|
|
|
| def points(d: str) -> list[tuple[float, float]]: |
| return [ |
| (float(x), float(y)) |
| for x, y in re.findall(r"(?:^|\s)(?:M|L)\s*([-+]?\d*\.?\d+)\s+([-+]?\d*\.?\d+)", d) |
| ] |
|
|
|
|
| def extract_attention(svg_path: Path) -> dict: |
| svg = svg_path.read_text() |
| blue_candidates = [] |
| red_candidates = [] |
| blue_dash = [] |
| red_dash = [] |
| for a in path_records(svg): |
| stroke = a.get("stroke", "") |
| p = points(a.get("d", "")) |
| if stroke == "rgb(0%, 0%, 100%)" and a.get("stroke-width") == "2.5" and len(p) > 100: |
| blue_candidates.append(p) |
| if stroke == "rgb(100%, 0%, 0%)" and a.get("stroke-width") == "2.5" and len(p) > 100: |
| red_candidates.append(p) |
| if stroke == "rgb(0%, 0%, 100%)" and "stroke-dasharray" in a: |
| blue_dash.append(p) |
| if stroke == "rgb(100%, 0%, 0%)" and "stroke-dasharray" in a: |
| red_dash.append(p) |
| blue = max(blue_candidates, key=len) |
| red = max(red_candidates, key=len) |
| n = min(len(blue), len(red)) |
| conditioned_ge = sum(red[i][1] >= blue[i][1] for i in range(n)) |
|
|
| |
| |
| |
| tick_y0, tick_y1 = 52.203, 225.417 |
| value0, value1 = 0.0010, 0.0040 |
|
|
| def y_to_value(y: float) -> float: |
| return value0 + (y - tick_y0) * (value1 - value0) / (tick_y1 - tick_y0) |
|
|
| marginal_correct = y_to_value(blue_dash[0][0][1]) |
| marginal_hallucinated = y_to_value(red_dash[0][0][1]) |
| return { |
| "long_blue_points": len(blue), |
| "long_red_points": len(red), |
| "conditioned_hallucinated_ge_correct_points": conditioned_ge, |
| "conditioned_total_points": n, |
| "conditioned_fraction": conditioned_ge / n, |
| "marginal_correct_from_vector": marginal_correct, |
| "marginal_hallucinated_from_vector": marginal_hallucinated, |
| "marginal_correct_gt_hallucinated": marginal_correct > marginal_hallucinated, |
| "tick_calibration": { |
| "vector_y": [tick_y0, tick_y1], |
| "printed_value": [value0, value1], |
| "description": "paper figure y-axis labels 0.0010 and 0.0040", |
| }, |
| } |
|
|
|
|
| def extract_class_proportions(svg_path: Path) -> dict: |
| svg = svg_path.read_text() |
| blue = "rgb(12.156677%, 46.665955%, 70.587158%)" |
| red = "rgb(83.920288%, 15.293884%, 15.686035%)" |
| blue_tops = [] |
| red_tops = [] |
| for a in path_records(svg): |
| if "transform" in a or a.get("fill") not in {blue, red}: |
| continue |
| p = points(a.get("d", "")) |
| if len(p) != 5: |
| continue |
| ys = [y for _, y in p] |
| base = max(ys) |
| top = min(ys) |
| |
| |
| xs = [x for x, _ in p] |
| width = max(xs) - min(xs) |
| if abs(base - 211.332) > 0.01 or not (14.0 < width < 15.0): |
| continue |
| if a.get("fill") == blue: |
| blue_tops.append(top) |
| else: |
| red_tops.append(top) |
| |
| |
| |
| |
| base = 211.332 |
| full = 23.316 |
| correct_props = [(base - y) / (base - full) for y in blue_tops] |
| halluc_props = [1.0 - x for x in correct_props] |
| return { |
| "bars_extracted": len(correct_props), |
| "correct_proportion_min": min(correct_props), |
| "correct_proportion_max": max(correct_props), |
| "hallucinated_proportion_max": max(halluc_props), |
| "all_extracted_correct_above_half": all(x > 0.5 for x in correct_props), |
| "axis_calibration": { |
| "vector_baseline": base, |
| "vector_full_height": full, |
| "printed_axis_values": [0.0, 1.0], |
| "description": "paper figure y-axis labels 0.0 and 1.0", |
| }, |
| } |
|
|
|
|
| def arithmetic_checks() -> dict: |
| |
| precision, recall, reported_f1 = 92.50, 95.80, 94.10 |
| harmonic_f1 = 2 * precision * recall / (precision + recall) |
| |
| acc_gain = 90.00 - 84.46 |
| auroc_gain = 93.50 - 90.19 |
| |
| baseline_cs, halo_cs, diml_cs = 51.6, 17.6, 25.0 |
| halo_f1, diml_f1 = 75.2, 76.1 |
| |
| full_acc, no_attn_acc, no_balance_acc = 90.0, 84.4, 89.8 |
| |
| beta, n_beam, tau, l_beam = 0.1, 5, 0.5, 20 |
| return { |
| "claim_3": { |
| "halo_f1_recomputed": harmonic_f1, |
| "halo_f1_paper": reported_f1, |
| "f1_rounds_to_paper": round(harmonic_f1, 1) == reported_f1, |
| "accuracy_gain_over_diml": acc_gain, |
| "auroc_gain_over_diml": auroc_gain, |
| "over_five_accuracy_points": acc_gain > 5, |
| "over_three_auroc_points": auroc_gain > 3, |
| }, |
| "claim_4": { |
| "baseline_cs": baseline_cs, |
| "halo_cs": halo_cs, |
| "diml_cs": diml_cs, |
| "halo_minus_baseline": halo_cs - baseline_cs, |
| "halo_beats_diml_on_cs": halo_cs < diml_cs, |
| "halo_f1": halo_f1, |
| "diml_f1": diml_f1, |
| "halo_beats_diml_on_f1": halo_f1 > diml_f1, |
| }, |
| "claim_5": { |
| "full_accuracy": full_acc, |
| "no_attention_accuracy": no_attn_acc, |
| "no_attention_drop": full_acc - no_attn_acc, |
| "no_balance_accuracy": no_balance_acc, |
| "no_balance_drop": full_acc - no_balance_acc, |
| "no_attention_drop_positive": full_acc > no_attn_acc, |
| "no_balance_value_matches_table": no_balance_acc == 89.8, |
| }, |
| "claim_6": { |
| "n_beam": n_beam, |
| "tau": tau, |
| "beta": beta, |
| "l_beam": l_beam, |
| "tau_is_temperature_in_source": True, |
| }, |
| } |
|
|
|
|
| def main() -> None: |
| attention_svg = OUT / "avg_attn_vs_pos.svg" |
| class_svg = OUT / "class_proportion_by_position.svg" |
| result = { |
| "paper": { |
| "orid": "jGRDp7Moik", |
| "title": "HaloProbe: Bayesian Detection and Mitigation of Object Hallucinations in Vision-Language Models", |
| "openreview": "https://openreview.net/forum?id=jGRDp7Moik", |
| "arxiv": "https://arxiv.org/abs/2604.06165", |
| }, |
| "claims_registered": 6, |
| "figure_extraction": { |
| "claim_1": extract_attention(attention_svg), |
| "claim_2": extract_class_proportions(class_svg), |
| }, |
| "arithmetic": arithmetic_checks(), |
| "source_sha256": { |
| "avg_attn_vs_pos.svg": sha256(attention_svg), |
| "class_proportion_by_position.svg": sha256(class_svg), |
| }, |
| "verdicts": ["VERIFIED", "VERIFIED", "VERIFIED", "FALSIFIED", "VERIFIED", "VERIFIED"], |
| } |
| |
| |
| if result["claims_registered"] != 6 or len(result["verdicts"]) != 6: |
| raise SystemExit("expected exactly six registered claims and six verdicts") |
| if not result["figure_extraction"]["claim_1"]["marginal_correct_gt_hallucinated"]: |
| raise SystemExit("Claim 1 marginal ordering did not reverse as printed") |
| if not result["figure_extraction"]["claim_2"]["all_extracted_correct_above_half"]: |
| raise SystemExit("Claim 2 figure extraction did not show a correct-class majority") |
| if not result["arithmetic"]["claim_3"]["f1_rounds_to_paper"]: |
| raise SystemExit("Claim 3 F1 arithmetic disagrees with the printed table") |
| if not result["arithmetic"]["claim_4"]["halo_beats_diml_on_cs"]: |
| raise SystemExit("Claim 4 narrow C_s reduction is not present") |
| if result["arithmetic"]["claim_4"]["halo_beats_diml_on_f1"]: |
| raise SystemExit("Claim 4 broad superiority was not falsified by F1") |
| (OUT / "validation.json").write_text(json.dumps(result, indent=2) + "\n") |
| print(json.dumps(result, indent=2)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|