openapi: 3.0.3
info:
  title: MAIVE Public Model API
  version: "1.0.0"
  description: |
    Public, anonymous HTTP API for running MAIVE / WAIVE / WLS and RTMA
    meta-analysis models programmatically, without going through the MAIVE UI.

    This is the `/v1` contract described in
    [`docs/PUBLIC_API_DESIGN.md`](../PUBLIC_API_DESIGN.md) (§6). See
    [`docs/PUBLIC_API.md`](../PUBLIC_API.md) for a usage guide with copy-paste
    curl / R / Python examples.

    **Status:** live. `https://api.maive.eu` serves this contract, and
    serves this file at `https://api.maive.eu/openapi.yaml`. Assistants can
    also read `https://easymeta.org/llms.txt` and
    `https://easymeta.org/agent.md`.

    **One resolver, echoed back.** Every run, in the browser or over this
    API, goes through one parameter resolver: a named `recipe` (MAIVE, RTMA,
    PET-PEESE, EK) or explicit `parameters` are filled in from the same
    defaults and the same data-dependent rules (for example, study
    clustering is on when the data has a `study_id` column), and every
    successful response carries `resolvedParameters`, the complete object
    that actually ran, plus `recipe`. Unknown or misspelled parameter keys
    and conflicting values are rejected with `400 validation_error` naming
    the problem, never run as something else. The same goes for the top
    level of the request body: on `/v1/run-model` and `/v1/run-rtma` a
    `modelType` (or any other parameter name) sent beside `data` instead of
    inside `parameters` is a `400` saying where it belongs, and so is any
    other key those bodies do not document. Only `/v1/runs` accepts a
    top-level `modelType`.

    **Access:** anonymous; no accounts, no API keys. Abuse is bounded by
    server-side concurrency caps and edge rate limits, not identity.

    **Citation:** if you use this API in published or reported work, please
    cite the paper behind the model you ran. For MAIVE, WAIVE, and WLS:

    > Irsova, Z., Bom, P.R.D., Havranek, T., & Rachinger, H. (2025). Spurious
    > precision in meta-analysis of observational research. Nature
    > Communications, 16, 8454. https://doi.org/10.1038/s41467-025-63261-0

    For RTMA, cite the method and the software implementing it:

    > Mathur, M. B. (2024). P-hacking in meta-analyses: A formalization and
    > new meta-analytic methods. Research Synthesis Methods, 15(3), 483-499.
    > https://doi.org/10.1002/jrsm.1701

    > Mathur, M., & Braginsky, M. (2023). phacking: Sensitivity Analysis for
    > p-Hacking in Meta-Analyses. R package version 0.2.1.
    > https://doi.org/10.32614/CRAN.package.phacking
  license:
    name: See repository LICENSE
    url: https://github.com/PetrCala/maive-ui/blob/master/LICENSE

servers:
  - url: https://api.maive.eu
    description: Production

# Anonymous by design (D1): no accounts, no API keys. Documented explicitly
# rather than omitted, so linters don't flag a missing security definition.
security: []

tags:
  - name: sync
    description: Synchronous model runs (single request/response round trip).
  - name: async
    description: Asynchronous model runs (submit, then poll for a result).
  - name: meta
    description: Service metadata.

