> ## Documentation Index
> Fetch the complete documentation index at: https://surf-dcinside-api.kr.ask.surf/llms.txt
> Use this file to discover all available pages before exploring further.

# Score a Cycle

> One call per five-minute cycle. Sends candidates, engagement, candidate scores and removals, and returns a status and publish score for every pending candidate.

<div style={{ maxWidth: 760 }}>
  <h2 style={{ fontSize: "22px", marginBottom: 8 }}>Overview</h2>

  The **Score a Cycle** API is the one call DCinside makes at every five-minute boundary. The request carries four arrays that mirror tables DCinside already delivers: `candidates` (the queue), `engagement` (the engagement series), `candidate_scores` (the admin candidate-score table) and `removals` (queue outcomes and deletions). The response has one row per pending candidate, with its status and publish score, inside an envelope of counts and versions.

  <Note>
    **Pending** means a post that was sent in this or an earlier call and is not yet in `removals`. Send each candidate once. Send `engagement` for **every** pending post in **every** cycle, because the growth features are built from that series.
  </Note>

  <Warning>
    Use `null` for a counter you do not know. Never send `0` instead: the model treats a missing value differently from a zero.
  </Warning>

  <h3 style={{ marginTop: 20 }}>Cadence & timing</h3>

  <CardGroup cols={2}>
    <Card title="Cadence" icon="clock">
      Every five minutes by default, at `:00`, `:05`, `:10` and so on, Korean time. `cycle_time` must be on that grid and not in the future. The interval is a setting on DCinside's side, see [Conventions](/conventions#cycle-interval).
    </Card>

    <Card title="Timeout" icon="hourglass">
      Scoring runs inside the request. The proposed default is a 60-second timeout, to be settled with DCinside and adjusted from the first rehearsal day. If the response does not arrive, skip the cycle and read it later with [`GET /v1/scores/{cycle_id}`](/scores).
    </Card>
  </CardGroup>

  <Note>
    A candidate is scored once, at its due cycle, `ceil5(max(extracted_at, first call that carries it) + 15 minutes)`. Until then it comes back as `waiting`. See [How Scoring Works](/scoring).
  </Note>

  ***

  <h2 style={{ fontSize: "22px", marginBottom: 8 }}>Request Attributes</h2>

  The body is gzip-compressed JSON. Its limits are 16 MB after decompression, 200 `candidates` and 5,000 rows in each other array.

  <ParamField body="cycle_id" type="string" required>
    Idempotency key: the cycle time in KST as `YYYYMMDDTHHMM`, for example `20260929T0205`. The same id with the same body returns the stored response. The same id with a different body returns 409.
  </ParamField>

  <ParamField body="cycle_time" type="string" required>
    The five-minute boundary this call belongs to. Scores are taken as of this time, and the model's hour-of-day and day-of-week inputs are read from it in KST. A value off the grid or in the future gets 422.
  </ParamField>

  <ParamField body="candidates" type="array of objects" required>
    Posts that became candidates since the last successful call. Each post is sent once. May be empty.

    <Expandable title="item properties">
      <ParamField body="gall_id" type="string" required>
        Post key, together with `post_no`.
      </ParamField>

      <ParamField body="post_no" type="integer" required>
        Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.
      </ParamField>

      <ParamField body="title" type="string" required>
        Title as extracted, before any operator edit. The model embeds it (Unicode NFC, first 2,000 characters) and reads its length, and training used extraction-original titles only. An empty title is not scored. Delivered files: `queue.subject`.
      </ParamField>

      <ParamField body="created_at" type="string" required>
        When the post was created. Post age at scoring is an input. Delivered files: `queue.created_at`.
      </ParamField>

      <ParamField body="extracted_at" type="string" required>
        When the post entered the extraction queue. Queue age, and the 15-minute wait before scoring, count from it. Delivered files: `queue.extracted_at`.
      </ParamField>

      <ParamField body="source" type="string" required>
        How the post reached the queue. A categorical input. `unknown` appears only in the delivered files (posts first captured after processing), so live posts should carry a real source. Delivered files: `queue.source`. One of `score`, `setting`, `keyword`, `vote`, `manual`, `unknown`.
      </ParamField>

      <ParamField body="manual_flag" type="integer" required>
        `1` when the post was registered by hand from external monitoring. An input. Delivered files: `queue.manual_flag`. One of `0`, `1`.
      </ParamField>

      <ParamField body="images_in_post" type="integer" required>
        How many images the original body has. Image positions run from 1 to this number. Not a model input: it lets us tell a late upload from a missing one. Delivered files: `image_manifest.images_in_post` (v1.3).
      </ParamField>

      <ParamField body="image_positions" type="array of integers">
        The positions DCinside will upload, when some images cannot be fetched. Default: 1 to `images_in_post`. In v1.3, 509 of 559,329 images (0.09%) came back empty or with a 403, so gaps happen. Delivered files: `image_manifest.image_index` (v1.3).
      </ParamField>

      <ParamField body="body_html" type="string">
        Original body. The current model does not read it. DCinside agreed to send the text once, and later model versions or the review sample may use it. Delivered files: `queue.body_html`.
      </ParamField>

      <ParamField body="gallery_type" type="string">
        We look gallery type up in the gallery registry we hold. Send it for galleries added since that registry was built. A gallery missing from both is treated as unknown. One of `main`, `minor`, `mini`.
      </ParamField>

      <ParamField body="category_id" type="integer">
        Gallery category. Same rule as `gallery_type`: we read it from the registry, and you send it for galleries added since.
      </ParamField>
    </Expandable>
  </ParamField>

  <ParamField body="engagement" type="array of objects" required>
    One row for every pending candidate, every cycle. Pending means sent in this or an earlier call and not yet in `removals`.

    <Expandable title="item properties">
      <ParamField body="gall_id" type="string" required>
        Post key, together with `post_no`.
      </ParamField>

      <ParamField body="post_no" type="integer" required>
        Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.
      </ParamField>

      <ParamField body="observed_at" type="string" required>
        When the counters were read from the gallery, to the minute, at or before `cycle_time`. The model takes the latest row at or before the cycle, and its growth features need that row within 10 minutes of the cycle. Delivered files: `engagement_ts.ts`.
      </ParamField>

      <ParamField body="status" type="string" required>
        `ok` = read normally, `missing` = the post is gone, `nomap` = the gallery could not be mapped. A categorical input. A post whose latest status is `missing` or `nomap` is not scored. One of `ok`, `missing`, `nomap`.
      </ParamField>

      <ParamField body="views" type="integer | null" required>
        Current view count. Inputs: the value, up-per-view, comments-per-view, and its rate and acceleration over 5, 15 and 30 minutes, which we compute from the series.
      </ParamField>

      <ParamField body="up" type="integer | null" required>
        Recommendations, not the Send-to-Best counter. In v1 and v1.1 of the delivered files this column held the wrong counter, and v1.2 corrected it.
      </ParamField>

      <ParamField body="down" type="integer | null" required>
        Down-votes. Feeds the down-vote share input.
      </ParamField>

      <ParamField body="comment_cnt" type="integer | null" required>
        Comment count.
      </ParamField>

      <ParamField body="rtb_vote" type="integer | null" required>
        The Send-to-Best vote counter, separate from `up`.
      </ParamField>
    </Expandable>
  </ParamField>

  <ParamField body="candidate_scores" type="array of objects" required>
    The admin candidate-score table's row for every pending candidate that has one. May be empty.

    <Expandable title="item properties">
      <ParamField body="gall_id" type="string" required>
        Post key, together with `post_no`.
      </ParamField>

      <ParamField body="post_no" type="integer" required>
        Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.
      </ParamField>

      <ParamField body="snapshot_at" type="string" required>
        When the admin candidate-score table was read. The model uses the latest snapshot at or before the cycle, and how old it is is an input. Delivered files: `candidate_score_ts.snapshot_ts`.
      </ParamField>

      <ParamField body="recommend_up_cnt" type="integer | null">
        Over-baseline recommendations, PC part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="recommend_up_cnt_m" type="integer | null">
        Over-baseline recommendations, mobile part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="recommend_up_cnt_a" type="integer | null">
        Over-baseline recommendations, app part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="comment_cnt" type="integer | null">
        Over-baseline comments, PC part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="comment_cnt_m" type="integer | null">
        Over-baseline comments, mobile part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="comment_cnt_a" type="integer | null">
        Over-baseline comments, app part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="hit_cnt" type="integer | null">
        Over-baseline views, PC part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="hit_cnt_m" type="integer | null">
        Over-baseline views, mobile part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="hit_cnt_a" type="integer | null">
        Over-baseline views, app part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="dcbest_cnt" type="integer | null">
        Send-to-Best votes, PC part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="dcbest_cnt_m" type="integer | null">
        Send-to-Best votes, mobile part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="dcbest_cnt_a" type="integer | null">
        Send-to-Best votes, app part. The model reads the sum of the PC, mobile and app parts, and that sum is missing if any one part is `null`.
      </ParamField>

      <ParamField body="memo_size" type="integer | null">
        Body size as the admin table counts it. A model input as delivered, so there is nothing to compute on DCinside's side.
      </ParamField>

      <ParamField body="upimg_cnt" type="integer | null">
        Uploaded image count from the same table. An input.
      </ParamField>

      <ParamField body="upimg_height" type="integer | null">
        Total pixel height of the uploaded images, capped at 32,767. An input.
      </ParamField>
    </Expandable>
  </ParamField>

  <ParamField body="removals" type="array of objects" required>
    Posts leaving the pending pool since the last successful call. May be empty.

    <Expandable title="item properties">
      <ParamField body="gall_id" type="string" required>
        Post key, together with `post_no`.
      </ParamField>

      <ParamField body="post_no" type="integer" required>
        Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.
      </ParamField>

      <ParamField body="reason" type="string" required>
        Why the post leaves the pending pool: `saved` (placed on a board), `hidden`, `deleted`, or `aged_out` (over 24 hours: DCinside applies a 24-hour age filter and sends a removal signal when a pending candidate crosses it). It stops being scored and stops appearing in responses. Delivered files: `queue.outcome`, `deletion`. One of `saved`, `hidden`, `deleted`, `aged_out`.
      </ParamField>

      <ParamField body="tier" type="string">
        Board tier. Required when `reason` is `saved`, otherwise omit. One of `main`, `light`, `night`, `app`.
      </ParamField>

      <ParamField body="decided_at" type="string" required>
        When the decision or deletion happened. Kept for reconciliation. Not a model input. Delivered files: `queue.decided_at`, `deletion.deleted_at`.
      </ParamField>

      <ParamField body="reason_code" type="string">
        Optional. The code the operator picked in DCinside's reason tool for a `saved` or `hidden` decision. The code list is still being agreed. Surf proposed `low_quality`, `stale`, `quota_full`, `duplicate`, `policy_violation` and `other` for hides.
      </ParamField>
    </Expandable>
  </ParamField>

  ***

  <h2 style={{ fontSize: "22px", marginBottom: 8 }}>Response Attributes</h2>

  <ResponseField name="cycle_id" type="string" required>
    Echoed from the request.
  </ResponseField>

  <ResponseField name="cycle_time" type="string" required>
    Echoed from the request.
  </ResponseField>

  <ResponseField name="received_at" type="string" required>
    Our clock when the call arrived.
  </ResponseField>

  <ResponseField name="scored_at" type="string" required>
    Our clock when scoring finished. The gap from `received_at` is the scoring time.
  </ResponseField>

  <ResponseField name="model_version" type="string" required>
    The model that produced the scores. Every score can be traced to it and to `weights_version`.
  </ResponseField>

  <ResponseField name="weights_version" type="string" required>
    The publish-score weights in force for this cycle.
  </ResponseField>

  <ResponseField name="counts" type="object" required>
    Row counts for this cycle.

    <Expandable title="properties">
      <ResponseField name="candidates" type="integer" required>
        Rows received in `candidates`.
      </ResponseField>

      <ResponseField name="engagement" type="integer" required>
        Rows received in `engagement`.
      </ResponseField>

      <ResponseField name="candidate_scores" type="integer" required>
        Rows received in `candidate_scores`.
      </ResponseField>

      <ResponseField name="removals" type="integer" required>
        Rows received in `removals`.
      </ResponseField>

      <ResponseField name="scored_now" type="integer" required>
        Posts whose score was taken in this cycle.
      </ResponseField>

      <ResponseField name="waiting" type="integer" required>
        Results with status `waiting`.
      </ResponseField>

      <ResponseField name="not_scored" type="integer" required>
        Results with status `not_scored`.
      </ResponseField>

      <ResponseField name="results" type="integer" required>
        Rows in `results`: every pending post, including earlier scores repeated unchanged.
      </ResponseField>
    </Expandable>
  </ResponseField>

  <ResponseField name="missing_feature_share" type="number" required>
    Share of the model's 62 tabular inputs that were missing across the posts scored in this cycle. A jump means bad data upstream.
  </ResponseField>

  <ResponseField name="warnings" type="array of strings" required>
    Extra fields we ignored, and anything else worth a look. Empty when clean.
  </ResponseField>

  <ResponseField name="results" type="array of objects" required>
    One row per pending candidate. Scores given earlier are repeated unchanged, so DCinside keeps no score memory of its own. Removed posts are not returned.

    <Expandable title="item properties">
      <ResponseField name="gall_id" type="string" required>
        Post key, together with `post_no`.
      </ResponseField>

      <ResponseField name="post_no" type="integer" required>
        Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.
      </ResponseField>

      <ResponseField name="status" type="string" required>
        `scored` = the post has a score. `waiting` = it is still inside its 15-minute wait. `not_scored` = it cannot be scored, see `reason`. One of `scored`, `waiting`, `not_scored`.
      </ResponseField>

      <ResponseField name="score" type="number | null">
        The publish score: `w_v·P(views) + w_c·P(comments) + w_u·P(up) − w_d·downvote_share`, with the weights in force. `null` unless `status` is `scored`.
      </ResponseField>

      <ResponseField name="p_views" type="number | null">
        Chance that the post lands in the top quarter for views within its tier, day and time block. `null` unless scored.
      </ResponseField>

      <ResponseField name="p_comments" type="number | null">
        Same, for comments. `null` unless scored.
      </ResponseField>

      <ResponseField name="p_up" type="number | null">
        Same, for up-votes. `null` unless scored.
      </ResponseField>

      <ResponseField name="downvote_share" type="number | null">
        Predicted down-vote share. `null` unless scored.
      </ResponseField>

      <ResponseField name="scored_at_cycle" type="string">
        The cycle at which the score was taken. Present when `status` is `scored`. A score is taken once and repeated unchanged in later cycles until the post is removed.
      </ResponseField>

      <ResponseField name="due_cycle" type="string">
        The cycle at which a waiting post will be scored. Present when `status` is `waiting`.
      </ResponseField>

      <ResponseField name="reason" type="string">
        Present when `status` is `not_scored`. `missing_title`: the title is empty. `missing_clock`: `created_at` or `extracted_at` is missing. `post_unavailable`: the latest engagement status is `missing` or `nomap`. `unknown_post`: the post appears in `engagement` without a candidate record. One of `missing_title`, `missing_clock`, `post_unavailable`, `unknown_post`.
      </ResponseField>
    </Expandable>
  </ResponseField>

  ***
