Can your agent do date + time math?

ranked by score ↓
Source

Paste as source: in your trap.yaml

git+https://github.com/trapstreet/trapstreet-tasks@00d0632172c69e6f31c9ce26799ea34865e67930#subdirectory=tasks/core_date_arithmetic
Share

core-date-arithmetic

An open-source evaluation task for temporal arithmetic — the everyday "3 days from Tuesday" / "what year did X end" / "what time is it in Tokyo when it's 9am in NYC" computations that any scheduling, booking, calendar, or finance agent needs to get right.

21 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 (21)

add_subtract_01Date/time arithmetic (add_subtract)

input

question.txt

If the current date is May 22, 2023, what is the date 368 days from now? Return your answer as a JSON in the following format: JSON = {"explanation": <your step by step solution>, "date": "mm/dd/yyyy"}

expected output

answer.json

{
  "id": "add_subtract_01",
  "answer": "05/24/2024",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "05/24/2024"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "add_subtract",
  "difficulty": "medium",
  "source_full_label": "{'date': '05/24/2024'}"
}

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

add_subtract_02Date/time arithmetic (add_subtract)

input

question.txt

In a movie, the tower took exactly 20 years to construct. They started construction in 18-11-2005. What year was the tower ready in? Return your answer as a JSON in the following format JSON = {"explanation": <your step by step solution>, "answer": "mm/dd/yyyy"} if the question asks for a date, and in the following format JSON = {"answer": "yyyy"} if the question asks for a year.

expected output

answer.json

{
  "id": "add_subtract_02",
  "answer": "2025",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 2025,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "add_subtract",
  "difficulty": "medium",
  "source_full_label": "{'answer': '2025'}"
}

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

add_subtract_03Date/time arithmetic (add_subtract)

input

question.txt

In a movie, the tower took exactly 8 years to construct. They started construction in 23 February, 2005. What year was the tower ready in? Return your answer as a JSON in the following format JSON = {"explanation": <your step by step solution>, "answer": "mm/dd/yyyy"} if the question asks for a date, and in the following format JSON = {"answer": "yyyy"} if the question asks for a year.

expected output

answer.json

{
  "id": "add_subtract_03",
  "answer": "2013",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 2013,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "add_subtract",
  "difficulty": "medium",
  "source_full_label": "{'answer': '2013'}"
}

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

compare_01Date/time arithmetic (compare)

input

question.txt

Dates are separated by ";". 2005; 2004; 2013; 2021; 2007 were dates/years when they visited cities London, Miami, Cape Town, Beijing, Ankara respectively, in a movie. Which city did they visit last? The answer can have multiple values. Return your answer as a JSON with a field "unordered_list", which is a list of 1 or more entities (names, activities etc). Eg: JSON = {"explanation": <your step by step solution>, "unordered_list": ["entity1", "entity2", ...]}

expected output

answer.json

{
  "id": "compare_01",
  "answer": "['Beijing']",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "Beijing"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "compare",
  "difficulty": "medium",
  "source_full_label": "{'unordered_list': ['Beijing']}"
}

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

compare_02Date/time arithmetic (compare)

input

question.txt

In a movie, here are some activities Bob did. He was commuting at  11:38 PM. He also did some socializing and sleeping at 04:34:25 AM and 02 AM respectively. He was also meditating at 11:00 (24hr) and reading at 19:09:28 (24hr). Arrange the activities Bob did in descending order. If a time is at a lower granularity, assume the earliest value for the missing information. Eg: if the time is 2:40 PM, assume the complete time to be 2:40:00 PM. Format your answer as a JSON with a list of activities. Eg: answer = {"explanation": <your step by step solution>, "ordered_list": [activity1, activity2, ...]}

expected output

answer.json

{
  "id": "compare_02",
  "answer": "[\"['commuting', 'reading', 'meditating', 'socializing', 'sleeping']\"]",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "['commuting', 'reading', 'meditating', 'socializing', 'sleeping']"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "compare",
  "difficulty": "medium",
  "source_full_label": "{'ordered_list': ['commuting', 'reading', 'meditating', 'socializing', 'sleeping']}"
}

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

