8000 Assert format: date-time on runs and 1.0 runs[]: a non-RFC 3339 started_at / ended_at is a schema error by ynishi · Pull Request #52 · ynishi/evalhub · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"path": "/runs/0/started_at",
"code": "schema"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"schema": "evalhub.eval/1.0",
"title": "single2 K4, 33 runs",
"producer": {
"name": "my-harness",
"version": "0.4.1"
},
"eval_kind": "run_set",
"origin": "live",
"harness": {
"name": "my-harness",
"version": "0.4.1"
},
"model": {
"id": "qwen3.6-32b"
},
"task": {
"id": "single2",
"version": "3",
"split": "test",
"n": 33
},
"generation": {
"temperature": 0.0
},
"trial": {
"k": 4
},
"env": {
"git": {
"origin": "https://github.com/x/y",
"commit": "abc123",
"dirty": false
}
},
"runs": [
{
"run_id": "r1",
"started_at": "yesterday",
"ended_at": "2026-09-20T10:02:11Z",
"outcome": "pass",
"calls": "calls/r1.jsonl",
"artifacts": [
"artifacts/r1/diff.patch"
]
}
],
"relations": [
{
"type": "core/subset_of",
"to": "alice/single2-full@1"
}
],
"attachments": [
{
"path": "calls/r1.jsonl",
"sha256": "a8b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6",
"size": 90210,
"media_type": "application/x-ndjson"
},
{
"path": "artifacts/r1/diff.patch",
"sha256": "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0",
"size": 3311,
"media_type": "text/x-diff"
}
],
"redaction": {
"applied": false
},
"ext": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"path": "/ended_at",
"code": "schema"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"run_id": "r1",
"body": {
"run_id": "r1",
"status": "ok",
"started_at": "2026-09-20T10:00:00+09:00",
"ended_at": "2026-09-20"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"path": "/started_at",
"code": "schema"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"run_id": "r1",
"body": {
"run_id": "r1",
"status": "ok",
"started_at": "yesterday",
"ended_at": "2026-09-20T10:02:11Z"
}
}
32 changes: 32 additions & 0 deletions crates/evalhub-core/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@
//! `$ref` in the schema is local (`#/$defs/…`), and a reference that would
//! need fetching is a build error, not a fetch.
//!
//! ## Formats
//!
//! `format` is an assertion. Draft 2020-12 makes it an annotation unless
//! the validator opts in, and the validators opt in, so a value the served
//! schema marks `"format": "date-time"` must be an RFC 3339 date-time, or
//! it is a `schema` error at its path. The fields that carry it are a run's
//! `started_at` and `ended_at` (`schemas/run.json`), and the same fields on
//! `runs[]` of an Eval 1.0 body (`schemas/eval.json`). Card and Eval 2.0
//! have no `date-time` field.
//!
//! The check is the `jsonschema` crate's `date-time`: a `T` (or `t`)
//! between date and time, seconds present, and a `Z` or `±hh:mm` offset. A
//! value that passes it also parses with `DateTime::parse_from_rfc3339`,
//! which is what fills the store's `runs.started_at` / `ended_at` columns,
//! so an accepted run with a timestamp always has the column. The
//! separator between date and time must be `T` (or `t`), as RFC 3339 §5.6
//! has it; a space is refused, although `parse_from_rfc3339` would parse
//! it.
//!
//! The other `format` values in the generated schemas are the numeric
//! hints schemars emits for Rust integer and float types (`double`,
//! `int64`, `uint32`, `uint64`). They are not formats the crate knows, and
//! unknown formats are ignored, so they stay annotations; `type` and
//! `minimum` already carry what they say.
//!
//! # The two Eval arms
//!
//! Release 0.2.0 accepts two majors of the Eval schema
Expand Down Expand Up @@ -306,10 +331,17 @@ fn sort(errors: &mut [ErrorEntry]) {
/// Compile a generated schema. The generated schemas only reference
/// `#/$defs/*`; `offline()` turns any other reference into a build failure
/// here, which the unit tests exercise, rather than a fetch in production.
///
/// `format` is asserted, not annotated (see "Formats" in the module doc).
/// Unknown formats stay ignored: that is the crate's default, and it is set
/// here explicitly because schemars emits numeric hints (`double`, `int64`,
/// `uint32`, `uint64`) that are not formats the crate knows.
fn compile(schema: &Value) -> Validator {
#[allow(clippy::expect_used)]
jsonschema::options()
.with_draft(Draft::Draft202012)
.should_validate_formats(true)
.should_ignore_unknown_formats(true)
.offline()
.build(schema)
.expect("the generated schema compiles")
Expand Down
59 changes: 59 additions & 0 deletions crates/evalhub-core/tests/validate.rs
< 8 td class 10BC8 ="blob-num blob-num-addition empty-cell">
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,62 @@ fn a_run_integer_outside_2_53_is_number_too_large() {
assert_eq!(errors[0].code, ErrorCode::NumberTooLarge);
assert_eq!(errors[0].path, "/metrics/core~1tokens_out");
}

#[test]
fn a_date_time_that_is_not_rfc_3339_is_a_schema_error_at_its_path() {
let cases = [
(
"run started_at",
validate::run(
"r1",
&json!({"run_id": "r1", "status": "ok", "started_at": "yesterday"}),
),
"/started_at",
),
(
"run ended_at",
validate::run(
"r1",
&json!({"run_id": "r1", "status": "ok", "ended_at": "2026-09-20"}),
),
"/ended_at",
),
(
"run started_at with a space for the T",
validate::run(
"r1",
&json!({"run_id": "r1", "status": "ok", "started_at": "2026-09-20 10:00:00Z"}),
),
"/started_at",
),
(
"eval 1.0 runs[].started_at",
validate(RecordKind::Eval, &read("eval-v1-started-at-invalid.json")),
"/runs/0/started_at",
),
];
let mut wrong = Vec::new();
for (what, errors, path) in cases {
let got: Vec<_> = errors.iter().map(|e| (e.path.as_str(), e.code)).collect();
if got != [(path, ErrorCode::Schema)] {
wrong.push(format!("{what}: got {got:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}

#[test]
fn an_rfc_3339_date_time_with_an_offset_or_a_fraction_is_valid() {
for t in [
"2026-09-20T10:00:00Z",
"2026-09-20T10:00:00.123456Z",
"2026-09-20T19:00:00+09:00",
"2026-09-20t10:00:00z",
] {
let errors = validate::run(
"r1",
&json!({"run_id": "r1", "status": "ok", "started_at": t}),
);
assert!(errors.is_empty(), "{t}: {errors:?}");
}
}
74 changes: 74 additions & 0 deletions crates/evalhub-server/tests/runs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,80 @@ async fn an_ext_key_without_a_namespace_is_refused_on_records_and_runs() {
assert_eq!(body["errors"][0]["path"], "/ext/notes", "{body}");
}

/// A timestamp the schema marks `date-time` that is not RFC 3339 is
/// `422 schema` at its path: on `PUT …/runs/{id}`, in a batch, and in the
/// `runs[]` of a 1.0 body on `POST /evals/…`. Nothing is written.
#[tokio::test]
async fn a_timestamp_that_is_not_rfc_3339_is_a_schema_error() {
let (hub, alice) = hub_with_eval(Hub::start().await).await;
let at = |body: &Value| -> Vec<(String, String)> {
body["errors"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e["path"].as_str().unwrap().to_string(),
e["code"].as_str().unwrap().to_string(),
)
})
.collect()
};
let &str| vec![(path.to_string(), "schema".to_string())];

for (key, bad) in [("started_at", "yesterday"), ("ended_at", "2026-09-20")] {
let mut run = json!({"run_id": "r1", "status": "ok"});
run[key] = json!(bad);
let (status, body) = hub
.call(
Method::PUT,
&format!("/api/v1/evals/{EVAL_NAME}/runs/r1"),
Some(&alice),
Some(&run.to_string()),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(at(&body), one(&format!("/{key}")), "{body}");
}

let batch = json!({"runs": [
{"run_id": "b1", "status": "ok", "started_at": "2026-09-20T10:00:00Z"},
{"run_id": "b2", "status": "ok", "started_at": "yesterday"},
]});
let (status, body) = hub
.call(
Method::POST,
&format!("/api/v1/evals/{EVAL_NAME}/runs:batch"),
Some(&alice),
Some(&batch.to_string()),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(at(&body), one("/runs/1/started_at"), "{body}");

let (_, page) = get(
&hub,
&format!("/api/v1/evals/{EVAL_NAME}/runs"),
Some(&alice),
)
.await;
assert_eq!(page["items"], json!([]), "nothing was written: {page}");

hub.ready_attachments(common::EVAL_V1).await;
let mut v1: Value = serde_json::from_str(common::EVAL_V1).unwrap();
v1["runs"][0]["started_at"] = json!("yesterday");
let (status, body) = hub
.call(
Method::POST,
"/api/v1/evals/alice/legacy-bad-time",
Some(&alice),
Some(&v1.to_string()),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(at(&body), one("/runs/0/started_at"), "{body}");
}

/// A batch with invalid elements writes nothing and lists every failing
/// element by index; a valid batch writes all.
#[tokio::test]
Expand Down
21 changes: 15 additions & 6 deletions crates/evalhub-store/src/runs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,21 @@
//! # Timestamps
//!
//! `started_at` and `ended_at` are copied from the body into
//! `timestamptz` columns for filtering and sorting. A body value that is
//! not an RFC 3339 date-time is accepted (the JSON Schema declares
//! `format: date-time`, but Draft 2020-12 formats are annotations, not
//! assertions, in the core validator), kept verbatim in the body, and
//! leaves the column NULL, so the run sorts and filters as one with no
//! timestamp. The content hash covers the string as sent.
//! `timestamptz` columns for filtering and sorting. The core validator
//! asserts the schema's `format: date-time`, so a body value that is not
//! an RFC 3339 date-time is refused in step 2 with `schema` at
//! `/started_at` or `/ended_at`, and nothing is written. A value that
//! passes also parses with `DateTime::parse_from_rfc3339`, so for an
//! accepted run the column is NULL exactly when the body has no value
//! (the key is absent or `null`). The body keeps the string as sent, and
//! the content hash covers it. The separator between date and time must
//! be `T` (or `t`), as RFC 3339 §5.6 has it; a space is refused, although
//! `parse_from_rfc3339` would parse it, so a body 0.3.0 accepted with a
//! space is now a `schema` error.
//!
//! Runs stored by 0.3.0 or earlier were not checked, and may keep a
//! non-RFC 3339 string with a NULL column. Reads do not re-validate, so
//! they are served as stored; writing such a body again is refused.
//!
//! # Materialisation
//!
Expand Down
Loading
Loading
0