paths:
  /v1/run-model:
    post:
      tags: [sync]
      operationId: runModel
      summary: Run MAIVE, WAIVE, or WLS synchronously
      description: |
        Runs the requested model and returns the result in the same HTTP
        response. Subject to Cloudflare's ~100s proxy cap at the edge, fine
        for typical runs (roughly 15-60s including cold start), but
        unsuitable for large or slow datasets. Prefer `POST /v1/runs` (async)
        for anything that might run long. See §6.3 of the design doc.
      parameters:
        - $ref: "#/components/parameters/Include"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RunModelRequest"
            examples:
              minimal:
                summary: Minimal request (all parameters default)
                value:
                  data:
                    - effect: 0.42
                      se: 0.11
                      n_obs: 120
                    - effect: 0.31
                      se: 0.06
                      n_obs: 90
                    - effect: 0.55
                      se: 0.2
                      n_obs: 45
                    - effect: 0.12
                      se: 0.04
                      n_obs: 200
              recipe:
                summary: Named recipe (conventional PET-PEESE)
                value:
                  recipe: PET-PEESE
                  data:
                    - effect: 0.42
                      se: 0.11
                      n_obs: 120
                    - effect: 0.31
                      se: 0.06
                      n_obs: 90
                    - effect: 0.55
                      se: 0.2
                      n_obs: 45
                    - effect: 0.12
                      se: 0.04
                      n_obs: 200
              full:
                summary: Explicit parameters
                value:
                  data:
                    - effect: 0.42
                      se: 0.11
                      n_obs: 120
                      study_id: Smith2020
                    - effect: 0.31
                      se: 0.06
                      n_obs: 90
                      study_id: Smith2020
                    - effect: 0.55
                      se: 0.2
                      n_obs: 45
                      study_id: Jones2019
                    - effect: 0.12
                      se: 0.04
                      n_obs: 200
                      study_id: Jones2019
                  parameters:
                    modelType: MAIVE
                    maiveMethod: PET-PEESE
                    weight: equal_weights
                    standardErrorTreatment: clustered_cr2
                    includeStudyDummies: false
                    includeStudyClustering: true
                    computeAndersonRubin: false
                    useLogFirstStage: true
                    winsorize: 0
      responses:
        "200":
          description: Model results, plus the resolved parameters that ran.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ModelResults"
                  - $ref: "#/components/schemas/ResolvedRunEcho"
        "400":
          $ref: "#/components/responses/ValidationError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "504":
          $ref: "#/components/responses/Timeout"

  /v1/run-rtma:
    post:
      tags: [sync]
      operationId: runRtma
      summary: Run RTMA (Right-Truncated Meta-Analysis) synchronously
      description: |
        Same synchronous envelope and edge-cap caveat as `POST /v1/run-model`.
        See §6.4 of the design doc.
      parameters:
        - $ref: "#/components/parameters/Include"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RunRtmaRequest"
            examples:
              minimal:
                summary: Minimal request (all parameters default)
                description: >-
                  RTMA is refused unless at least one estimate is
                  nonaffirmative (`|effect / se|` below 1.96), and a
                  mostly-affirmative dataset makes the sampler unpredictably
                  slow. These 40 rows are the fixture the backend test suite
                  runs: 24 of them nonaffirmative, and a few seconds to fit.
                value:
                  data:
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.05, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.1, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.15, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.25, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
                    - { effect: 0.35, se: 0.1 }
      responses:
        "200":
          description: RTMA results, plus the resolved parameters that ran.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/RTMAResults"
                  - $ref: "#/components/schemas/ResolvedRunEcho"
        "400":
          $ref: "#/components/responses/ValidationError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "504":
          $ref: "#/components/responses/Timeout"

  /v1/runs:
    post:
      tags: [async]
      operationId: submitRun
      summary: Submit an asynchronous run
      description: |
        Queues a model run and returns immediately with a `jobId`. Poll
        `GET /v1/runs/{jobId}` until the status is terminal, then read
        `result`. Recommended as the default integration path, immune to the
        edge's ~100s proxy cap. See §6.5 of the design doc.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitRunRequest"
            examples:
              maive:
                summary: Submit a MAIVE run
                value:
                  modelType: MAIVE
                  data:
                    - effect: 0.42
                      se: 0.11
                      n_obs: 120
                    - effect: 0.31
                      se: 0.06
                      n_obs: 90
                    - effect: 0.55
                      se: 0.2
                      n_obs: 45
                    - effect: 0.12
                      se: 0.04
                      n_obs: 200
              rtma:
                summary: Submit an RTMA run with a pinned seed
                value:
                  modelType: RTMA
                  parameters:
                    seed: 42
                  data:
                    - effect: 0.42
                      se: 0.11
                    - effect: 0.31
                      se: 0.06
                    - effect: 0.55
                      se: 0.2
                    - effect: 0.12
                      se: 0.04
              ek:
                summary: Submit a conventional EK run by recipe
                value:
                  recipe: EK
                  data:
                    - effect: 0.42
                      se: 0.11
                      n_obs: 120
                    - effect: 0.31
                      se: 0.06
                      n_obs: 90
                    - effect: 0.55
                      se: 0.2
                      n_obs: 45
                    - effect: 0.12
                      se: 0.04
                      n_obs: 200
      responses:
        "200":
          description: Run queued.
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    required: [jobId]
                    properties:
                      jobId:
                        type: string
                        description: >-
                          Opaque bearer token. Anyone holding it can read the run
                          for 48h; treat it like a share link.
                        example: "8f14e45f-ceea-467e-9e42-1c0e0f5a4e3e"
                  - $ref: "#/components/schemas/ResolvedRunEcho"
        "400":
          $ref: "#/components/responses/ValidationError"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/NotConfigured"
    get:
      tags: [async]
      operationId: listRunStatuses
      summary: Batch status lookup
      description: |
        Returns status (no `result`) for up to 100 job ids in one call. Used
        for polling multiple runs at once (e.g. a "my runs" list). Unknown ids
        are silently omitted from the response; this endpoint never 404s.
      parameters:
        - name: ids
          in: query
          required: false
          description: Comma-separated list of job ids (max 100).
          schema:
            type: string
          example: "8f14e45f-ceea-467e-9e42-1c0e0f5a4e3e,3c9e2f10-9b7a-4b1a-9a1a-2f6b8e7c1d2a"
      responses:
        "200":
          description: Status for each recognized id (may be empty).
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/RunStatusSummary"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/NotConfigured"

  /v1/runs/{jobId}:
    get:
      tags: [async]
      operationId: getRun
      summary: Poll a run / fetch its result
      description: |
        Returns the run's current status. Once `status` is `succeeded`,
        `result` is populated with the parsed `ModelResults` or `RTMAResults`
        object (never a JSON-encoded string). Suggested polling interval:
        every 2-5s. Runs expire and start 404ing 48h after submission.
      parameters:
        - name: jobId
          in: path
          required: true
          schema:
            type: string
          example: "8f14e45f-ceea-467e-9e42-1c0e0f5a4e3e"
        - $ref: "#/components/parameters/Include"
      responses:
        "200":
          description: Run status (and result, once terminal-successful).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Run"
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/NotConfigured"

  /v1/health:
    get:
      tags: [meta]
      operationId: health
      summary: Health check
      description: Alias of the existing `/health` route. No auth, no rate limiting.
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema:
                type: object
                required: [status, time]
                properties:
                  status:
                    type: string
                    example: ok
                  time:
                    type: string
                    description: Server time (UTC).
                    example: "2026-07-08 12:34:56 UTC"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"