compare_03Date/time arithmetic (compare)

input

question.txt

If Kyle, Jenny, Joe, Lily, Emma have their exams at (times separated by ;):  04:00 (24hr); 22:00 (24hr); 18:00 (24hr); 05:57:17 AM; 01 PM respectively, arrange the persons in ascending order of their exam time. If a time is at a lower granularity, assume the earliest value for the missing information. Eg: if the time is 2:40 PM, assume the complete time to be 2:40:00 PM. Format your answer as a JSON with a list of names. Eg: answer = {"explanation": <your step by step solution>, "ordered_list": [name1, name2, ...]}

expected output

answer.json

{
  "id": "compare_03",
  "answer": "[\"['Kyle', 'Lily', 'Emma', 'Joe', 'Jenny']\"]",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "['Kyle', 'Lily', 'Emma', 'Joe', 'Jenny']"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "compare",
  "difficulty": "medium",
  "source_full_label": "{'ordered_list': ['Kyle', 'Lily', 'Emma', 'Joe', 'Jenny']}"
}

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

duration_01Date/time arithmetic (duration)

input

question.txt

Camila was born on 1999-Apr-04 and Samuel was born on 2007-Mar-22. How many days is Camila older than Samuel? Return your answer as a JSON like: JSON = {"explanation": <your step by step solution>, "age": <num_days>}. Do not include the units in the JSON value.

expected output

answer.json

{
  "id": "duration_01",
  "answer": "2909",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 2909,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "duration",
  "difficulty": "medium",
  "source_full_label": "{'answer': 2909}"
}

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

duration_02Date/time arithmetic (duration)

input

question.txt

I was exactly 362 days old on 2009-03-14. Compute my age in days on 2009-11-18. Return your answer as a JSON like: JSON = {"explanation": <your step by step solution>, "answer": <num_days>}. Do not include the units in the JSON value.

expected output

answer.json

{
  "id": "duration_02",
  "answer": "611",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 611,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "duration",
  "difficulty": "medium",
  "source_full_label": "{'answer': '611'}"
}

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

duration_03Date/time arithmetic (duration)

input

question.txt

Abigail and Jack were born on 2023-Dec-30 and 2024-Jun-29 respectively. When Jack was 202 days old, how old was Abigail in days? Return your answer as a JSON like: JSON = {"explanation": <your step by step solution>, "answer": <num_days>}. Do not include the units in the JSON value.

expected output

answer.json

{
  "id": "duration_03",
  "answer": "384",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 384,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "duration",
  "difficulty": "medium",
  "source_full_label": "{'answer': '384'}"
}

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

multi_op_01Date/time arithmetic (multi_op)

input

question.txt

Christina has bought a robot that helps with everyday tasks. It takes the robot 4:37:15 hours:minutes:seconds to take one lesson, on average. Christina wants the robot to take 7 lessons. It took the robot A hours, B minutes, and C seconds. Report the values of A, B and C as a json of the form {"explanation": <your step by step solution>, "A": A, "B": B, "C": C}.

expected output

answer.json

{
  "id": "multi_op_01",
  "answer": "['32', '20', '45']",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "32",
        "20",
        "45"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "multi_op",
  "difficulty": "hard",
  "source_full_label": "{\"A\": 32, \"B\": 20, \"C\": 45}"
}

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

multi_op_02Date/time arithmetic (multi_op)

input

question.txt

Calculate the time taken to play a solitaire game 24 times if Bob takes 16 minutes and 15 seconds to play a solitaire game. If the answer is X hours, Y minutes, and Z seconds,  Report the values of X, Y and Z as a json of the form {"explanation": <your step by step solution>, "X": X, "Y": Y, "Z": Z}.

expected output

answer.json

{
  "id": "multi_op_02",
  "answer": "['6', '30', '0']",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "6",
        "30",
        "0"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "multi_op",
  "difficulty": "hard",
  "source_full_label": "{\"X\": 6, \"Y\": 30, \"Z\": 0}"
}

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

multi_op_03Date/time arithmetic (multi_op)

input

question.txt

