{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://www.stablebet.co.uk/data/rating/stablebet-rating.schema.json",
  "title": "StableBet Rating: the published data contract",
  "description": "The one definition of the StableBet Rating data in the daily predictions file (/data/predictions/<date>.json). The model run on the server writes it (stablebet_agents/racing/form_rating.py and runner_stats.py); the racecard reads it (lib/schemas/stablebet-rating.ts). Tests on both sides validate against this file, so neither can drift from it. Only the rating's fields are pinned here: races and runners carry other model fields, which this schema leaves open. The rating is a reading aid published beside the model's output. It is priced in by the market and is never a tip. The system record is STABLEBET_RATING.md in the repository.",
  "type": "object",
  "required": ["date", "races"],
  "properties": {
    "date": { "$ref": "#/$defs/isoDate" },
    "stablebet_rating": { "$ref": "#/$defs/ratingSpec" },
    "races": { "type": "array", "items": { "$ref": "#/$defs/race" } }
  },
  "$defs": {
    "isoDate": {
      "type": "string",
      "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
    },
    "ratingSpec": {
      "description": "Which rule made this card's figures. Written once per day beside model_version. Absent on cards before 25 Sep 2026, which were all version 1.0. spec_sha256 is the SHA-256 of the canonical JSON (keys sorted, no spaces, whole numbers without a decimal point) of method, scale, parameters and handicap_rule, so any change to the numbers changes it.",
      "type": "object",
      "additionalProperties": false,
      "required": ["name", "version", "method", "scale", "parameters", "handicap_rule", "spec_sha256"],
      "properties": {
        "name": { "const": "StableBet Rating" },
        "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
        "method": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
        "scale": { "const": "lb", "description": "Pounds, on the official rating's scale, so a figure means the same in every race." },
        "parameters": {
          "type": "object",
          "minProperties": 1,
          "additionalProperties": { "type": "number" }
        },
        "handicap_rule": {
          "type": "string",
          "minLength": 1,
          "description": "The case-insensitive pattern a race name must match to count as a handicap. Version 1.0: handicap|nursery."
        },
        "spec_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
      }
    },
    "race": {
      "type": "object",
      "required": ["race_id", "runners"],
      "properties": {
        "race_id": { "type": "string", "minLength": 1 },
        "country": { "enum": ["GB", "IE", "GB-NIR"] },
        "is_handicap": {
          "type": "boolean",
          "description": "True when the race name matches the spec's handicap_rule. The rating's measured effect is in handicaps."
        },
        "race_type": {
          "type": ["string", "null"],
          "description": "The racing database's race type: Flat, Hurdle, Chase or NH Flat. Absent on cards before 25 Sep 2026."
        },
        "runners": { "type": "array", "items": { "$ref": "#/$defs/runner" } }
      }
    },
    "runner": {
      "type": "object",
      "required": ["horse"],
      "properties": {
        "horse": { "type": "string" },
        "form_rating": {
          "description": "The StableBet Rating for this runner. The key keeps its original name. Absent on cards before 24 Sep 2026; null when the rating step failed that morning.",
          "anyOf": [{ "$ref": "#/$defs/formRating" }, { "type": "null" }]
        },
        "stats": {
          "description": "The counts behind the rating in the racecard's expansion panel. Absent on cards before 24 Sep 2026; null when the stats step failed that morning.",
          "anyOf": [{ "$ref": "#/$defs/runnerStats" }, { "type": "null" }]
        }
      }
    },
    "formRating": {
      "description": "Today's official rating less 2lb for each length the horse was beaten on its last finished run, capped at 10 lengths, rounded to the nearest pound (a half rounds up). A figure and a reason are never both set: a runner with no figure always says why.",
      "type": "object",
      "additionalProperties": false,
      "required": ["value", "missing", "or_today", "last_run", "dnf_last"],
      "x-key-order": ["value", "missing", "or_today", "last_run", "dnf_last"],
      "properties": {
        "value": { "type": ["integer", "null"] },
        "missing": { "enum": ["no_previous_run", "no_official_rating", "no_finishing_distance", null] },
        "or_today": { "type": ["integer", "null"], "minimum": 1 },
        "last_run": { "anyOf": [{ "$ref": "#/$defs/lastRun" }, { "type": "null" }] },
        "dnf_last": {
          "type": "boolean",
          "description": "The most recent run was a fall, pull-up or other non-completion, so last_run is the finished run before it."
        }
      },
      "allOf": [
        {
          "if": { "properties": { "value": { "type": "integer" } } },
          "then": {
            "properties": {
              "missing": { "type": "null" },
              "or_today": { "type": "integer" },
              "last_run": { "$ref": "#/$defs/lastRun" }
            }
          },
          "else": { "properties": { "missing": { "type": "string" } } }
        }
      ]
    },
    "lastRun": {
      "type": "object",
      "additionalProperties": false,
      "required": ["date", "course", "position", "field_size", "beaten_lengths"],
      "x-key-order": ["date", "course", "position", "field_size", "beaten_lengths"],
      "properties": {
        "date": { "$ref": "#/$defs/isoDate" },
        "course": { "type": ["string", "null"] },
        "position": { "type": ["integer", "null"], "minimum": 1 },
        "field_size": { "type": ["integer", "null"], "minimum": 1 },
        "beaten_lengths": {
          "type": ["number", "null"],
          "minimum": 0,
          "description": "Lengths behind the winner before the 10-length cap. 0 for a win. null when a gap on the way down is unknown."
        }
      }
    },
    "record": {
      "description": "Wins from runs. A run includes a fall or a pull-up; non-runners and void races are not runs.",
      "type": "object",
      "additionalProperties": false,
      "required": ["runs", "wins"],
      "x-key-order": ["runs", "wins"],
      "properties": {
        "runs": { "type": "integer", "minimum": 0 },
        "wins": { "type": "integer", "minimum": 0 }
      }
    },
    "goingRecord": {
      "type": "object",
      "additionalProperties": false,
      "required": ["band", "matched_on", "runs", "wins"],
      "x-key-order": ["band", "matched_on", "runs", "wins"],
      "properties": {
        "band": { "enum": ["firm", "good to firm", "good", "good to soft", "soft", "heavy", "standard", "slow"] },
        "matched_on": { "const": "band" },
        "runs": { "type": "integer", "minimum": 0 },
        "wins": { "type": "integer", "minimum": 0 }
      }
    },
    "trainerCourseRecord": {
      "type": "object",
      "additionalProperties": false,
      "required": ["runs", "wins", "since"],
      "x-key-order": ["runs", "wins", "since"],
      "properties": {
        "runs": { "type": "integer", "minimum": 0 },
        "wins": { "type": "integer", "minimum": 0 },
        "since": { "$ref": "#/$defs/isoDate" }
      }
    },
    "drawRecord": {
      "description": "Flat only. Numbers, never a verdict: the racecard decides when a sample is big enough to say anything.",
      "type": "object",
      "additionalProperties": false,
      "required": ["stall", "runners", "stall_third", "cd_runs", "third_win_pct", "all_win_pct"],
      "x-key-order": ["stall", "runners", "stall_third", "cd_runs", "third_win_pct", "all_win_pct"],
      "properties": {
        "stall": { "type": "integer", "minimum": 1 },
        "runners": { "type": "integer", "minimum": 2 },
        "stall_third": { "enum": ["low", "middle", "high"] },
        "cd_runs": { "type": "integer", "minimum": 0 },
        "third_win_pct": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
        "all_win_pct": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }
      },
      "allOf": [
        {
          "if": { "properties": { "cd_runs": { "const": 0 } } },
          "then": { "properties": { "all_win_pct": { "type": "null" } } },
          "else": { "properties": { "all_win_pct": { "type": "number" } } }
        }
      ]
    },
    "runnerStats": {
      "description": "Descriptive counts from British and Irish runs since window_from (the first date the racing database holds), never a whole career by themselves. A block that cannot be computed is null.",
      "type": "object",
      "additionalProperties": false,
      "required": ["window_from", "first_run", "going", "course", "distance", "career", "trainer_14d", "trainer_course", "jockey_14d", "draw"],
      "x-key-order": ["window_from", "first_run", "going", "course", "distance", "career", "trainer_14d", "trainer_course", "jockey_14d", "draw"],
      "properties": {
        "window_from": { "$ref": "#/$defs/isoDate" },
        "first_run": { "anyOf": [{ "$ref": "#/$defs/isoDate" }, { "type": "null" }] },
        "going": { "anyOf": [{ "$ref": "#/$defs/goingRecord" }, { "type": "null" }] },
        "course": { "anyOf": [{ "$ref": "#/$defs/record" }, { "type": "null" }] },
        "distance": { "anyOf": [{ "$ref": "#/$defs/record" }, { "type": "null" }] },
        "career": { "anyOf": [{ "$ref": "#/$defs/record" }, { "type": "null" }] },
        "trainer_14d": { "anyOf": [{ "$ref": "#/$defs/record" }, { "type": "null" }] },
        "trainer_course": { "anyOf": [{ "$ref": "#/$defs/trainerCourseRecord" }, { "type": "null" }] },
        "jockey_14d": { "anyOf": [{ "$ref": "#/$defs/record" }, { "type": "null" }] },
        "draw": { "anyOf": [{ "$ref": "#/$defs/drawRecord" }, { "type": "null" }] }
      }
    }
  }
}