</div>

<RequestExample>
  ```bash cURL theme={null}
  gzip -c cycle.json | curl --request POST \
    --url "https://rtb.example.com/v1/cycles" \
    --header "Authorization: <api-key>" \
    --header "Content-Type: application/json" \
    --header "Content-Encoding: gzip" \
    --data-binary @-
  ```

  ```python Python theme={null}
  import gzip, json
  import requests

  body = json.load(open("cycle.json"))

  response = requests.post(
      "https://rtb.example.com/v1/cycles",
      headers={
          "Authorization": "<api-key>",
          "Content-Type": "application/json",
          "Content-Encoding": "gzip",
      },
      data=gzip.compress(json.dumps(body).encode("utf-8")),
      timeout=60,
  )
  scores = response.json()
  ```

  ```javascript JavaScript theme={null}
  import { readFileSync } from "node:fs";
  import { gzipSync } from "node:zlib";

  const response = await fetch("https://rtb.example.com/v1/cycles", {
    method: "POST",
    headers: {
      Authorization: "<api-key>",
      "Content-Type": "application/json",
      "Content-Encoding": "gzip",
    },
    body: gzipSync(readFileSync("cycle.json")),
    signal: AbortSignal.timeout(60_000),
  });
  const scores = await response.json();
  ```

  ```json Request body theme={null}
  {
    "cycle_id": "20260929T0205",
    "cycle_time": "2026-09-29T02:05:00+09:00",
    "candidates": [
      {
        "gall_id": "example_gallery",
        "post_no": 31900001,
        "title": "Example original title",
        "created_at": "2026-09-29T01:58:12+09:00",
        "extracted_at": "2026-09-29T02:03:40+09:00",
        "source": "keyword",
        "manual_flag": 0,
        "images_in_post": 2
      }
    ],
    "engagement": [
      {
        "gall_id": "example_gallery",
        "post_no": 31900001,
        "observed_at": "2026-09-29T02:04:00+09:00",
        "status": "ok",
        "views": 412,
        "up": 9,
        "down": 1,
        "comment_cnt": 6,
        "rtb_vote": 0
      },
      {
        "gall_id": "example_gallery",
        "post_no": 31899120,
        "observed_at": "2026-09-29T02:04:00+09:00",
        "status": "ok",
        "views": 1875,
        "up": null,
        "down": null,
        "comment_cnt": 21,
        "rtb_vote": 0
      }
    ],
    "candidate_scores": [
      {
        "gall_id": "example_gallery",
        "post_no": 31900001,
        "snapshot_at": "2026-09-29T02:05:00+09:00",
        "recommend_up_cnt": 3,
        "recommend_up_cnt_m": 2,
        "recommend_up_cnt_a": 1,
        "comment_cnt": 2,
        "comment_cnt_m": 1,
        "comment_cnt_a": 0,
        "hit_cnt": 5,
        "hit_cnt_m": 3,
        "hit_cnt_a": 1,
        "dcbest_cnt": 0,
        "dcbest_cnt_m": 0,
        "dcbest_cnt_a": 0,
        "memo_size": 1840,
        "upimg_cnt": 2,
        "upimg_height": 2410
      }
    ],
    "removals": [
      {
        "gall_id": "example_gallery",
        "post_no": 31898050,
        "reason": "hidden",
        "decided_at": "2026-09-29T02:01:10+09:00"
      }
    ]
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 200 — one post scored now, one waiting theme={null}
  {
    "cycle_id": "20260929T0205",
    "cycle_time": "2026-09-29T02:05:00+09:00",
    "received_at": "2026-09-29T02:05:03+09:00",
    "scored_at": "2026-09-29T02:05:09+09:00",
    "model_version": "m0924-1",
    "weights_version": "w-1",
    "counts": {
      "candidates": 1,
      "engagement": 2,
      "candidate_scores": 1,
      "removals": 1,
      "scored_now": 1,
      "waiting": 1,
      "not_scored": 0,
      "results": 2
    },
    "missing_feature_share": 0.11,
    "warnings": [],
    "results": [
      {
        "gall_id": "example_gallery",
        "post_no": 31899120,
        "status": "scored",
        "score": 0.62,
        "p_views": 0.62,
        "p_comments": 0.41,
        "p_up": 0.37,
        "downvote_share": 0.08,
        "scored_at_cycle": "2026-09-29T02:05:00+09:00"
      },
      {
        "gall_id": "example_gallery",
        "post_no": 31900001,
        "status": "waiting",
        "score": null,
        "due_cycle": "2026-09-29T02:20:00+09:00"
      }
    ]
  }
  ```

  ```json 200 — next cycle, an earlier score repeated, one post not scored theme={null}
  {
    "cycle_id": "20260929T0210",
    "cycle_time": "2026-09-29T02:10:00+09:00",
    "received_at": "2026-09-29T02:10:02+09:00",
    "scored_at": "2026-09-29T02:10:03+09:00",
    "model_version": "m0924-1",
    "weights_version": "w-1",
    "counts": {
      "candidates": 1,
      "engagement": 3,
      "candidate_scores": 3,
      "removals": 0,
      "scored_now": 0,
      "waiting": 1,
      "not_scored": 1,
      "results": 3
    },
    "missing_feature_share": 0.0,
    "warnings": [
      "candidates[0]: ignored unknown field \"channel\""
    ],
    "results": [
      {
        "gall_id": "example_gallery",
        "post_no": 31899120,
        "status": "scored",
        "score": 0.62,
        "p_views": 0.62,
        "p_comments": 0.41,
        "p_up": 0.37,
        "downvote_share": 0.08,
        "scored_at_cycle": "2026-09-29T02:05:00+09:00"
      },
      {
        "gall_id": "example_gallery",
        "post_no": 31900001,
        "status": "waiting",
        "score": null,
        "due_cycle": "2026-09-29T02:20:00+09:00"
      },
      {
        "gall_id": "example_gallery",
        "post_no": 31900044,
        "status": "not_scored",
        "score": null,
        "reason": "missing_title"
      }
    ]
  }
  ```

  ```json 400 — fields failed validation theme={null}
  {
    "error": {
      "code": "bad_request",
      "message": "2 fields failed validation",
      "fields": [
        {
          "path": "candidates[0].created_at",
          "problem": "missing +09:00 offset"
        },
        {
          "path": "engagement[3].views",
          "problem": "must be a non-negative integer or null"
        }
      ]
    }
  }
  ```

  ```json 409 — cycle id reused with a different body theme={null}
  {
    "error": {
      "code": "cycle_conflict",
      "message": "cycle 20260929T0205 was already received with a different body"
    }
  }
  ```