The average time taken to learn a magic trick is 14 minutes and 29 seconds. Calculate the time taken to learn 23 magic tricks at the same rate.  Report the answer in the form of a JSON {"explanation": <your step by step solution>, "X": X, "Y": Y, "Z": Z}, where the time taken is X hours, Y minutes, and Z seconds.

expected output

answer.json

{
  "id": "multi_op_03",
  "answer": "['5', '33', '7']",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "5",
        "33",
        "7"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "multi_op",
  "difficulty": "hard",
  "source_full_label": "{\"X\": 5, \"Y\": 33, \"Z\": 7}"
}

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

schedule_01Date/time arithmetic (schedule)

input

question.txt

Isabella, Asher, and Gabriel want to meet for half an hour. Isabella is available from 11 to 12:30 and also from 2:30 to 4, Asher is available from 2 to 2:30, and Gabriel is available from 1:30 to 3:30. The meeting has to start on the hour or half hour. How many possibilities are there for the meeting time? Format your answer as a JSON. Eg. JSON = {"explanation": <your step by step solution>, "answer":<number>}. Do not include units in the JSON value.

expected output

answer.json

{
  "id": "schedule_01",
  "answer": "0",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 0,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "schedule",
  "difficulty": "hard",
  "source_full_label": "{'answer': 0}"
}

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

schedule_02Date/time arithmetic (schedule)

input

question.txt

Sophia is available from 10 to 11 and also from 3:30 to 4:30. Clara is available from 11 to noon and also from 4:30 to 5. They want to have a 30 minute meeting. The meeting has to start on the hour or half hour. How many possibilities are there for the meeting time? Format your answer as a JSON. Eg. JSON = {"explanation": <your step by step solution>, "answer":<number>}. Do not include units in the JSON value.

expected output

answer.json

{
  "id": "schedule_02",
  "answer": "0",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 0,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "schedule",
  "difficulty": "hard",
  "source_full_label": "{'answer': 0}"
}

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

schedule_03Date/time arithmetic (schedule)

input

question.txt

Calculate the number of possibilities for a 30 minutes meeting that has to start on the hour or half hour. Mason and Liamwork from 9am to 5pm. Mason's booked: 9 to12:30,  4 to 5. 
Liam's booked:9:30 to noon, 2:30 to 4. Format your answer as a JSON. Eg. JSON = {"explanation": <your step by step solution>, "answer":<number>}. Do not include units in the JSON value.

expected output

answer.json

{
  "id": "schedule_03",
  "answer": "4",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "numeric",
      "value": 4,
      "tolerance": 0.5
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "schedule",
  "difficulty": "hard",
  "source_full_label": "{'answer': 4}"
}

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

trick_01Date/time arithmetic (trick)

input

question.txt

If it is Tuesday, what is the day 10 hours from now? Format your answer as a JSON. JSON = {'explanation': <your step by step solution>, 'answer': <your_answer>}. Do not include units in the answer. If the information provided is not enough to calculate the answer, or if there is more than one possible answer, reply {'answer': 'unanswerable'}.

expected output

answer.json

{
  "id": "trick_01",
  "answer": "unanswerable",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "unanswerable"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "trick",
  "difficulty": "hard",
  "source_full_label": "{'answer': 'unanswerable'}"
}

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

trick_02Date/time arithmetic (trick)

input

question.txt

The day after yesterday is 2008-Aug-15. What is the date 1599 days from now in the yyyy-mm-dd format? Format your answer as a JSON. JSON = {"explanation": <your step by step solution>, "answer": "yyyy-mm-dd"}.

expected output

answer.json

{
  "id": "trick_02",
  "answer": "2012-12-31",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "2012-12-31"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "trick",
  "difficulty": "hard",
  "source_full_label": "{'answer': '2012-12-31'}"
}

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

trick_03Date/time arithmetic (trick)

input

question.txt

What is the day 47 days from now in the yyyy-mm-dd format if the day before tomorrow is  2005-Oct-25? Format your answer as a JSON. JSON = {"explanation": <your step by step solution>, "answer": "yyyy-mm-dd"}.

expected output

answer.json

