all tasks / pdf-reader / pdf-chart-reasoning
pdf-chart-reasoning · pdf-reader · ranked by score ↓
Share

📊 🧐 Which pdf parser does the best job? read pdf chart

runs
7
solutions
7
spent
$10

23 cases

Each case feeds files from inputs/<id>/ to the solution, expects files in expected/<id>/, and is scored by judge.py then aggregated by grader.py.

traptask.yaml · source on GitHub

cases (23)

case_01read length

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.B, "Distribution of participants' projections for the unemployment rate", how many participants project a 2026 rate in the 4.2-4.3 percent range? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "13",
  "capability": "read_length",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 13,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_02read length

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.A, "Distribution of participants' projections for the change in real GDP", how many participants project 2026 growth in the 2.2-2.3 percent range? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "9",
  "capability": "read_length",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 9,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_03read length

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.B, how many participants project a 2028 unemployment rate in the 4.2-4.3 percent range? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "12",
  "capability": "read_length",
  "difficulty": "medium",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 12,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_04read position

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 4.E, "Diffusion indexes of participants' risk weightings", what was the diffusion index for the change in real GDP at the June 2019 SEP? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "-0.832",
  "capability": "read_position",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": -0.832,
      "tolerance": 0.025
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_05read position

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 4.D, "Diffusion indexes of participants' uncertainty assessments", what was the diffusion index for PCE inflation at the June 2019 SEP? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "0.168",
  "capability": "read_position",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 0.168,
      "tolerance": 0.025
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_06read position

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 4.E, what was the diffusion index for PCE inflation at the June 2020 SEP? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "-0.773",
  "capability": "read_position",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": -0.773,
      "tolerance": 0.025
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_07derived value

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 4.E, by how much did the diffusion index for the change in real GDP move between the June 2019 SEP and the June 2026 SEP? Give the change, with its sign. End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "0.712",
  "capability": "derived_value",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 0.712,
      "tolerance": 0.05
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_08derived value

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.E, how many participants place the end-2027 midpoint of the appropriate target range at 3.88 percent or higher? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "8",
  "capability": "derived_value",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 8,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_09derived value

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.A, how many participants project 2028 GDP growth below 2.2 percent? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "7",
  "capability": "derived_value",
  "difficulty": "medium",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 7,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_10derived value

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.C, how many participants project 2028 PCE inflation above 2.0 percent? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "5",
  "capability": "derived_value",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 5,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_11count marks

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 2, "FOMC participants' assessments of appropriate monetary policy", how many participants judge the appropriate midpoint of the target range at the end of 2026 to be 3.625 percent? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "8",
  "capability": "count_marks",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 8,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_12count marks

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 2, how many participants judge the appropriate midpoint at the end of 2027 to be 3.875 percent? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "5",
  "capability": "count_marks",
  "difficulty": "medium",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 5,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_13count marks

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 2, how many distinct rate levels are occupied by at least one dot in the longer-run column? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "9",
  "capability": "count_marks",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 9,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_14semantic

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

Figure 1 shows the central tendency of participants' projections alongside their full range. For any one variable and year, how many participants' projections lie outside the central tendency? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "6",
  "capability": "semantic",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 6,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_15semantic

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

