{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://torneo.ai/schemas/match.v1.json",
  "title": "TORNEO match result, v1 (paired comparison of exactly two tools, on several dimensions)",
  "description": "Un match oppose DEUX outils sur le même corpus gelé, item par item (TORNEO-009 §3). Il ne mesure pas l'exactitude seule: il mesure tout ce qui différencie un outil d'un autre, dimension par dimension, chacune avec son test apparié préenregistré. Le verdict d'une dimension vient d'un test apparié, jamais d'un recouvrement d'intervalles marginaux, qui n'est pas un test de différence. « Aucune différence mesurée » est un résultat de première classe, publié comme tel et non comme un silence.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "schema_version",
    "match_id",
    "epreuve_id",
    "category",
    "tools",
    "unit_count",
    "preregistration",
    "dimensions",
    "multiplicity",
    "multiplicity_family",
    "alpha",
    "observed_at",
    "evidence_uri"
  ],
  "properties": {
    "schema_version": { "const": "match.v1" },
    "match_id": { "type": "string", "pattern": "^[A-Z0-9][A-Z0-9-]*$" },
    "epreuve_id": {
      "type": "string",
      "pattern": "^[A-Z0-9][A-Z0-9-]*$",
      "description": "Un match n'existe jamais seul: il appartient à un tour, et c'est ce tour qui porte la correction de multiplicité."
    },
    "category": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
    "tools": {
      "type": "array",
      "minItems": 2,
      "maxItems": 2,
      "uniqueItems": true,
      "items": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" },
      "description": "Exactement deux outils, dans l'ordre A puis B. Toutes les statistiques du match sont orientées A contre B."
    },
    "unit_count": {
      "type": "integer",
      "minimum": 1,
      "description": "Nombre d'unités du corpus gelé sur lesquelles LES DEUX outils ont été observés."
    },
    "preregistration": {
      "type": "object",
      "additionalProperties": false,
      "required": ["document", "sha256", "frozen_before_collection"],
      "properties": {
        "document": { "type": "string", "minLength": 1 },
        "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
        "frozen_before_collection": {
          "const": true,
          "description": "Toujours true, et c'est le point: un plan figé APRÈS la collecte n'est pas un préenregistrement."
        },
        "frozen_at": { "$ref": "#/$defs/utc_datetime" }
      }
    },
    "dimensions": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/dimension" },
      "description": "Une entrée par dimension mesurée ou déclarée non mesurable. Chacune porte son test, son seuil et son verdict: un outil peut gagner en fiabilité et perdre en coût, et le dire est plus utile qu'une note unique."
    },
    "multiplicity": { "enum": ["holm", "bonferroni", "none"] },
    "multiplicity_family": {
      "enum": ["per_dimension", "whole_epreuve"],
      "description": "Sur quoi porte la correction. per_dimension: les k(k-1)/2 matchs d'UNE dimension forment une famille, parce qu'un acheteur lit chaque dimension comme une question distincte. whole_epreuve: toutes les dimensions de tous les matchs. Le choix est préenregistré, jamais fait après lecture, et le contrôleur rejoue celui qui est déclaré."
    },
    "alpha": { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
    "observed_at": { "$ref": "#/$defs/utc_datetime" },
    "corpus_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
    "evidence_uri": {
      "type": "string",
      "minLength": 1,
      "description": "Le bundle d'où sortent les observations appariées, pour qu'un tiers rejoue le match."
    },
    "notes": { "type": "string" }
  },
  "$defs": {
    "utc_datetime": {
      "type": "string",
      "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$"
    },
    "dimension": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name", "measured", "verdict"],
      "properties": {
        "name": {
          "enum": ["quality", "latency", "cost", "reliability", "energy"],
          "description": "Vocabulaire fermé. quality: la justesse de la sortie. latency: le temps par unité. cost: le coût par unité. reliability: les échecs de l'outil, dont les échecs d'API. energy: la consommation, qu'une API hébergée n'expose pas."
        },
        "measured": {
          "type": "boolean",
          "description": "False quand la dimension n'est pas mesurable dans cette épreuve. Ce n'est pas un trou: c'est une donnée, et elle se publie avec sa raison."
        },
        "not_measured_reason": {
          "type": "string",
          "minLength": 1,
          "description": "Obligatoire quand measured est false. « Le fournisseur n'expose pas sa consommation » est une raison; l'absence de mention n'en est pas une."
        },
        "measurable_proxies": {
          "type": "array",
          "items": { "type": "string", "minLength": 1 },
          "description": "Ce qui EST mesurable à défaut de la grandeur elle-même (jetons consommés, durée). Un substitut se nomme comme substitut; il ne se publie jamais comme la grandeur qu'il approche."
        },
        "measurement_source": {
          "type": "string",
          "minLength": 1,
          "description": "D'où vient la mesure quand elle existe. Exigé pour energy: un chiffre d'énergie sans instrument nommé est un chiffre inventé, et le contrôleur le refuse."
        },
        "lower_is_better": {
          "type": "boolean",
          "description": "Sens de la dimension. Sans lui, un lecteur lit le signe à l'envers, et il le fera."
        },
        "outcome_kind": { "enum": ["binary", "continuous"] },
        "effect_unit": {
          "type": "string",
          "minLength": 1,
          "description": "L'unité de mean_difference et de l'intervalle (« secondes par unité », « USD par unité »). Exigée dès qu'une marge d'équivalence est déclarée: sans elle, rien n'oblige la marge et l'intervalle à parler de la même grandeur."
        },
        "difference_within_margin": {
          "type": "boolean",
          "description": "Sur une dimension DÉPARTAGÉE qui porte quand même une marge: l'écart établi tient-il sous la marge utile? Un écart significatif mais négligeable est une information d'achat, et la refuser obligerait à choisir entre « il y a une différence » et « elle ne change rien ». Le contrôleur recalcule ce booléen depuis l'intervalle."
        },
        "test": { "enum": ["mcnemar_exact", "paired_bootstrap"] },
        "test_params": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "resamples": { "type": "integer", "minimum": 1000 },
            "seed": { "type": ["integer", "string"] },
            "resample_unit": { "type": "string" }
          }
        },
        "statistic": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "discordant_favouring_a": { "type": "integer", "minimum": 0 },
            "discordant_favouring_b": { "type": "integer", "minimum": 0 },
            "concordant_both": { "type": "integer", "minimum": 0 },
            "concordant_neither": { "type": "integer", "minimum": 0 },
            "mean_difference": {
              "type": "number",
              "description": "Convention fixe: moyenne(A) moins moyenne(B). Le signe décide donc du vainqueur avec lower_is_better, et le contrôleur refuse un vainqueur qui contredit son propre effet."
            },
            "ci95_low": { "type": "number" },
            "ci95_high": { "type": "number" }
          }
        },
        "p_raw": { "type": "number", "minimum": 0, "maximum": 1 },
        "p_adjusted": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "C'est LUI qui décide, jamais p_raw."
        },
        "verdict": {
          "enum": ["A", "B", "draw", "not_measured"],
          "description": "draw signifie « aucune différence mesurée sur cette dimension », et c'est un résultat, pas une absence de résultat. not_measured signifie que la dimension n'a pas pu être mesurée du tout: les deux se publient, et ils ne veulent pas dire la même chose."
        },
        "draw_kind": {
          "enum": ["insufficient_precision", "equivalent_within_margin"],
          "description": "D3: un nul n'est pas une équivalence. insufficient_precision: le test n'a pas séparé les deux outils, et avec plus d'unités il le ferait peut-être. equivalent_within_margin: la différence est bornée sous une marge jugée sans conséquence pour un acheteur, marge FIXÉE AVANT l'observation. Les deux empêchent de nommer un vainqueur; elles ne disent pas la même chose, et les confondre transforme un manque de données en promesse d'équivalence."
        },
        "equivalence_margin": {
          "type": "object",
          "additionalProperties": false,
          "required": ["value", "unit", "preregistered", "frozen_at", "justification"],
          "properties": {
            "value": { "type": "number", "exclusiveMinimum": 0 },
            "unit": { "type": "string", "minLength": 1 },
            "preregistered": {
              "const": true,
              "description": "Toujours true. Une marge choisie après avoir vu l'écart est un écart habillé en équivalence."
            },
            "frozen_at": { "$ref": "#/$defs/utc_datetime" },
            "justification": {
              "type": "string",
              "minLength": 1,
              "description": "Pourquoi cet écart est sans conséquence pour l'usage visé. Sans elle, une marge assez grande rend n'importe quoi équivalent, et la marge devient l'outil de la conclusion au lieu de sa limite."
            }
          },
          "description": "L'intervalle de confiance apparié doit tenir entièrement dans [-value, +value] pour qu'une équivalence soit déclarée. L'unité doit être celle de effect_unit: une marge en millisecondes contre un intervalle en secondes est une équivalence obtenue par un changement d'unité."
        },
        "winner": { "type": ["string", "null"] }
      },
      "allOf": [
        {
          "if": { "properties": { "measured": { "const": false } }, "required": ["measured"] },
          "then": {
            "required": ["not_measured_reason"],
            "properties": {
              "verdict": { "const": "not_measured" },
              "winner": { "const": null },
              "p_raw": false,
              "p_adjusted": false,
              "statistic": false
            },
            "description": "Une dimension non mesurée ne porte ni p ni statistique: publier un chiffre sur une grandeur qu'on n'a pas mesurée est exactement ce que cette contrainte interdit."
          }
        },
        {
          "if": { "properties": { "measured": { "const": true } }, "required": ["measured"] },
          "then": {
            "required": ["outcome_kind", "test", "statistic", "p_raw", "p_adjusted", "lower_is_better"],
            "properties": { "verdict": { "enum": ["A", "B", "draw"] } }
          }
        },
        {
          "if": {
            "properties": { "name": { "const": "energy" }, "measured": { "const": true } },
            "required": ["name", "measured"]
          },
          "then": {
            "required": ["measurement_source"],
            "description": "Une API hébergée n'expose pas sa consommation. Si energy est déclarée mesurée, l'instrument doit être nommé."
          }
        },
        {
          "if": { "properties": { "verdict": { "const": "draw" } }, "required": ["verdict"] },
          "then": { "properties": { "winner": { "const": null } } }
        },
        {
          "if": { "properties": { "verdict": { "enum": ["A", "B"] } }, "required": ["verdict"] },
          "then": { "required": ["winner"], "properties": { "winner": { "type": "string", "minLength": 1 } } }
        },
        {
          "if": { "properties": { "test": { "const": "paired_bootstrap" } }, "required": ["test"] },
          "then": {
            "required": ["test_params"],
            "properties": { "test_params": { "required": ["resamples", "seed"] } }
          }
        },
        {
          "if": { "properties": { "outcome_kind": { "const": "binary" } }, "required": ["outcome_kind"] },
          "then": { "properties": { "test": { "const": "mcnemar_exact" } } }
        },
        {
          "if": { "properties": { "outcome_kind": { "const": "continuous" } }, "required": ["outcome_kind"] },
          "then": { "properties": { "test": { "const": "paired_bootstrap" } } }
        },
        {
          "if": { "properties": { "verdict": { "const": "draw" } }, "required": ["verdict"] },
          "then": {
            "required": ["draw_kind"],
            "description": "Publier « nul » sans dire de quelle sorte laisse le lecteur choisir la lecture qui l'arrange."
          }
        },
        {
          "if": { "properties": { "verdict": { "enum": ["A", "B", "not_measured"] } }, "required": ["verdict"] },
          "then": {
            "properties": { "draw_kind": false },
            "description": "draw_kind n'a de sens que sur un nul. La marge, elle, reste possible sur une dimension départagée: c'est ainsi qu'on publie « la différence est établie, et elle est négligeable »."
          }
        },
        {
          "if": { "properties": { "draw_kind": { "const": "equivalent_within_margin" } }, "required": ["draw_kind"] },
          "then": {
            "required": ["equivalence_margin"],
            "description": "Une équivalence se déclare CONTRE une marge nommée, jamais dans l'absolu."
          }
        },
        {
          "if": { "properties": { "draw_kind": { "const": "insufficient_precision" } }, "required": ["draw_kind"] },
          "then": { "properties": { "equivalence_margin": false } }
        },
        {
          "if": { "required": ["equivalence_margin"] },
          "then": {
            "required": ["effect_unit"],
            "description": "Une marge sans unité de l'effet ne se compare à rien."
          }
        },
        {
          "if": { "required": ["difference_within_margin"] },
          "then": {
            "required": ["equivalence_margin"],
            "properties": { "verdict": { "enum": ["A", "B"] } }
          }
        }
      ]
    }
  }
}