{
  "id": "trick_03",
  "answer": "2005-12-11",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "2005-12-11"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "trick",
  "difficulty": "hard",
  "source_full_label": "{'answer': '2005-12-11'}"
}

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

timezone_01Date/time arithmetic (timezone)

input

question.txt

Natalie lives in Location A (UTC +0000) and is going to call Zoe in Location B at 09:01 AM, A's time. What time is it for B, who lives in UTC (+0000), in their local time. Answer with time and day in json. Eg: JSON = {"explanation": <your step by step solution>, "day": "same_day/+x/-x", "time": "HH:MM:SS"}, where day=same_day if the answer is the same day, or +x/-x, where x is the number of days before/after the start day, and time is in 24-hour format. Assume standard time without daylight saving for all timezones. If seconds aren't specified, assume it to be 00. 

expected output

answer.json

{
  "id": "timezone_01",
  "answer": "09:01:00",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "09:01:00"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "timezone",
  "difficulty": "medium",
  "source_full_label": "{'day': 'same_day', 'time': '09:01:00'}"
}

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

timezone_02Date/time arithmetic (timezone)

input

question.txt

What is the time in Location A (-0800 PST) when the time in Location B is 10:00 (24hr) UTC (+0000). Answer with time and day in json. Eg: JSON = {"explanation": <your step by step solution>, "day": "same_day/+x/-x", "time": "HH:MM:SS"}, where day=same_day if the answer is the same day, or +x/-x, where x is the number of days before/after the start day, and time is in 24-hour format. Assume standard time without daylight saving for all timezones. If seconds aren't specified, assume it to be 00. 

expected output

answer.json

{
  "id": "timezone_02",
  "answer": "02:00:00",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "02:00:00"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "timezone",
  "difficulty": "medium",
  "source_full_label": "{'day': 'same_day', 'time': '02:00:00'}"
}

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

timezone_03Date/time arithmetic (timezone)

input

question.txt

Isabella lives in Location A (PST -0800) and is going to call William in Location B at 11:13:48 PM, A's time. What time is it for B, who lives in PST (-0800), in their local time. Answer with time and day in json. Eg: JSON = {"explanation": <your step by step solution>, "day": "same_day/+x/-x", "time": "HH:MM:SS"}, where day=same_day if the answer is the same day, or +x/-x, where x is the number of days before/after the start day, and time is in 24-hour format. Assume standard time without daylight saving for all timezones. If seconds aren't specified, assume it to be 00. 

expected output

answer.json

{
  "id": "timezone_03",
  "answer": "23:13:48",
  "type": "date_arithmetic",
  "matchers": [
    {
      "kind": "keywords_all",
      "values": [
        "23:13:48"
      ]
    },
    {
      "kind": "no_hedge"
    }
  ],
  "category": "timezone",
  "difficulty": "medium",
  "source_full_label": "{'day': 'same_day', 'time': '23:13:48'}"
}

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.py345 lines · view on GitHub
"""Per-case judge for the tenancy_agreement task — harsh by design.

Reads the agent's stdout (plain text OR JSON `{"answer": "..."}`) and applies
matchers declared in expected/{case_id}/answer.json. A case scores 1.0 only if
ALL matchers pass — partial credit is intentionally not offered. The whole
point of this task is to expose agents that hedge, miss clauses, or skip parts
of multi-part questions; lenient grading would defeat that.

Matcher kinds supported:
  - numeric          {"kind":"numeric","value":1234.5,"tolerance":0.01}
                     Passes if ANY number in the answer matches. Use for
                     show-your-working questions where the model walks
                     through arithmetic before stating the total.
  - leading_numeric  {"kind":"leading_numeric","value":1234.5,"tolerance":0.01}
                     The FIRST number in the answer must match. Use for
                     simple extraction questions where listing decoy
                     numbers should not count as a pass.
  - regex_required   {"kind":"regex_required","pattern":"...","flags":"i"}
                     Pattern must match (re.search). Default flags = i.
  - leading_word     {"kind":"leading_word","value":"yes"}
                     First alphanumeric token must equal value (case-insens),
                     after stripping common prefixes like "Answer:" or
                     markdown bold. Forces the model to commit, not hedge.
  - keywords_all     {"kind":"keywords_all","values":["a","b"]}
                     Every value must appear (case-insens substring).
  - keywords_any     {"kind":"keywords_any","values":["a","b"]}
                     At least one value must appear (case-insens substring).
  - keywords_any_word {"kind":"keywords_any_word","values":["ICE","BOE"]}
                     At least one value must appear as a whole word (\b...\b,
                     case-insens). Use for short acronyms that would
                     false-positive as substrings (ICE in "price", BOE in
                     "Boeing").
  - no_hedge         {"kind":"no_hedge"}
                     Reject answers that visibly punt the question, e.g.
                     "I cannot determine", "unclear from the document",
                     "I don't have access", "as an AI", etc.
  - min_words        {"kind":"min_words","value":5}
                     Reject one-word answers when the question asked for
                     reasoning/explanation.

Fallback (when no `matchers` provided):
  Substring match of `answer` (and any `accepted` variants) against the
  normalised agent output. Lenient but kept for cases that haven't been
  hardened yet (e.g. scenario_* cases without a curated gold).

Outputs JSON on stdout — trap stores it as CaseResult.metrics. The grader
reads `metrics.score` plus category/difficulty/reason for the report.
"""