</ResponseExample>


## OpenAPI

````yaml openapi.json POST /v1/cycles
openapi: 3.0.3
info:
  title: DCinside RTB API
  description: >-
    Publish scores for DCinside's Real-Time Best candidate pool. DCinside makes
    one call per five-minute cycle and reads the scores in the response. Version
    1 is a proposal for the technical exchange with DCinside: calls, field names
    and limits can change until both sides agree.
  version: 1.0.0
servers:
  - url: https://{host}
    description: Issued by Surf together with the API key.
    variables:
      host:
        default: rtb.example.com
        description: >-
          Host name issued by Surf for the environment (rehearsal or
          production). The default is a placeholder.
security:
  - ApiKey: []
tags:
  - name: Scoring
    description: One call per five-minute cycle, and re-reading a stored cycle.
  - name: Images
    description: Post images, uploaded once per post, separately from scoring.
  - name: Outcomes
    description: Not in MVP scope. How placed posts performed. Used for learning.
  - name: Weights
    description: Not in MVP scope. The weights of the publish score.
  - name: Health
    description: Service state.
paths:
  /v1/cycles:
    post:
      tags:
        - Scoring
      summary: Score a Cycle
      description: >-
        One call per five-minute cycle. Sends new candidates, the current
        engagement of every pending candidate, the admin candidate-score rows
        and removals, and returns one row per pending candidate with its status
        and publish score. The same `cycle_id` with the same body returns the
        stored response and never scores twice.
      operationId: scoreCycle
      parameters:
        - name: Content-Encoding
          in: header
          required: false
          schema:
            type: string
            enum:
              - gzip
          description: >-
            Send `gzip` when the body is gzip-compressed. Recommended for this
            call: a busy cycle is about 0.6 MB of JSON.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CycleRequest'
            example:
              cycle_id: 20260929T0205
              cycle_time: '2026-09-29T02:05:00+09:00'
              candidates:
                - gall_id: example_gallery
                  post_no: 31900001
                  title: Example original title
                  created_at: '2026-09-29T01:58:12+09:00'
                  extracted_at: '2026-09-29T02:03:40+09:00'
                  source: keyword
                  manual_flag: 0
                  images_in_post: 2
              engagement:
                - gall_id: example_gallery
                  post_no: 31900001
                  observed_at: '2026-09-29T02:04:00+09:00'
                  status: ok
                  views: 412
                  up: 9
                  down: 1
                  comment_cnt: 6
                  rtb_vote: 0
                - gall_id: example_gallery
                  post_no: 31899120
                  observed_at: '2026-09-29T02:04:00+09:00'
                  status: ok
                  views: 1875
                  up: null
                  down: null
                  comment_cnt: 21
                  rtb_vote: 0
              candidate_scores:
                - gall_id: example_gallery
                  post_no: 31900001
                  snapshot_at: '2026-09-29T02:05:00+09:00'
                  recommend_up_cnt: 3
                  recommend_up_cnt_m: 2
                  recommend_up_cnt_a: 1
                  comment_cnt: 2
                  comment_cnt_m: 1
                  comment_cnt_a: 0
                  hit_cnt: 5
                  hit_cnt_m: 3
                  hit_cnt_a: 1
                  dcbest_cnt: 0
                  dcbest_cnt_m: 0
                  dcbest_cnt_a: 0
                  memo_size: 1840
                  upimg_cnt: 2
                  upimg_height: 2410
              removals:
                - gall_id: example_gallery
                  post_no: 31898050
                  reason: hidden
                  decided_at: '2026-09-29T02:01:10+09:00'
      responses:
        '200':
          description: The scores for this cycle.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CycleResponse'
              examples:
                scored_and_waiting:
                  summary: One post scored now, one waiting
                  value:
                    cycle_id: 20260929T0205
                    cycle_time: '2026-09-29T02:05:00+09:00'
                    received_at: '2026-09-29T02:05:03+09:00'
                    scored_at: '2026-09-29T02:05:09+09:00'
                    model_version: m0924-1
                    weights_version: w-1
                    counts:
                      candidates: 1
                      engagement: 2
                      candidate_scores: 1
                      removals: 1
                      scored_now: 1
                      waiting: 1
                      not_scored: 0
                      results: 2
                    missing_feature_share: 0.11
                    warnings: []
                    results:
                      - gall_id: example_gallery
                        post_no: 31899120
                        status: scored
                        score: 0.62
                        p_views: 0.62
                        p_comments: 0.41
                        p_up: 0.37
                        downvote_share: 0.08
                        scored_at_cycle: '2026-09-29T02:05:00+09:00'
                      - gall_id: example_gallery
                        post_no: 31900001
                        status: waiting
                        score: null
                        due_cycle: '2026-09-29T02:20:00+09:00'
                repeated_and_not_scored:
                  summary: >-
                    The next cycle: an earlier score repeated, one post not
                    scored
                  value:
                    cycle_id: 20260929T0210
                    cycle_time: '2026-09-29T02:10:00+09:00'
                    received_at: '2026-09-29T02:10:02+09:00'
                    scored_at: '2026-09-29T02:10:03+09:00'
                    model_version: m0924-1
                    weights_version: w-1
                    counts:
                      candidates: 1
                      engagement: 3
                      candidate_scores: 3
                      removals: 0
                      scored_now: 0
                      waiting: 1
                      not_scored: 1
                      results: 3
                    missing_feature_share: 0
                    warnings:
                      - 'candidates[0]: ignored unknown field "channel"'
                    results:
                      - gall_id: example_gallery
                        post_no: 31899120
                        status: scored
                        score: 0.62
                        p_views: 0.62
                        p_comments: 0.41
                        p_up: 0.37
                        downvote_share: 0.08
                        scored_at_cycle: '2026-09-29T02:05:00+09:00'
                      - gall_id: example_gallery
                        post_no: 31900001
                        status: waiting
                        score: null
                        due_cycle: '2026-09-29T02:20:00+09:00'
                      - gall_id: example_gallery
                        post_no: 31900044
                        status: not_scored
                        score: null
                        reason: missing_title
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/TooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
        '503':
          $ref: '#/components/responses/ServerError'