components:
  parameters:
    Include:
      name: include
      in: query
      required: false
      description: >-
        Set to `plot` to embed the base64-encoded plot image(s) in the
        response. Omitted by default (D7): the plot is ~50KB and most
        programmatic callers don't need it.
      schema:
        type: string
        enum: [plot]

  responses:
    ValidationError:
      description: >-
        The request body failed validation. This includes a parameter key
        the contract does not know (a misspelling such as `favourPositive`
        is named in the message), a parameter value that conflicts with the
        others (for example `adjusted_weights` on a WLS run), and a key the
        endpoint does not accept at the top level of the body (a `modelType`
        beside `data` on `/v1/run-model` or `/v1/run-rtma`, which belongs
        inside `parameters`, or any undocumented key); none of these is ever
        run as a different analysis.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            data:
              value:
                error:
                  code: validation_error
                  message: "Data must have 3 or 4 columns; found 6."
            unknownKey:
              value:
                error:
                  code: validation_error
                  message: "Unknown RTMA parameter key: favourPositive. Known keys: modelType, favorPositive, alphaSelect, ciLevel, winsorize, seed."
            misplacedKey:
              value:
                error:
                  code: validation_error
                  message: "Unexpected top-level key: modelType. modelType is a run parameter and belongs inside `parameters`; this endpoint accepts data, parameters and recipe at the top level."
    NotFound:
      description: The requested resource does not exist or has expired.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: not_found
              message: "Run not found or expired."
    MethodNotAllowed:
      description: The HTTP method is not supported on this route.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: method_not_allowed
              message: "Method Not Allowed"
    PayloadTooLarge:
      description: >-
        The dataset is too large to queue (SQS message budget, ~900KB). Use
        the synchronous endpoint instead.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: payload_too_large
              message: "Dataset too large to queue; use POST /v1/run-model or /v1/run-rtma instead."
    RateLimited:
      description: >-
        Too many requests: either the edge rate limit or the server-side
        concurrency cap was hit. Retry with backoff.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: rate_limited
              message: "Too many requests. Please retry after a short delay."
    InternalError:
      description: An unexpected server-side error occurred.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: internal_error
              message: "Internal server error."
    Timeout:
      description: >-
        The analysis exceeded the synchronous wall-clock budget (120 s) and
        was stopped. Reduce the dataset, winsorize outliers, or submit the
        analysis as a background run via POST /v1/runs.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: timeout
              message: "The request timed out after 120 seconds."
              timeoutSeconds: 120
              elapsedSeconds: 121.2
    NotConfigured:
      description: This deployment does not have the async runs infrastructure configured.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: not_configured
              message: "Async runs are not configured."

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - validation_error
                - not_found
                - method_not_allowed
                - payload_too_large
                - rate_limited
                - timeout
                - internal_error
                - not_configured
            message:
              type: string
            timeoutSeconds:
              type: number
              description: Present on timeout errors; the enforced budget.
            elapsedSeconds:
              type: number
              description: Present on timeout errors; seconds before the stop.

    DataRow:
      type: object
      description: |
        One row of meta-analysis input data. Columns are resolved by the
        canonical keys below when present (matched case-insensitively);
        otherwise the first 3-4 keys of the object are taken positionally as
        (effect, se, n_obs[, study_id]) for MAIVE-family endpoints, or
        (effect, se) for RTMA (D5). With positional resolution, MAIVE-family
        rows must have exactly 3 or 4 keys; extra columns are tolerated only
        when the canonical names are present.
      properties:
        effect:
          type: number
          description: Estimated effect size.
        se:
          type: number
          description: >-
            Standard error of the effect estimate. Must be > 0, and for
            MAIVE-family runs it must vary across rows: a constant column
            leaves the publication-bias slope unidentified.
        n_obs:
          type: integer
          description: >-
            Number of observations underlying the estimate. Required for
            MAIVE-family endpoints (not used by RTMA). Must be a positive
            integer.
        study_id:
          type: string
          description: >-
            Optional study identifier, used for clustering. If present across
            rows, the number of rows must be at least the number of unique
            study ids plus 3.
      additionalProperties: true
      example:
        effect: 0.42
        se: 0.11
        n_obs: 120
        study_id: Smith2020

    ModelParameters:
      type: object
      additionalProperties: false
      description: |
        All parameters are optional; a minimal valid request is just
        `{"data": [...]}` (D6). Unset parameters are resolved by the same
        resolver the browser uses, so some defaults depend on the other
        parameters and on the data:

        - `includeStudyClustering` is `true` when the data has a `study_id`
          column and `standardErrorTreatment` is not `not_clustered`, and
          `false` otherwise.
        - `weight` defaults to `standard_weights` when instrumenting is off
          (WLS), `equal_weights` otherwise.
        - `useLogFirstStage` defaults to `true` whenever the model instruments
          (MAIVE, WAIVE) and `false` for WLS, which has no first stage.
        - `shouldUseInstrumenting` defaults to `false` for WLS and `true`
          otherwise. `modelType: MAIVE` with `shouldUseInstrumenting: false`
          is the conventional estimator, which the app calls WLS; the echo
          reports it as `WLS`.

        Keys outside this schema are rejected with `400`. Explicit values
        that conflict (WAIVE with a method other than PET-PEESE, adjusted
        weights or a log first stage or the Anderson-Rubin CI without
        instrumenting, study clustering without a `study_id` column) are
        rejected with `400` rather than silently changed. The response's
        `resolvedParameters` is the object that actually ran.
      properties:
        modelType:
          type: string
          enum: [MAIVE, WAIVE, WLS]
          default: MAIVE
        maiveMethod:
          type: string
          enum: [PET, PEESE, PET-PEESE, EK]
          default: PET-PEESE
        weight:
          type: string
          enum:
            [
              equal_weights,
              standard_weights,
              adjusted_weights,
              study_weights,
            ]
          description: >-
            Default `equal_weights` when instrumenting, `standard_weights`
            when not (WLS). `adjusted_weights` requires instrumenting.
        standardErrorTreatment:
          type: string
          enum: [not_clustered, clustered, clustered_cr2, bootstrap]
          default: clustered_cr2
        includeStudyDummies:
          type: boolean
          default: false
        includeStudyClustering:
          type: boolean
          description: >-
            Derived from the data: `true` when a `study_id` column is
            present and the standard errors are clustered, `false`
            otherwise. Setting it against that rule is a `400`.
        computeAndersonRubin:
          type: boolean
          default: false
          description: >-
            Needs instrumenting and is not available with standard weights
            or study dummies.
        useLogFirstStage:
          type: boolean
          description: >-
            Run the first-stage regression on log variances versus log
            sample size. Default `true` when instrumenting (MAIVE, WAIVE),
            `false` for WLS. Needs instrumenting.
        winsorize:
          type: number
          description: Winsorization percentage applied to effect sizes and standard errors. 0 disables it.
          default: 0
        shouldUseInstrumenting:
          type: boolean
          description: >-
            Derived from `modelType` unless explicitly set: `false` when
            `modelType` is `WLS`, `true` otherwise. `false` with
            `modelType: MAIVE` selects the conventional estimator (reported
            as `WLS`); WAIVE always instruments.
        favorPositive:
          type: boolean
          default: true
          description: >-
            Accepted for symmetry with RTMA parameters; ignored by the
            MAIVE family.

    RTMAParameters:
      type: object
      additionalProperties: false
      description: >-
        All parameters are optional. See §6.4 of the design doc. Keys outside
        this schema are rejected with `400`.
      properties:
        modelType:
          type: string
          enum: [RTMA]
          description: Optional; must be `RTMA` when present.
        favorPositive:
          type: boolean
          default: true
        alphaSelect:
          type: number
          default: 0.05
        ciLevel:
          type: number
          default: 0.95
        winsorize:
          type: number
          description: Winsorization percentage applied to effect sizes and standard errors. 0 disables it.
          default: 0
        seed:
          type: integer
          minimum: 1
          default: 2025
          description: >-
            RNG seed for the sampler. The credible intervals are posterior
            quantiles, so they depend on it: the same data and seed return the
            same numbers, and varying it is how you check that an interval is
            Monte Carlo stable. Always echoed back in the response.

    Recipe:
      type: string
      enum: [MAIVE, RTMA, PET-PEESE, EK]
      description: |
        A named preset that expands into `parameters` before the caller's
        own `parameters` are applied on top:

        - `MAIVE`: `{"modelType":"MAIVE","maiveMethod":"PET-PEESE","shouldUseInstrumenting":true}`
        - `RTMA`: `{"modelType":"RTMA"}` (RTMA endpoints only)
        - `PET-PEESE`: `{"modelType":"WLS","maiveMethod":"PET-PEESE","shouldUseInstrumenting":false}` (conventional PET-PEESE)
        - `EK`: `{"modelType":"WLS","maiveMethod":"EK","shouldUseInstrumenting":false}` (conventional endogenous kink)

        Dropping `shouldUseInstrumenting: false` from the last two gives the
        MAIVE variant of the same method.

    ResolvedRunEcho:
      type: object
      description: >-
        Present on every successful run response: what the server actually
        ran, after defaults and data-dependent rules.
      required: [resolvedParameters, recipe]
      properties:
        resolvedParameters:
          description: >-
            The complete parameter object the backend ran; `ModelParameters`
            for MAIVE, WAIVE and WLS, `RTMAParameters` (with `seed`) for RTMA.
            Feed it back unchanged to reproduce the run.
          oneOf:
            - $ref: "#/components/schemas/ModelParameters"
            - $ref: "#/components/schemas/RTMAParameters"
        recipe:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Recipe"
          description: >-
            The named recipe `resolvedParameters` corresponds to, or `null`
            for a custom configuration.

    RunModelRequest:
      type: object
      required: [data]
      additionalProperties: false
      description: >-
        Only `data`, `parameters` and `recipe` are accepted at the top
        level. There is no top-level `modelType` here (unlike `/v1/runs`):
        put it inside `parameters`. Any other top-level key is a `400`
        naming it, so a misplaced parameter can never run as a different
        analysis (#574).
      properties:
        recipe:
          $ref: "#/components/schemas/Recipe"
        data:
          type: array
          minItems: 4
          description: >-
            3 or 4 columns; at least 4 rows; `effect`/`se`/`n_obs` numeric;
            `se > 0`; `n_obs` positive integers; if `study_id` is present,
            rows must be >= unique studies + 3 (§6.2).
          items:
            $ref: "#/components/schemas/DataRow"
        parameters:
          $ref: "#/components/schemas/ModelParameters"

    RunRtmaRequest:
      type: object
      required: [data]
      additionalProperties: false
      description: >-
        Only `data`, `parameters` and `recipe` are accepted at the top
        level; a `seed`, `favorPositive` or any other parameter name sent
        beside them is a `400` naming it and pointing at `parameters`.
      properties:
        recipe:
          $ref: "#/components/schemas/Recipe"
        data:
          type: array
          minItems: 2
          description: >-
            At least 2 columns (`effect`, `se`). Rows with missing or
            non-positive `se` are dropped with a warning (§6.2).
          items:
            $ref: "#/components/schemas/DataRow"
        parameters:
          $ref: "#/components/schemas/RTMAParameters"

    SubmitRunRequest:
      type: object
      required: [data]
      additionalProperties: false
      description: >-
        Only `data`, `parameters`, `recipe` and `modelType` are accepted at
        the top level. This is the one endpoint where a top-level
        `modelType` is documented; every other parameter name still belongs
        inside `parameters`, and an undocumented top-level key is a `400`.
      properties:
        recipe:
          $ref: "#/components/schemas/Recipe"
        data:
          type: array
          items:
            $ref: "#/components/schemas/DataRow"
        parameters:
          description: >-
            `ModelParameters` if `modelType` is `MAIVE`/`WAIVE`/`WLS`, or
            `RTMAParameters` if `modelType` is `RTMA`.
          oneOf:
            - $ref: "#/components/schemas/ModelParameters"
            - $ref: "#/components/schemas/RTMAParameters"
        modelType:
          type: string
          enum: [MAIVE, WAIVE, WLS, RTMA]
          default: MAIVE
          description: >-
            Routes the queued run to the MAIVE-family or RTMA compute path.
            Optional when `recipe` or `parameters.modelType` says the same
            thing; a two-column upload without `n_obs` defaults to RTMA.

    CIInterval:
      description: A `[lower, upper]` confidence interval, or `"NA"` when not computed.
      oneOf:
        - type: array
          items:
            type: number
          minItems: 2
          maxItems: 2
        - type: string
          enum: ["NA"]

    ModelResults:
      type: object
      description: >-
        Result of `POST /v1/run-model`. Field names mirror the internal R
        result object 1:1 (`maive_model.R`, `run_maive_model`); they are
        already the de-facto contract for the UI and reproducibility
        packages, and are frozen as-is (§11).
      required:
        - effectEstimate
        - standardError
        - isSignificant
        - andersonRubinCI
        - publicationBias
        - firstStageFStatistic
        - hausmanTest
        - seInstrumented
        - bootSE
        - bootCI
        - warnings
      properties:
        effectEstimate:
          type: number
        standardError:
          type: number
        isSignificant:
          type: boolean
          nullable: true
          description: >-
            `null` when the verdict is undefined, e.g. a numerically zero
            standard error from a perfect fit; not the same as `false`.
        andersonRubinCI:
          $ref: "#/components/schemas/CIInterval"
        publicationBias:
          type: object
          required: [eggerCoef, eggerSE, isSignificant, eggerBootCI, eggerAndersonRubinCI]
          properties:
            eggerCoef:
              type: number
            eggerSE:
              type: number
            isSignificant:
              type: boolean
              nullable: true
              description: '`null` when `pValue` is undefined.'
            eggerBootCI:
              $ref: "#/components/schemas/CIInterval"
            eggerAndersonRubinCI:
              $ref: "#/components/schemas/CIInterval"
            pValue:
              description: >-
                Egger p-value, or `"NA"` when it is undefined (an Egger
                standard error of zero, e.g. identical effects).
              oneOf:
                - type: number
                - type: string
                  enum: ["NA"]
        firstStageFStatistic:
          description: Numeric F-statistic, or `"NA"` when not computed (e.g. no instrumenting).
          oneOf:
            - type: number
            - type: string
              enum: ["NA"]
        hausmanTest:
          type: object
          required: [statistic, criticalValue, rejectsNull]
          properties:
            statistic:
              description: Numeric Hausman statistic, or `"NA"` when it is undefined.
              oneOf:
                - type: number
                - type: string
                  enum: ["NA"]
            criticalValue:
              type: number
            rejectsNull:
              type: boolean
              nullable: true
              description: '`null` when the statistic is undefined (reported as `"NA"`).'
        seInstrumented:
          description: >-
            One entry per input row: the instrumented standard error, or
            `"NA"` when the first stage fitted a negative variance for that
            row and its instrumented SE is undefined (see
            https://github.com/PetrCala/MAIVE/issues/24).
          type: array
          items:
            oneOf:
              - type: number
              - type: string
                enum: ["NA"]
        bootSE:
          description: '`[se_lower, se_upper]`, or `"NA"` when bootstrap SE was not requested/computed.'
          oneOf:
            - type: array
              items:
                type: number
              minItems: 2
              maxItems: 2
            - type: string
              enum: ["NA"]
        bootCI:
          description: A pair of confidence intervals `[[lo, hi], [lo, hi]]`, or `"NA"`.
          oneOf:
            - type: array
              items:
                type: array
                items:
                  type: number
                minItems: 2
                maxItems: 2
              minItems: 2
              maxItems: 2
            - type: string
              enum: ["NA"]
        firstStage:
          nullable: true
          type: object
          properties:
            mode:
              type: string
              enum: [levels, log]
            description:
              type: string
            fStatisticLabel:
              type: string
        petpeese_selected:
          nullable: true
          type: string
          enum: [PET, PEESE]
        peese_se2_coef:
          description: >-
            PEESE `se^2` coefficient, or `"NA"` when `petpeese_selected` is
            `PET` and no PEESE curve was fitted.
          oneOf:
            - type: number
              nullable: true
            - type: string
              enum: ["NA"]
        peese_se2_se:
          description: >-
            Standard error of `peese_se2_coef`, or `"NA"` under the same
            condition.
          oneOf:
            - type: number
              nullable: true
            - type: string
              enum: ["NA"]
        slope_coef:
          description: A single slope coefficient, or a kink description when the fit is piecewise.
          oneOf:
            - type: number
            - type: object
              properties:
                kink_effect:
                  type: number
                kink_location:
                  type: number
        is_quadratic_fit:
          type: object
          properties:
            quadratic:
              type: boolean
            slope_type:
              type: string
            slope_detail:
              nullable: true
              type: object
              properties:
                kink_location:
                  type: number
                kink_effect:
                  type: number
        instrument_strength:
          nullable: true
          type: string
          enum: [strong, weak, very_weak, unknown, not_applicable]
          description: >-
            Instrument strength label reported by the MAIVE package (0.2.3+);
            `not_applicable` without instrumenting, `null` on older packages.
        warnings:
          type: array
          items:
            type: string
          description: >-
            Conditions raised while fitting; empty when the fit was clean.
            Includes the package's own diagnostics (small sample, weak
            instrument, perfect fit) and the backend's, such as a numerically
            zero standard error that leaves `isSignificant` undefined.
        funnelPlot:
          type: string
          description: 'Base64-encoded PNG data URI. Only present with `?include=plot`.'
        funnelPlotWidth:
          type: number
          description: Only present with `?include=plot`.
        funnelPlotHeight:
          type: number
          description: Only present with `?include=plot`.

    RTMAResults:
      type: object
      description: >-
        Result of `POST /v1/run-rtma`. Field names mirror the internal R
        result object 1:1 (`rtma_model.R`, `run_rtma_model`).
      required:
        - mu
        - muMedian
        - muCI
        - tau
        - tauMedian
        - tauCI
        - unadjustedMean
        - ciLevel
        - seed
        - k
        - affirmativeCount
        - droppedRows
        - nonaffirmativeCount
        - nonaffirmativeProportion
        - warnings
        - diagnostics
      properties:
        mu:
          type: number
          description: >-
            Posterior mode of the bias-corrected mean, on the sign convention of
            the submitted data (so a negative literature analysed with
            `favorPositive: false` returns a negative `mu`).
        muMedian:
          type: number
          description: >-
            Posterior median of the bias-corrected mean, same sign convention
            as `mu`. The posterior is often skewed, so the median can differ
            noticeably from the mode.
        muCI:
          type: array
          items:
            type: number
          minItems: 2
          maxItems: 2
          description: >-
            Equal-tailed credible interval for `mu` at `ciLevel` (posterior
            quantiles, not an HPD interval).
        tau:
          type: number
        tauMedian:
          type: number
          description: Posterior median of the heterogeneity `tau`.
        tauCI:
          type: array
          items:
            type: number
          minItems: 2
          maxItems: 2
          description: >-
            Equal-tailed credible interval for `tau` at `ciLevel` (posterior
            quantiles, not an HPD interval).
        unadjustedMean:
          type: number
          description: >-
            Naive inverse-variance (fixed-effect) pooled mean of the analyzed
            estimates with no truncation correction, for comparison with `mu`.
        ciLevel:
          type: number
          description: Level of the credible intervals, echoed from the request.
        seed:
          type: integer
          description: >-
            RNG seed the sampler ran under, echoed from the request or the
            default when none was sent. Re-running with the same data and seed
            reproduces these numbers exactly.
        k:
          type: integer
          description: >-
            Number of estimates the model was fitted to, after dropping rows
            with a missing or non-positive standard error.
        affirmativeCount:
          type: integer
          description: >-
            Estimates significant in the favored direction at the selection
            threshold; `affirmativeCount + nonaffirmativeCount = k`.
        droppedRows:
          type: integer
          description: Submitted rows removed by the `se > 0` filter.
        nonaffirmativeCount:
          type: integer
        nonaffirmativeProportion:
          type: number
        warnings:
          type: array
          items:
            type: string
          description: >-
            Conditions raised while fitting; empty when the fit was clean. The
            most important one reports a favored direction opposite the pooled
            estimate, which means nothing was truncated and `mu` is not actually
            corrected for p-hacking.
        diagnostics:
          $ref: "#/components/schemas/RTMADiagnostics"
        zScorePlot:
          type: string
          description: 'Base64-encoded PNG data URI. Only present with `?include=plot`.'
        zScorePlotWidth:
          type: number
          description: Only present with `?include=plot`.
        zScorePlotHeight:
          type: number
          description: Only present with `?include=plot`.

    RTMAParameterDiagnostic:
      type: object
      description: >-
        A sampler diagnostic reported separately for each parameter, because
        the chains can mix well for one and badly for the other. `null` means
        the backend could not read the value off the fit, which is not the same
        as the diagnostic being healthy.
      required: [mu, tau]
      properties:
        mu:
          type: number
          nullable: true
        tau:
          type: number
          nullable: true

    RTMADiagnostics:
      type: object
      description: >-
        Convergence diagnostics for the fit that produced the results above.
        Without them a failed fit and a clean fit are indistinguishable in this
        response.
      required: [optimConverged, rHat, nEff, divergences]
      properties:
        optimConverged:
          type: boolean
          nullable: true
          description: >-
            Whether the `mle_params()` optimisation converged. That
            optimisation runs separately from the sampler and is what produces
            the reported modes `mu` and `tau`, so `false` means those two point
            estimates are meaningless while `muCI` and `tauCI`, which are
            posterior quantiles, remain valid. `null` means the value could not
            be read.
        rHat:
          allOf:
            - $ref: "#/components/schemas/RTMAParameterDiagnostic"
          description: >-
            Gelman-Rubin convergence statistic per parameter. Above 1.01 the
            chains have not converged on the same distribution.
        nEff:
          allOf:
            - $ref: "#/components/schemas/RTMAParameterDiagnostic"
          description: >-
            Effective posterior sample size per parameter. The sampler runs
            four chains, so values below roughly 400 mean the summaries rest on
            few effectively independent draws.
        divergences:
          type: integer
          nullable: true
          description: >-
            Divergent transitions in the sampler. Any at all mean part of the
            posterior went unexplored, so the credible intervals can be biased
            even at a healthy `rHat`. `null` means the value could not be read.

    RunStatus:
      type: string
      enum: [queued, running, succeeded, failed, timedout]

    RunStatusSummary:
      type: object
      description: Status-only view returned by the batch `GET /v1/runs?ids=` lookup (no `result`).
      required: [jobId, status]
      properties:
        jobId:
          type: string
        status:
          $ref: "#/components/schemas/RunStatus"
        modelType:
          type: string
          enum: [MAIVE, WAIVE, WLS, RTMA]
        errorMessage:
          type: string
        errorCode:
          type: string
          description: >-
            Structured error code from the backend (e.g. `timeout`,
            `worker_died`), present on some `failed`/`timedout` runs.
        runDurationMs:
          type: number
        runTimestamp:
          type: string
          format: date-time

    Run:
      type: object
      description: Full run object returned by `GET /v1/runs/{jobId}`.
      required: [jobId, status]
      properties:
        jobId:
          type: string
        status:
          $ref: "#/components/schemas/RunStatus"
        modelType:
          type: string
          enum: [MAIVE, WAIVE, WLS, RTMA]
        runDurationMs:
          type: number
        runTimestamp:
          type: string
          format: date-time
        result:
          description: >-
            Parsed result object, present once `status` is `succeeded`.
            `ModelResults` for `MAIVE`/`WAIVE`/`WLS` runs, `RTMAResults` for
            `RTMA` runs.
          oneOf:
            - $ref: "#/components/schemas/ModelResults"
            - $ref: "#/components/schemas/RTMAResults"
        errorMessage:
          type: string
          description: Present on `failed`/`timedout`.
        errorCode:
          type: string
          description: >-
            Structured error code from the backend (e.g. `timeout`,
            `worker_died`), present on some `failed`/`timedout` runs. Prefer
            it over parsing `errorMessage` when reacting to a specific
            failure mode.
        resolvedParameters:
          description: >-
            The parameters the run was queued with, already resolved (see
            `ResolvedRunEcho`). For RTMA the `seed` fills in from the result
            once the run has succeeded.
          oneOf:
            - $ref: "#/components/schemas/ModelParameters"
            - $ref: "#/components/schemas/RTMAParameters"
        recipe:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Recipe"