from __future__ import annotations

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

HEDGE_PHRASES = [
    "i cannot", "i can't", "i am unable", "i'm unable",
    "i don't have access", "i do not have access",
    "as an ai", "as a language model",
    "cannot determine", "unable to determine",
    "unclear from the document", "not clear from the document",
    "i don't know", "i do not know",
    "insufficient information", "not enough information",
    "i'm not sure", "i am not sure",
]

NUMBER_RE = re.compile(r"-?\d[\d,]*(?:\.\d+)?")


def normalise(s: str) -> str:
    return re.sub(r"\s+", " ", s).strip().lower()


def extract_agent_answer(stdout: str) -> str:
    """Accept JSON {"answer": "..."} or plain text. Strip surrounding whitespace."""
    stdout = stdout.strip()
    if not stdout:
        return ""
    try:
        obj = json.loads(stdout)
        if isinstance(obj, dict) and "answer" in obj:
            return str(obj["answer"])
    except json.JSONDecodeError:
        pass
    return stdout


def parse_numeric(s: str) -> float | None:
    """Extract the first plausible number from `s`. £, $, commas, spaces stripped."""
    nums = parse_all_numerics(s)
    return nums[0] if nums else None


def parse_all_numerics(s: str) -> list[float]:
    """Extract ALL plausible numbers from `s`. Used to match agents that show
    working (e.g. "£1,950 × 12 + ... = £77,400" — we want to find 77400)."""
    if not s:
        return []
    cleaned = s.replace("£", "").replace("$", "").replace(",", "")
    out: list[float] = []
    for m in NUMBER_RE.finditer(cleaned):
        try:
            out.append(float(m.group(0).replace(",", "")))
        except ValueError:
            continue
    return out


_LEADING_LABEL_RE = re.compile(
    r"^\s*(?:answer|a|response|reply)[\s*_`]*:\s*", re.IGNORECASE,
)
_LEADING_NOISE_RE = re.compile(r"^[\s*_`#>\-]+")


def leading_word(s: str) -> str:
    """First alpha token, after stripping markdown noise and labels like
    "Answer:" / "**Answer**:" / "> ". Lets models prefix their commit with
    a natural label without auto-failing the case."""
    s = _LEADING_NOISE_RE.sub("", s)
    s = _LEADING_LABEL_RE.sub("", s)
    s = _LEADING_NOISE_RE.sub("", s)
    m = re.search(r"[a-zA-Z]+", s)
    return m.group(0).lower() if m else ""


# --- Matcher implementations ----------------------------------------------