Figure 4.A shows a 70 percent confidence interval around the median projection for GDP growth. Does that interval describe how widely the participants' own projections are spread? Answer yes or no and say what it is based on. End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "No -- historical forecast errors of outside forecasters, 2006-2025",
  "capability": "semantic",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_regex",
      "pattern": "\\bno\\b|\\bnot\\b|does not|is not|rather than|instead of"
    },
    {
      "kind": "regex_required",
      "pattern": "historical|forecast error|root mean squared|past projection|private and government|outside forecaster"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_16semantic

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.C, every participant's longer-run projection for PCE inflation falls in a single percent range. Which range is it? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "1.9-2.0",
  "capability": "semantic",
  "difficulty": "medium",
  "matchers": [
    {
      "kind": "committed_regex",
      "pattern": "1\\.9\\s*[-‐-―]\\s*2\\.0"
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_17semantic

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

Figure 2 plots each participant's judgment for the end of 2026 rounded to the nearest eighth of a percentage point. Taking the dots as plotted, what is the median of those judgments? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "3.75",
  "capability": "semantic",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 3.75,
      "tolerance": 0.001
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_18abstention

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 3.D, how many participants put their longer-run projection for core PCE inflation in the 1.9-2.0 percent range? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "Not derivable -- longer-run core PCE projections are not collected",
  "capability": "abstention",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "not_derivable",
      "pattern": "not collected|no longer[- ]run|does not (?:have|include|show)|only .{0,20}three panels|not (?:gathered|solicited)"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_19abstention

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 2, which FOMC participant placed the single highest dot for the end of 2026? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "Not derivable -- the projections are anonymous",
  "capability": "abstention",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "not_derivable",
      "pattern": "anonym|not identif|does not identif|no names?\\b|not attribut|unattributed|cannot be determined|cannot be identified"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_20abstention

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

How many participants submitted a projection for 2028? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "17",
  "capability": "abstention",
  "difficulty": "medium",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 17,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_21cross figure

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

Figures 2 and 3.E describe the same judgments in two different chart types. How many participants place the end-2026 midpoint in the range figure 3.E labels 3.63-3.87? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "8",
  "capability": "cross_figure",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 8,
      "tolerance": 0
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_22cross figure

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

At the June 2020 SEP, the diffusion indexes for PCE inflation in figure 4.D (uncertainty) and figure 4.E (risk weightings) point opposite ways. How far apart were they? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "1.764",
  "capability": "cross_figure",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_value",
      "value": 1.764,
      "tolerance": 0.05
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

case_23cross figure

input

document.pdfbinary · 5.4 MBview on GitHub

question.txt

In figure 2's longer-run column, two participants sit at 3.375 percent. Which of figure 3.E's longer-run percent ranges contains them? End your reply with a line of the form `ANSWER: <value>`.

expected output

answer.json

{
  "answer": "3.38-3.62",
  "capability": "cross_figure",
  "difficulty": "hard",
  "matchers": [
    {
      "kind": "committed_regex",
      "pattern": "3\\.38\\s*[-‐-―]\\s*3\\.62"
    },
    {
      "kind": "no_hedge"
    }
  ]
}

Scored by judge.py — see Scoring logic below for the full rule.

scoring logic

judge.py runs once per case and prints a score per case. grader.py runs once at the end and folds case scores into a run-level summary. Without grader.py, the run's score is simply the average of case scores.

judge.py241 lines · view on GitHub
"""Per-case judge for pdf_chart_reading.

Every question in this task asks for a small integer -- a number of
participants, a number of ranges, a number of levels. That makes the anti-
shotgun problem acute in a way pdf_mixed_scan's six-digit figures never were:
an answer that lists a whole distribution contains the right small number by
accident, and "the last number wins" turns any trailing aside ("out of 18
participants") into a wrong answer.

So this judge does not guess which number the model meant. Each question ends
with `End your reply with a line of the form ANSWER: <value>`, and the judge
grades that line. The fallback, for a model that ignores the contract, is
lenient only when there is nothing to be lenient about: if the whole reply
contains at most three numbers there is no shotgun to catch, so the reply
itself is read as the answer. Beyond that it scores zero with the reason
stated, which is a format failure and is reported as one.

Matchers:
  committed_value  {"value": 9, "tolerance": 0}   first number of the committed answer
  committed_has_value {"value": 8}                 the figure appears in a compound answer
  committed_regex  {"pattern": "1\\.9\\s*-\\s*2\\.0"}  pattern over the committed answer
  regex_required   {"pattern": "..."}             pattern over the whole reply
  no_hedge         {}                             reject a visible punt
  not_derivable    {"pattern": "..."}             require a punt, WITH a stated reason
"""
from __future__ import annotations

import json
import os
import re
import sys
from pathlib import Path
from typing import Any

HEDGE_PHRASES = [
    "i cannot", "i can't", "i am unable", "i'm unable", "cannot be determined",
    "can't be determined", "cannot determine", "unable to determine", "not derivable",
    "cannot be derived", "not possible to determine", "does not identify",
    "is not identified", "not shown", "not reported", "no way to tell", "unknowable",
]

NUM = re.compile(r"[-−–(]?\s*\d[\d,]*(?:\.\d+)?\s*\)?")

# Numbers that are part of how the answer is phrased rather than the answer:
# the bin the bar sits in, the figure it came from, the year of the panel.
# "ANSWER: The 3.5-3.6 bar holds 9 participants" commits to 9, not to 3.5.
SCAFFOLDING = [
    re.compile(r"\d+\.\d+\s*(?:to|[-−–—‐―])\s*\d+\.\d+"),   # a bin label
    re.compile(r"\b(?:figure|fig\.?|table|panel|page)\s*\d+(?:\.\w+)?", re.IGNORECASE),
    re.compile(r"\b(?:19|20)\d{2}\b"),                          # a year
]


def strip_scaffolding(s: str) -> str:
    stripped = s
    for pat in SCAFFOLDING:
        stripped = pat.sub(" ", stripped)
    # Never strip an answer away entirely: a case whose answer is itself a year
    # or a range would otherwise be left with nothing to compare.
    return stripped if NUM.search(stripped) else s


def to_float(tok: str) -> float | None:
    t = tok.strip()
    neg = t.startswith(("-", "−", "–")) or (t.startswith("(") and t.endswith(")"))
    t = t.strip("()-−– ")
    # A comma groups thousands when the digits come in threes, else it is a decimal point.
    if "," in t:
        head, _, tail = t.rpartition(",")
        t = t.replace(",", "") if len(tail) == 3 and tail.isdigit() else t.replace(",", ".")
    try:
        v = float(t)
    except ValueError:
        return None
    return -v if neg else v


# "ANSWER: nine" is a committed answer too.
WORD_NUMBERS = {w: i for i, w in enumerate(
    "zero one two three four five six seven eight nine ten eleven twelve "
    "thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty".split())}


def numbers(s: str) -> list[float]:
    out = []
    for m in NUM.finditer(s):
        v = to_float(m.group())
        if v is not None:
            out.append(v)
    if not out:
        for word in re.findall(r"[a-z]+", s.lower()):
            if word in WORD_NUMBERS:
                out.append(float(WORD_NUMBERS[word]))
    return out


def strip_markup(s: str) -> str:
    return s.replace("**", "").replace("*", "").replace("`", "").strip(" .")


def committed_answer(reply: str) -> tuple[str | None, str]:
    """The value the model committed to, and how it was obtained."""
    hits = list(re.finditer(r"ANSWER\s*[::]\s*(.+)", reply, re.IGNORECASE))
    if hits:
        return strip_markup(hits[-1].group(1)), "answer line"
    if not reply.strip():
        return None, "the solution returned nothing"
    # No fallback. A lenient reading of an unformatted reply -- take the last
    # number, say -- silently picks the wrong one out of "the bar reaches 9, up
    # from 6 in March". Every question states the contract, so a reply without
    # it has not committed to anything.
    return None, "no ANSWER line"


def m_committed_value(reply: str, committed: str | None, spec: dict) -> tuple[bool, str]:
    if committed is None:
        return False, "no committed answer"
    got = numbers(strip_scaffolding(committed))
    if not got:
        return False, f"committed answer carries no number: {committed!r}"
    want, tol = float(spec["value"]), float(spec.get("tolerance", 0))
    ok = abs(got[0] - want) <= tol + 1e-9
    return ok, f"committed {got[0]:g}, expected {want:g}"


def m_committed_has_value(reply: str, committed: str | None, spec: dict) -> tuple[bool, str]:
    """For questions that ask for more than one thing.

    `committed_value` reads the first number, which is right when the answer IS
    a number and wrong the moment the question asks for a name as well: a
    correct "in the 2028 panel, 1.9-2.0 and 2.1-2.2 are tied at 8 each" leads
    with 2028. Here the figure only has to appear, and the accompanying
    regex matchers are what pin down the rest of the answer.
    """
    if committed is None:
        return False, "no committed answer"
    want, tol = float(spec["value"]), float(spec.get("tolerance", 0))
    got = numbers(strip_scaffolding(committed))
    ok = any(abs(g - want) <= tol + 1e-9 for g in got)
    return ok, f"expected {want:g} among {[f'{g:g}' for g in got]}"


def m_committed_regex(reply: str, committed: str | None, spec: dict) -> tuple[bool, str]:
    if committed is None:
        return False, "no committed answer"
    ok = bool(re.search(spec["pattern"], committed, re.IGNORECASE))
    return ok, f"pattern {spec['pattern']!r} against {committed!r}"


def m_regex_required(reply: str, committed: str | None, spec: dict) -> tuple[bool, str]:
    ok = bool(re.search(spec["pattern"], reply, re.IGNORECASE))
    return ok, f"pattern {spec['pattern']!r} {'found' if ok else 'absent'}"


def m_no_hedge(reply: str, committed: str | None, spec: dict) -> tuple[bool, str]:
    """Scoped to the committed answer, not the whole reply.

    The premise of this task is that the figure is not printed anywhere, so
    "the count is not shown numerically, but the bar reaches 9" is the natural
    shape of a CORRECT answer. Scanning the whole reply for "not shown" scored
    that zero. Once a model has committed a value, a caveat in the prose above
    it is not a punt.
    """
    low = (committed or "").lower()
    hit = next((p for p in HEDGE_PHRASES if p in low), None)
    return hit is None, f"hedge {hit!r} in the committed answer" if hit else "committed a value"


def m_not_derivable(reply: str, committed: str | None, spec: dict) -> tuple[bool, str]:
    """The reason IS the evidence of the refusal.

    Requiring a phrase from HEDGE_PHRASES as well rejected "the figure does not
    attribute dots to individuals, so no participant can be named" -- a refusal
    phrased as a statement about the document rather than about the model.
    """
    # The refusal has to be what the model committed to, not something it said
    # on the way to naming somebody. "The figure does not attribute dots to
    # individuals, but it was the Chair. ANSWER: the Chair" scored 1.0 before
    # this: the reason was present, and it was reciting it while inventing an
    # answer anyway. When there is an ANSWER line it is the only thing read;
    # without one, a reply that is nothing but the refusal still counts.
    # A pipeline that cannot see the figure at all declines too, and its answer
    # ("not stated in the document") is right by accident -- it is describing its
    # own blindness, not the chart. Requiring the reason is what separates the
    # two: the figure carries no identities, and only a reader of it can say so.
    if committed and re.search(spec["pattern"], committed, re.IGNORECASE):
        return True, "declined, naming why the figure cannot supply it"
    return False, ("the committed answer does not say why the figure cannot identify a "
                   "participant")


MATCHERS = {
    "committed_value": m_committed_value,
    "committed_has_value": m_committed_has_value,
    "committed_regex": m_committed_regex,
    "regex_required": m_regex_required,
    "no_hedge": m_no_hedge,
    "not_derivable": m_not_derivable,
}


def main() -> None:
    manifest = json.loads(os.environ["TRAPTASK_MANIFEST"])
    expected = json.loads((Path(manifest["expected_dir"]) / "answer.json").read_text())
    reply = Path(manifest["run"]["stdout"]).read_text().strip()

    committed, how = committed_answer(reply)
    details, score = [], 1.0
    if committed is None:
        # Uniform across matchers: nothing was committed, so nothing can pass.
        details = [{"kind": spec["kind"], "passed": False, "detail": how}
                   for spec in expected["matchers"]]
        score = 0.0
    for spec in (expected["matchers"] if committed is not None else []):
        fn = MATCHERS.get(spec["kind"])
        if fn is None:
            details.append({"kind": spec["kind"], "passed": False, "detail": "unknown matcher"})
            score = 0.0
            continue
        ok, detail = fn(reply, committed, spec)
        details.append({"kind": spec["kind"], "passed": ok, "detail": detail})
        if not ok:
            score = 0.0

    print(json.dumps({
        "score": score,
        "passed": score == 1.0,
        "category": expected.get("capability"),
        "difficulty": expected.get("difficulty"),
        "committed": committed,
        "committed_via": how,
        "reason": ("; ".join(f"{d['kind']}: {d['detail']}" for d in details if not d["passed"])
                   + ("" if committed is not None else f" ({how})")).strip()
                  or "all matchers passed",
        "matchers": details,
    }))


if __name__ == "__main__":
    sys.exit(main())
grader.py82 lines · view on GitHub
"""Overall grader for the pdf_chart_reading task.

Aggregates per-case judge results into a run-level verdict. Emits JSON to stdout —
trap stores it as GraderResult.metrics. Convention: include `passed` (bool) and
`score` (float) so the reporter can render them.

Pass threshold defaults to 80% accuracy; tweak below.
"""
from __future__ import annotations

import json
import os
from collections import Counter

PASS_THRESHOLD = 0.80


def main() -> None:
    cases = json.loads(os.environ["TRAPTASK_MANIFEST"])

    scored = [c for c in cases if c.get("metrics") and c["metrics"].get("score") is not None]
    skipped = [c for c in cases if not c.get("metrics") or c["metrics"].get("score") is None]

    if scored:
        accuracy = sum(c["metrics"]["score"] for c in scored) / len(scored)
    else:
        accuracy = 0.0

    # Break out accuracy by category (the judge tags each case with its category).
    by_category_score: Counter[str] = Counter()
    by_category_total: Counter[str] = Counter()
    for c in scored:
        cat = c["metrics"].get("category")
        if cat:
            by_category_total[cat] += 1
            by_category_score[cat] += c["metrics"]["score"]

    by_category_pct = {
        k: round(by_category_score[k] / by_category_total[k], 3)
        for k in by_category_total
    }

    passed = bool(scored) and accuracy >= PASS_THRESHOLD

    # Latency stats — trap records `duration` (seconds) per case. The leaderboard
    # displays median latency. Round-trip to ms for the JSON contract.
    durations = [c.get("duration", 0.0) for c in cases if c.get("duration") is not None]
    if durations:
        ds = sorted(durations)
        latency_ms_median = round(ds[len(ds) // 2] * 1000, 1)
        latency_ms_p95 = round(ds[int(0.95 * len(ds))] * 1000, 1) if len(ds) > 1 else latency_ms_median
        latency_ms_total = round(sum(ds) * 1000, 1)
    else:
        latency_ms_median = latency_ms_p95 = latency_ms_total = 0.0

    # Cost — sum per-case usd_cost if the solution captured usage; otherwise
    # leave it None and let the regrade/submit script stamp a known total.
    # trap records cost per case under "cost", not in the judge's metrics.
    case_costs = [(c.get("cost") or {}).get("cost_usd") for c in scored]
    cost_usd_total = round(sum(x for x in case_costs if x is not None), 4) if any(x is not None for x in case_costs) else None

    n_passed = sum(1 for c in scored if c["metrics"]["score"] == 1.0)

    print(json.dumps({
        "passed": passed,
        "score": round(accuracy, 3),
        "n_passed": n_passed,
        "n_total": len(cases),
        "n_scored": len(scored),
        "n_skipped_no_gold": len(skipped),
        "threshold": PASS_THRESHOLD,
        "by_category": by_category_pct,
        "latency_ms_median": latency_ms_median,
        "latency_ms_p95": latency_ms_p95,
        "latency_ms_total": latency_ms_total,
        "cost_usd_total": cost_usd_total,
    }))


if __name__ == "__main__":
    main()