components:
  schemas:
    CycleRequest:
      type: object
      properties:
        cycle_id:
          type: string
          pattern: ^\d{8}T\d{4}$
          example: 20260929T0205
          description: >-
            Idempotency key: the cycle time in KST as `YYYYMMDDTHHMM`, for
            example `20260929T0205`. The same id with the same body returns the
            stored response. The same id with a different body returns 409.
        cycle_time:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:[0-5][05]:00\+09:00$
          description: >-
            The five-minute boundary this call belongs to. Scores are taken as
            of this time, and the model's hour-of-day and day-of-week inputs are
            read from it in KST. A value off the grid or in the future gets 422.
          example: '2026-09-29T02:05:00+09:00'
        candidates:
          type: array
          maxItems: 200
          items:
            $ref: '#/components/schemas/Candidate'
          description: >-
            Posts that became candidates since the last successful call. Each
            post is sent once. May be empty.
        engagement:
          type: array
          maxItems: 5000
          items:
            $ref: '#/components/schemas/EngagementRow'
          description: >-
            One row for every pending candidate, every cycle. Pending means sent
            in this or an earlier call and not yet in `removals`.
        candidate_scores:
          type: array
          maxItems: 5000
          items:
            $ref: '#/components/schemas/CandidateScoreRow'
          description: >-
            The admin candidate-score table's row for every pending candidate
            that has one. May be empty.
        removals:
          type: array
          maxItems: 5000
          items:
            $ref: '#/components/schemas/Removal'
          description: >-
            Posts leaving the pending pool since the last successful call. May
            be empty.
      required:
        - cycle_id
        - cycle_time
        - candidates
        - engagement
        - candidate_scores
        - removals
    CycleResponse:
      type: object
      properties:
        cycle_id:
          type: string
          pattern: ^\d{8}T\d{4}$
          example: 20260929T0205
          description: Echoed from the request.
        cycle_time:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:[0-5][05]:00\+09:00$
          description: Echoed from the request.
          example: '2026-09-29T02:05:00+09:00'
        received_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: Our clock when the call arrived.
          example: '2026-09-29T02:05:03+09:00'
        scored_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            Our clock when scoring finished. The gap from `received_at` is the
            scoring time.
          example: '2026-09-29T02:05:09+09:00'
        model_version:
          type: string
          example: m0924-1
          description: >-
            The model that produced the scores. Every score can be traced to it
            and to `weights_version`.
        weights_version:
          type: string
          example: w-1
          description: The publish-score weights in force for this cycle.
        counts:
          $ref: '#/components/schemas/Counts'
        missing_feature_share:
          type: number
          minimum: 0
          maximum: 1
          example: 0.11
          description: >-
            Share of the model's 62 tabular inputs that were missing across the
            posts scored in this cycle. A jump means bad data upstream.
        warnings:
          type: array
          items:
            type: string
          example: []
          description: >-
            Extra fields we ignored, and anything else worth a look. Empty when
            clean.
        results:
          type: array
          items:
            $ref: '#/components/schemas/ResultRow'
          description: >-
            One row per pending candidate. Scores given earlier are repeated
            unchanged, so DCinside keeps no score memory of its own. Removed
            posts are not returned.
      required:
        - cycle_id
        - cycle_time
        - received_at
        - scored_at
        - model_version
        - weights_version
        - counts
        - missing_feature_share
        - warnings
        - results
    Candidate:
      type: object
      properties:
        gall_id:
          type: string
          minLength: 1
          description: Post key, together with `post_no`.
          example: example_gallery
        post_no:
          type: integer
          minimum: 1
          description: >-
            Post number. Unique only within a gallery, so a post is always keyed
            by `gall_id` and `post_no` together.
          example: 31900001
        title:
          type: string
          example: Example original title
          description: >-
            Title as extracted, before any operator edit. The model embeds it
            (Unicode NFC, first 2,000 characters) and reads its length, and
            training used extraction-original titles only. An empty title is not
            scored. Delivered files: `queue.subject`.
        created_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the post was created. Post age at scoring is an input.
            Delivered files: `queue.created_at`.
          example: '2026-09-29T01:58:12+09:00'
        extracted_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the post entered the extraction queue. Queue age, and the
            15-minute wait before scoring, count from it. Delivered files:
            `queue.extracted_at`.
          example: '2026-09-29T02:03:40+09:00'
        source:
          type: string
          enum:
            - score
            - setting
            - keyword
            - vote
            - manual
            - unknown
          example: keyword
          description: >-
            How the post reached the queue. A categorical input. `unknown`
            appears only in the delivered files (posts first captured after
            processing), so live posts should carry a real source. Delivered
            files: `queue.source`.
        manual_flag:
          type: integer
          enum:
            - 0
            - 1
          example: 0
          description: >-
            `1` when the post was registered by hand from external monitoring.
            An input. Delivered files: `queue.manual_flag`.
        images_in_post:
          type: integer
          minimum: 0
          example: 2
          description: >-
            How many images the original body has. Image positions run from 1 to
            this number. Not a model input: it lets us tell a late upload from a
            missing one. Delivered files: `image_manifest.images_in_post`
            (v1.3).
        image_positions:
          type: array
          uniqueItems: true
          items:
            type: integer
            minimum: 1
          example:
            - 1
            - 2
          description: >-
            The positions DCinside will upload, when some images cannot be
            fetched. Default: 1 to `images_in_post`. In v1.3, 509 of 559,329
            images (0.09%) came back empty or with a 403, so gaps happen.
            Delivered files: `image_manifest.image_index` (v1.3).
        body_html:
          type: string
          example: <p>Example body</p>
          description: >-
            Original body. The current model does not read it. DCinside agreed
            to send the text once, and later model versions or the review sample
            may use it. Delivered files: `queue.body_html`.
        gallery_type:
          type: string
          enum:
            - main
            - minor
            - mini
          example: minor
          description: >-
            We look gallery type up in the gallery registry we hold. Send it for
            galleries added since that registry was built. A gallery missing
            from both is treated as unknown.
        category_id:
          type: integer
          minimum: 1
          maximum: 47
          example: 12
          description: >-
            Gallery category. Same rule as `gallery_type`: we read it from the
            registry, and you send it for galleries added since.
      required:
        - gall_id
        - post_no
        - title
        - created_at
        - extracted_at
        - source
        - manual_flag
        - images_in_post
      description: >-
        A post that became a candidate. Sent once, in the first call after it
        enters the queue.
    EngagementRow:
      type: object
      properties:
        gall_id:
          type: string
          minLength: 1
          description: Post key, together with `post_no`.
          example: example_gallery
        post_no:
          type: integer
          minimum: 1
          description: >-
            Post number. Unique only within a gallery, so a post is always keyed
            by `gall_id` and `post_no` together.
          example: 31900001
        observed_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the counters were read from the gallery, to the minute, at or
            before `cycle_time`. The model takes the latest row at or before the
            cycle, and its growth features need that row within 10 minutes of
            the cycle. Delivered files: `engagement_ts.ts`.
          example: '2026-09-29T02:04:00+09:00'
        status:
          type: string
          enum:
            - ok
            - missing
            - nomap
          example: ok
          description: >-
            `ok` = read normally, `missing` = the post is gone, `nomap` = the
            gallery could not be mapped. A categorical input. A post whose
            latest status is `missing` or `nomap` is not scored.
        views:
          type: integer
          minimum: 0
          description: >-
            Current view count. Inputs: the value, up-per-view,
            comments-per-view, and its rate and acceleration over 5, 15 and 30
            minutes, which we compute from the series.
          example: 412
          nullable: true
        up:
          type: integer
          minimum: 0
          description: >-
            Recommendations, not the Send-to-Best counter. In v1 and v1.1 of the
            delivered files this column held the wrong counter, and v1.2
            corrected it.
          example: 9
          nullable: true
        down:
          type: integer
          minimum: 0
          description: Down-votes. Feeds the down-vote share input.
          example: 1
          nullable: true
        comment_cnt:
          type: integer
          minimum: 0
          description: Comment count.
          example: 6
          nullable: true
        rtb_vote:
          type: integer
          minimum: 0
          description: The Send-to-Best vote counter, separate from `up`.
          example: 0
          nullable: true
      required:
        - gall_id
        - post_no
        - observed_at
        - status
        - views
        - up
        - down
        - comment_cnt
        - rtb_vote
      description: Current counters of one pending post, as read from the gallery.
    CandidateScoreRow:
      type: object
      properties:
        gall_id:
          type: string
          minLength: 1
          description: Post key, together with `post_no`.
          example: example_gallery
        post_no:
          type: integer
          minimum: 1
          description: >-
            Post number. Unique only within a gallery, so a post is always keyed
            by `gall_id` and `post_no` together.
          example: 31900001
        snapshot_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the admin candidate-score table was read. The model uses the
            latest snapshot at or before the cycle, and how old it is is an
            input. Delivered files: `candidate_score_ts.snapshot_ts`.
          example: '2026-09-29T02:05:00+09:00'
        recommend_up_cnt:
          type: integer
          minimum: 0
          description: >-
            Over-baseline recommendations, PC part. The model reads the sum of
            the PC, mobile and app parts, and that sum is missing if any one
            part is `null`.
          example: 3
          nullable: true
        recommend_up_cnt_m:
          type: integer
          minimum: 0
          description: >-
            Over-baseline recommendations, mobile part. The model reads the sum
            of the PC, mobile and app parts, and that sum is missing if any one
            part is `null`.
          example: 2
          nullable: true
        recommend_up_cnt_a:
          type: integer
          minimum: 0
          description: >-
            Over-baseline recommendations, app part. The model reads the sum of
            the PC, mobile and app parts, and that sum is missing if any one
            part is `null`.
          example: 1
          nullable: true
        comment_cnt:
          type: integer
          minimum: 0
          description: >-
            Over-baseline comments, PC part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 2
          nullable: true
        comment_cnt_m:
          type: integer
          minimum: 0
          description: >-
            Over-baseline comments, mobile part. The model reads the sum of the
            PC, mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 1
          nullable: true
        comment_cnt_a:
          type: integer
          minimum: 0
          description: >-
            Over-baseline comments, app part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 1
          nullable: true
        hit_cnt:
          type: integer
          minimum: 0
          description: >-
            Over-baseline views, PC part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 5
          nullable: true
        hit_cnt_m:
          type: integer
          minimum: 0
          description: >-
            Over-baseline views, mobile part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 4
          nullable: true
        hit_cnt_a:
          type: integer
          minimum: 0
          description: >-
            Over-baseline views, app part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 1
          nullable: true
        dcbest_cnt:
          type: integer
          minimum: 0
          description: >-
            Send-to-Best votes, PC part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 0
          nullable: true
        dcbest_cnt_m:
          type: integer
          minimum: 0
          description: >-
            Send-to-Best votes, mobile part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 0
          nullable: true
        dcbest_cnt_a:
          type: integer
          minimum: 0
          description: >-
            Send-to-Best votes, app part. The model reads the sum of the PC,
            mobile and app parts, and that sum is missing if any one part is
            `null`.
          example: 0
          nullable: true
        memo_size:
          type: integer
          minimum: 0
          description: >-
            Body size as the admin table counts it. A model input as delivered,
            so there is nothing to compute on DCinside's side.
          example: 1840
          nullable: true
        upimg_cnt:
          type: integer
          minimum: 0
          description: Uploaded image count from the same table. An input.
          example: 2
          nullable: true
        upimg_height:
          type: integer
          minimum: 0
          maximum: 32767
          nullable: true
          example: 2410
          description: >-
            Total pixel height of the uploaded images, capped at 32,767. An
            input.
      required:
        - gall_id
        - post_no
        - snapshot_at
      description: >-
        The admin candidate-score table's row for one pending post. The
        delivered table also has `is_extract`, `is_extract_g`, `category` and
        `regdate`, which the model does not read.
    Removal:
      type: object
      properties:
        gall_id:
          type: string
          minLength: 1
          description: Post key, together with `post_no`.
          example: example_gallery
        post_no:
          type: integer
          minimum: 1
          description: >-
            Post number. Unique only within a gallery, so a post is always keyed
            by `gall_id` and `post_no` together.
          example: 31900001
        reason:
          type: string
          enum:
            - saved
            - hidden
            - deleted
            - aged_out
          example: hidden
          description: >-
            Why the post leaves the pending pool: `saved` (placed on a board),
            `hidden`, `deleted`, or `aged_out` (over 24 hours: DCinside applies
            a 24-hour age filter and sends a removal signal when a pending
            candidate crosses it). It stops being scored and stops appearing in
            responses. Delivered files: `queue.outcome`, `deletion`.
        tier:
          type: string
          enum:
            - main
            - light
            - night
            - app
          example: main
          description: Board tier. Required when `reason` is `saved`, otherwise omit.
        decided_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the decision or deletion happened. Kept for reconciliation. Not
            a model input. Delivered files: `queue.decided_at`,
            `deletion.deleted_at`.
          example: '2026-09-29T02:01:10+09:00'
        reason_code:
          type: string
          example: quota_full
          description: >-
            Optional. The code the operator picked in DCinside's reason tool for
            a `saved` or `hidden` decision. The code list is still being agreed.
            Surf proposed `low_quality`, `stale`, `quota_full`, `duplicate`,
            `policy_violation` and `other` for hides.
      required:
        - gall_id
        - post_no
        - reason
        - decided_at
      description: A post leaving the pending pool.
    Counts:
      type: object
      properties:
        candidates:
          type: integer
          minimum: 0
          example: 1
          description: Rows received in `candidates`.
        engagement:
          type: integer
          minimum: 0
          example: 2
          description: Rows received in `engagement`.
        candidate_scores:
          type: integer
          minimum: 0
          example: 1
          description: Rows received in `candidate_scores`.
        removals:
          type: integer
          minimum: 0
          example: 1
          description: Rows received in `removals`.
        scored_now:
          type: integer
          minimum: 0
          example: 1
          description: Posts whose score was taken in this cycle.
        waiting:
          type: integer
          minimum: 0
          example: 1
          description: Results with status `waiting`.
        not_scored:
          type: integer
          minimum: 0
          example: 0
          description: Results with status `not_scored`.
        results:
          type: integer
          minimum: 0
          example: 2
          description: >-
            Rows in `results`: every pending post, including earlier scores
            repeated unchanged.
      required:
        - candidates
        - engagement
        - candidate_scores
        - removals
        - scored_now
        - waiting
        - not_scored
        - results
      description: Row counts for this cycle.
    ResultRow:
      type: object
      properties:
        gall_id:
          type: string
          minLength: 1
          description: Post key, together with `post_no`.
          example: example_gallery
        post_no:
          type: integer
          minimum: 1
          description: >-
            Post number. Unique only within a gallery, so a post is always keyed
            by `gall_id` and `post_no` together.
          example: 31900001
        status:
          type: string
          enum:
            - scored
            - waiting
            - not_scored
          example: scored
          description: >-
            `scored` = the post has a score. `waiting` = it is still inside its
            15-minute wait. `not_scored` = it cannot be scored, see `reason`.
        score:
          type: number
          nullable: true
          example: 0.62
          description: >-
            The publish score: `w_v·P(views) + w_c·P(comments) + w_u·P(up) −
            w_d·downvote_share`, with the weights in force. `null` unless
            `status` is `scored`.
        p_views:
          type: number
          minimum: 0
          maximum: 1
          nullable: true
          example: 0.62
          description: >-
            Chance that the post lands in the top quarter for views within its
            tier, day and time block. `null` unless scored.
        p_comments:
          type: number
          minimum: 0
          maximum: 1
          nullable: true
          example: 0.41
          description: Same, for comments. `null` unless scored.
        p_up:
          type: number
          minimum: 0
          maximum: 1
          nullable: true
          example: 0.37
          description: Same, for up-votes. `null` unless scored.
        downvote_share:
          type: number
          minimum: 0
          maximum: 1
          nullable: true
          example: 0.08
          description: Predicted down-vote share. `null` unless scored.
        scored_at_cycle:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:[0-5][05]:00\+09:00$
          description: >-
            The cycle at which the score was taken. Present when `status` is
            `scored`. A score is taken once and repeated unchanged in later
            cycles until the post is removed.
          example: '2026-09-29T02:05:00+09:00'
        due_cycle:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:[0-5][05]:00\+09:00$
          description: >-
            The cycle at which a waiting post will be scored. Present when
            `status` is `waiting`.
          example: '2026-09-29T02:20:00+09:00'
        reason:
          type: string
          enum:
            - missing_title
            - missing_clock
            - post_unavailable
            - unknown_post
          example: missing_title
          description: >-
            Present when `status` is `not_scored`. `missing_title`: the title is
            empty. `missing_clock`: `created_at` or `extracted_at` is missing.
            `post_unavailable`: the latest engagement status is `missing` or
            `nomap`. `unknown_post`: the post appears in `engagement` without a
            candidate record.
      required:
        - gall_id
        - post_no
        - status
      description: The state of one pending post.
    Error:
      type: object
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
      required:
        - error
    ErrorBody:
      type: object
      properties:
        code:
          type: string
          example: bad_request
          description: Machine-readable code. See Errors.
        message:
          type: string
          example: 2 fields failed validation
          description: Human-readable summary.
        fields:
          type: array
          items:
            $ref: '#/components/schemas/ErrorField'
          description: Every failing field, when the error is about the body.
      required:
        - code
        - message
    ErrorField:
      type: object
      properties:
        path:
          type: string
          example: candidates[0].created_at
          description: Path of the failing field.
        problem:
          type: string
          example: missing +09:00 offset
          description: What is wrong with it.
      required:
        - path
        - problem
  responses:
    BadRequest:
      description: >-
        The body is not valid JSON, or a field fails the schema. The error lists
        every failing path. Nothing is stored.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: bad_request
              message: 2 fields failed validation
              fields:
                - path: candidates[0].created_at
                  problem: missing +09:00 offset
                - path: engagement[3].views
                  problem: must be a non-negative integer or null
    Unauthorized:
      description: The API key is missing or unknown.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unauthorized
              message: API key missing or unknown
    Forbidden:
      description: The key has no scope for this call.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: forbidden
              message: this key cannot call PUT /v1/weights
    Conflict:
      description: The id was already used with a different body. The first body stays.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: cycle_conflict
              message: cycle 20260929T0205 was already received with a different body
    TooLarge:
      description: Over the size or row limits.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: too_large
              message: candidates has 214 rows, the limit is 200
    Unprocessable:
      description: >-
        The body is valid JSON and matches the schema, but the values are not
        acceptable. Nothing is stored.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unprocessable
              message: cycle_time is not on the 5-minute grid
              fields:
                - path: cycle_time
                  problem: must be a multiple of 5 minutes
    RateLimited:
      description: >-
        Too many calls. Wait for `Retry-After` seconds, then repeat the same
        call.
      headers:
        Retry-After:
          description: Seconds to wait.
          schema:
            type: integer
            example: 10
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: rate_limited
              message: too many calls, retry in 10 s
    ServerError:
      description: Our failure. No scores. DCinside skips the cycle and our alert fires.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: server_error
              message: internal error
  securitySchemes:
    ApiKey:
      type: apiKey
      name: Authorization
      in: header
      description: >-
        The API key issued for the environment, sent as the whole header value.
        Two keys are valid during a rotation.

````