def m_numeric(answer: str, spec: dict) -> tuple[bool, str]:
    """Pass if ANY number in the answer matches the target within tolerance.
    This lets models that show working ("1950 × 12 + 2100 × 12 = 77400") pass
    as long as the right number appears somewhere — exposing the actual answer
    is what matters, not whether the model led with it. For simple extraction
    where listing decoys should NOT pass, use `leading_numeric` instead."""
    nums = parse_all_numerics(answer)
    if not nums:
        return False, "no number found in answer"
    target = float(spec["value"])
    tol = float(spec.get("tolerance", 0.01))
    for n in nums:
        if abs(n - target) <= tol:
            return True, f"numeric ok (matched {n} of {nums} against target={target} tol={tol})"
    return False, f"numeric mismatch (numbers found={nums} target={target} tol={tol})"


def m_leading_numeric(answer: str, spec: dict) -> tuple[bool, str]:
    """First number in the answer must match within tolerance. Rejects
    decoy-number dumps like "rent 1950, deposit 2250, rent yr2 2100"
    where the target appears but isn't the committed answer."""
    nums = parse_all_numerics(answer)
    if not nums:
        return False, "no number found in answer"
    target = float(spec["value"])
    tol = float(spec.get("tolerance", 0.01))
    if abs(nums[0] - target) <= tol:
        return True, f"leading number ok ({nums[0]} == target {target} tol {tol})"
    return False, f"leading number {nums[0]} ≠ target {target} (other numbers in answer: {nums[1:]})"


def m_regex_required(answer: str, spec: dict) -> tuple[bool, str]:
    flags = 0
    if "i" in spec.get("flags", "i"):
        flags |= re.IGNORECASE
    if re.search(spec["pattern"], answer, flags):
        return True, f"regex matched"
    return False, f"regex {spec['pattern']!r} did not match"


def m_leading_word(answer: str, spec: dict) -> tuple[bool, str]:
    got = leading_word(answer)
    want = str(spec["value"]).lower()
    if got == want:
        return True, f"leading word ok ({got!r})"
    return False, f"leading word {got!r} ≠ required {want!r}"


def m_keywords_all(answer: str, spec: dict) -> tuple[bool, str]:
    norm = normalise(answer)
    missing = [v for v in spec["values"] if v.lower() not in norm]
    if missing:
        return False, f"missing required keyword(s): {missing}"
    return True, "all keywords present"


def m_keywords_any(answer: str, spec: dict) -> tuple[bool, str]:
    norm = normalise(answer)
    if any(v.lower() in norm for v in spec["values"]):
        return True, "at least one keyword present"
    return False, f"none of {spec['values']} present"


def m_keywords_any_word(answer: str, spec: dict) -> tuple[bool, str]:
    """Whole-word variant of keywords_any — wraps each value in \\b...\\b so
    short acronyms (ICE, BOE) don't false-match inside "price", "Boeing", etc."""
    for v in spec["values"]:
        if re.search(rf"\b{re.escape(v)}\b", answer, re.IGNORECASE):
            return True, f"whole-word match: {v!r}"
    return False, f"none of {spec['values']} matched as whole word"


def m_no_hedge(answer: str, spec: dict) -> tuple[bool, str]:
    norm = normalise(answer)
    for phrase in HEDGE_PHRASES:
        if phrase in norm:
            return False, f"hedge phrase detected: {phrase!r}"
    return True, "no hedge phrases"


def m_min_words(answer: str, spec: dict) -> tuple[bool, str]:
    count = len(re.findall(r"\S+", answer))
    want = int(spec["value"])
    if count >= want:
        return True, f"word count ok ({count} ≥ {want})"
    return False, f"too short ({count} < {want})"


MATCHERS = {
    "numeric": m_numeric,
    "leading_numeric": m_leading_numeric,
    "regex_required": m_regex_required,
    "leading_word": m_leading_word,
    "keywords_all": m_keywords_all,
    "keywords_any": m_keywords_any,
    "keywords_any_word": m_keywords_any_word,
    "no_hedge": m_no_hedge,
    "min_words": m_min_words,
}


def run_matchers(answer: str, matchers: list[dict]) -> tuple[float, list[dict]]:
    """Run all matchers; all must pass. Returns (score, per-matcher results)."""
    results = []
    all_ok = True
    for spec in matchers:
        kind = spec.get("kind")
        fn = MATCHERS.get(kind)
        if fn is None:
            results.append({"kind": kind, "pass": False, "reason": f"unknown matcher kind: {kind!r}"})
            all_ok = False
            continue
        ok, reason = fn(answer, spec)
        results.append({"kind": kind, "pass": ok, "reason": reason})
        if not ok:
            all_ok = False
    return (1.0 if all_ok else 0.0), results


def fallback_substring(answer: str, expected: dict) -> tuple[float, str]:
    """Lenient substring match when no matchers defined. Used for scenarios
    that don't have a curated gold yet — they shouldn't fail builds outright,
    but they also shouldn't claim a passing score from nothing."""
    targets = [t for t in [expected.get("answer"), *(expected.get("accepted") or [])] if t]
    if not targets:
        return 0.0, "no gold answer set (skip-equivalent)"
    norm = normalise(answer)
    hit = next((t for t in targets if normalise(t) in norm), None)
    if hit:
        return 1.0, f"substring match ({hit!r})"
    return 0.0, f"no substring match against {targets}"


# --- Main ------------------------------------------------------------------

def main() -> None:
    # trap-cli IO contract: TRAPTASK_MANIFEST carries directory + capture paths.
    m = json.loads(os.environ["TRAPTASK_MANIFEST"])

    stdout = Path(m["run"]["stdout"]).read_text()
    exit_code = json.loads(Path(m["run"]["meta"]).read_text())["exit_code"]
    expected = json.loads((Path(m["expected_dir"]) / "answer.json").read_text())

    agent_answer = extract_agent_answer(stdout)

    # Solution crashed → hard fail.
    if exit_code != 0:
        out: dict[str, Any] = {
            "score": 0.0,
            "reason": f"solution exited {exit_code}",
            "agent_answer": agent_answer,
            "id": expected.get("id"),
            "category": expected.get("category"),
            "difficulty": expected.get("difficulty"),
        }
        print(json.dumps(out))
        return

    # Empty stdout → hard fail (silently passing the test is the worst outcome).
    if not agent_answer:
        out = {
            "score": 0.0,
            "reason": "agent produced no answer",
            "agent_answer": "",
            "id": expected.get("id"),
            "category": expected.get("category"),
            "difficulty": expected.get("difficulty"),
        }
        print(json.dumps(out))
        return

    matchers = expected.get("matchers")
    if matchers:
        score, matcher_results = run_matchers(agent_answer, matchers)
        out = {
            "score": score,
            "matcher_results": matcher_results,
            "agent_answer": agent_answer,
            "expected_answer": expected.get("answer"),
            "id": expected.get("id"),
            "type": expected.get("type"),
            "category": expected.get("category"),
            "difficulty": expected.get("difficulty"),
        }
    else:
        score, reason = fallback_substring(agent_answer, expected)
        # If there's no gold and no matchers, surface score=None so the grader
        # can flag it as "not yet curated" rather than mark the agent failed.
        if expected.get("answer") is None:
            out = {
                "score": None,
                "reason": "no curated gold yet (case not gradeable)",
                "agent_answer": agent_answer,
                "id": expected.get("id"),
                "type": expected.get("type"),
                "category": expected.get("category"),
                "difficulty": expected.get("difficulty"),
            }
        else:
            out = {
                "score": score,
                "reason": reason,
                "agent_answer": agent_answer,
                "expected_answer": expected.get("answer"),
                "id": expected.get("id"),
                "type": expected.get("type"),
                "category": expected.get("category"),
                "difficulty": expected.get("difficulty"),
            }

    print(json.dumps(out))


if __name__ == "__main__":
    main()
grader.py86 lines · view on GitHub
"""Overall grader for the tenancy_agreement 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:
    # trap-cli passes the list of per-case results directly (case_id, exit_code,
    # duration, metrics, cost).
    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 — trap-cli's proxy records a per-case `cost` object {cost_usd, by_model, ...}.
    case_costs = [
        c["cost"]["cost_usd"]
        for c in cases
        if isinstance(c.get("cost"), dict) and c["cost"].get("cost_usd") is not None
    ]
    cost_usd_total = round(sum(case_costs), 4) if 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()