> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mka1.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reinforcement learning

> <Badge color="blue" shape="pill">Early access</Badge>

Run a complete GRPO training workload on MKA1 Compute, publish the weights, and compare the result with the base model.


Train an open-weight model against a reward you define, then publish and serve its weights with the MKA1 API.
This example uses a small invoice-extraction task, [TRL's GRPO trainer](https://huggingface.co/docs/trl/v0.23.1/en/grpo_trainer), and a finite Compute job.
Compute allocates hardware, records logs, and enforces spending limits. The container generates answers, scores them, updates the model, and uploads checkpoints.

The examples assume an initialized `sdk` client and an authenticated [MKA1 CLI](/docs/cli/authentication).

## What the example does

1. Generates 128 invoice prompts for training and eight separate prompts for evaluation.
2. Loads a pinned revision of Qwen2.5-0.5B-Instruct from the public Hugging Face Hub.
3. Scores JSON validity, currency, and the extracted total.
4. Runs 20 GRPO steps with LoRA adapters on one GPU.
5. Publishes intermediate checkpoints, merged weights, tokenizer files, and before/after evaluation results to MKA1 repositories.

The model downloads use the public Hub. Artifact uploads use an explicitly configured MKA1 endpoint, so setting up output storage does not redirect the base-model download.

## Step 1 - Prepare the complete training image

Save the following two files together, or use the files in [examples/compute/rl](https://github.com/MeetKai/mka1-docs/tree/main/examples/compute/rl).
The training script includes dataset generation, the reward function, training, evaluation, and checkpoint publishing; no additional entry script or reward plugin is required.

<Accordion title="Dockerfile">
  ```dockerfile Dockerfile theme={null}
  FROM python:3.11-slim-bookworm
  RUN apt-get update && apt-get install -y --no-install-recommends bash curl ca-certificates coreutils \
      && rm -rf /var/lib/apt/lists/*
  WORKDIR /opt/workload
  RUN pip install --no-cache-dir \
      torch==2.8.0 transformers==4.56.2 trl==0.23.1 peft==0.17.1 \
      datasets==4.1.1 accelerate==1.10.1 huggingface-hub==0.34.4
  COPY train.py /opt/workload/train.py
  ENV PYTHONUNBUFFERED=1 HF_HUB_DISABLE_XET=1 TOKENIZERS_PARALLELISM=false
  CMD ["python", "/opt/workload/train.py"]
  ```
</Accordion>

<Accordion title="Complete training script">
  ```python train.py theme={null}
  """Small GRPO invoice-extraction experiment for a single Compute GPU job."""
  import argparse
  import hashlib
  import importlib.metadata
  import json
  import os
  from decimal import Decimal, InvalidOperation
  from pathlib import Path

  BASE_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
  BASE_REVISION = "7ae557604adf67be50417f59c2c2f167def9a775"


  def records(start, count):
      """Disjoint deterministic ranges keep training and evaluation separate."""
      return [
          {
              "prompt": (
                  "Extract the total due from this invoice. Return only a JSON object "
                  'with keys "currency" and "total". Currency must be "USD".\n'
                  f"Invoice #{i}: subtotal USD {i}.00; tax USD 2.00; "
                  f"total due USD {i + 2}.00."
              ),
              "expected_total": str(i + 2),
          }
          for i in range(start, start + count)
      ]


  def score(text, expected):
      """Reward valid JSON (0.2), the right currency (0.2), and total (0.6)."""
      try:
          value = json.loads(text.strip())
      except (ValueError, TypeError):
          return 0.0
      if not isinstance(value, dict) or set(value) != {"currency", "total"}:
          return 0.0
      reward = 0.2 + (0.2 if value["currency"] == "USD" else 0.0)
      try:
          correct = not isinstance(value["total"], bool) and Decimal(str(value["total"])) == Decimal(expected)
      except (InvalidOperation, ValueError, TypeError):
          correct = False
      return reward + (0.6 if correct else 0.0)


  def reward(completions, expected_total, **kwargs):
      return [score(text, expected) for text, expected in zip(completions, expected_total, strict=True)]


  def main():
      parser = argparse.ArgumentParser()
      parser.add_argument("--output", default="/workspace/output")
      parser.add_argument("--max-steps", type=int, default=20)
      parser.add_argument("--model", default=BASE_MODEL)
      parser.add_argument("--revision", default=BASE_REVISION)
      parser.add_argument("--cpu-test", action="store_true", help="Local validation only; disables GPU requirement and uses float32.")
      args = parser.parse_args()
      if args.max_steps < 1:
          parser.error("--max-steps must be positive")

      import torch
      from datasets import Dataset
      from huggingface_hub import HfApi
      from peft import LoraConfig
      from transformers import AutoModelForCausalLM, AutoTokenizer, TrainerCallback, set_seed
      from trl import GRPOConfig, GRPOTrainer

      if not args.cpu_test and not torch.cuda.is_available():
          raise RuntimeError("This workload requires an NVIDIA GPU; use --cpu-test only for local validation.")
      # Explicit artifact client: public base-model downloads still use huggingface.co.
      output_repo = os.environ.get("OUTPUT_MODEL_REPO")
      if not output_repo:
          raise RuntimeError("Set OUTPUT_MODEL_REPO to an existing MKA1 repository (org/name).")
      artifact_api = HfApi(endpoint=os.environ.get("ARTIFACT_ENDPOINT", "https://hf.mka1.com"), token=os.environ["ARTIFACT_TOKEN"])
      artifact_api.repo_info(repo_id=output_repo)  # Fail before training if credentials/repository are invalid.
      output = Path(args.output)
      output.mkdir(parents=True, exist_ok=True)
      set_seed(42)
      torch.set_num_threads(min(4, os.cpu_count() or 1))
      tokenizer = AutoTokenizer.from_pretrained(args.model, revision=args.revision)
      if tokenizer.pad_token_id is None:
          tokenizer.pad_token = tokenizer.eos_token
      tokenizer.padding_side = "left"
      tokenizer.model_input_names = ["input_ids", "attention_mask"]
      dtype = torch.float32 if args.cpu_test else (torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float32)
      model = AutoModelForCausalLM.from_pretrained(args.model, revision=args.revision, dtype=dtype)
      model.to("cpu" if args.cpu_test else "cuda")

      def prepare(rows):
          return [{**row, "prompt": tokenizer.apply_chat_template(
              [{"role": "user", "content": row["prompt"]}], tokenize=False, add_generation_prompt=True
          )} for row in rows]

      train_rows, eval_rows = prepare(records(10, 128)), prepare(records(200, 8))
      for name, rows in [("train", train_rows), ("validation", eval_rows)]:
          (output / f"{name}.jsonl").write_text("\n".join(json.dumps(row) for row in rows) + "\n")

      def evaluate(current_model):
          current_model.eval()
          samples = []
          for row in eval_rows:
              inputs = tokenizer(row["prompt"], return_tensors="pt", return_token_type_ids=False).to(current_model.device)
              with torch.inference_mode():
                  generated = current_model.generate(**inputs, max_new_tokens=64, do_sample=False, pad_token_id=tokenizer.pad_token_id)
              answer = tokenizer.decode(generated[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True)
              samples.append({"expected_total": row["expected_total"], "answer": answer, "reward": score(answer, row["expected_total"])})
          return {"exact_match_rate": sum(x["reward"] == 1.0 for x in samples) / len(samples), "samples": samples}

      baseline = evaluate(model)
      config = GRPOConfig(
          output_dir=str(output / "checkpoints"), max_steps=args.max_steps,
          per_device_train_batch_size=4, gradient_accumulation_steps=1,
          num_generations=4, max_prompt_length=192, max_completion_length=64,
          learning_rate=1e-5, beta=0.04, temperature=0.8, use_vllm=False,
          bf16=dtype == torch.bfloat16, fp16=False, use_cpu=args.cpu_test,
          gradient_checkpointing=True, logging_steps=1, save_steps=5,
          save_total_limit=2, report_to="none", seed=42,
      )
      manifest = {
          "model": args.model, "revision": args.revision, "seed": 42,
          "script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
          "packages": {p: importlib.metadata.version(p) for p in ["torch", "transformers", "trl", "peft", "datasets", "huggingface-hub"]},
          "training_config": config.to_dict(), "baseline": baseline,
      }
      (output / "experiment.json").write_text(json.dumps(manifest, indent=2, default=str))

      class PublishCheckpoint(TrainerCallback):
          def on_save(self, args, state, control, **kwargs):
              checkpoint = Path(args.output_dir) / f"checkpoint-{state.global_step}"
              tokenizer.save_pretrained(checkpoint)
              artifact_api.upload_folder(repo_id=output_repo, folder_path=str(checkpoint), path_in_repo=f"checkpoints/step-{state.global_step}")
              print(json.dumps({"event": "checkpoint_published", "step": state.global_step}), flush=True)

      trainer = GRPOTrainer(
          model=model, processing_class=tokenizer, reward_funcs=reward,
          args=config, train_dataset=Dataset.from_list(train_rows),
          peft_config=LoraConfig(r=8, lora_alpha=16, target_modules=["q_proj", "v_proj"], task_type="CAUSAL_LM"),
          callbacks=[PublishCheckpoint()],
      )
      trainer.train()
      trainer.save_model(str(output / "adapter"))
      tokenizer.save_pretrained(output / "adapter")
      merged = trainer.model.merge_and_unload()
      merged.eval()
      metrics = {"baseline": baseline, "trained": evaluate(merged)}
      (output / "evaluation.json").write_text(json.dumps(metrics, indent=2))
      # Root-level merged weights make the output repository directly loadable.
      merged.save_pretrained(output, safe_serialization=True)
      tokenizer.save_pretrained(output)
      artifact_api.upload_folder(repo_id=output_repo, folder_path=str(output), ignore_patterns=["checkpoints/**"])
      print(json.dumps({"event": "model_published", "repo": output_repo, "baseline_exact_match": baseline["exact_match_rate"], "trained_exact_match": metrics["trained"]["exact_match_rate"]}), flush=True)


  if __name__ == "__main__":
      main()
  ```
</Accordion>

Build and publish the image. Replace the registry path with a repository you own:

```bash Build image theme={null}
export MKA1_RL_IMAGE=your-registry/your-account/mka1-invoice-grpo:1
docker buildx build --platform linux/amd64 --tag "$MKA1_RL_IMAGE" --push .
```

Use that exact image reference in the quote and job examples below.
Pin the pushed image by digest when repeating an experiment.

## Step 2 - Create the output repository

Use a new output repository so that the run does not overwrite your base model.
The response includes your organization slug; substitute it for `<org-from-create>` in the job request.

<CodeGroup>
  ```ts TypeScript SDK theme={null}
  const result = await sdk.repos.create({
    "createRepoRequest": {
      "name": "invoice-grpo",
      "description": "GRPO invoice extraction smoke experiment"
    }
  });
  console.log(result.id);
  ```

  ```csharp C# SDK theme={null}
  using MeetKai.MKA1.Types.Components;
  using MeetKai.MKA1.Types.Requests;
  using Newtonsoft.Json;

  // Deserialize the wire-format payload into the SDK request model.
  var body = JsonConvert.DeserializeObject<MeetKai.MKA1.Types.Components.CreateRepoRequest> ("""
  {
    "name": "invoice-grpo",
    "description": "GRPO invoice extraction smoke experiment"
  }
  """)!;

  var response = await sdk.Repos.CreateAsync(body);
  var result = response.Repo
      ?? throw new InvalidOperationException(JsonConvert.SerializeObject(response));
  Console.WriteLine(JsonConvert.SerializeObject(result.Id));
  ```

  ```python Python SDK theme={null}
  import os
  from meetkai_mka1 import models

  result = sdk.repos.create(
      name="invoice-grpo",
      description="GRPO invoice extraction smoke experiment",
  )
  print(result.id)
  ```

  ```bash Bash theme={null}
  curl https://apigw.mka1.com/api/v1/repos \
    --request POST \
    --header "Authorization: Bearer $MKA1_API_KEY" \
    --header 'Content-Type: application/json' \
    --data '{
    "name": "invoice-grpo",
    "description": "GRPO invoice extraction smoke experiment"
  }'
  ```

  ```bash CLI theme={null}
  mka1 repos create \
    --output-format json \
    --body @- <<'JSON'
  {
    "description": "GRPO invoice extraction smoke experiment",
    "name": "invoice-grpo"
  }
  JSON
  ```
</CodeGroup>

## Step 3 - Store the artifact credential

Create a Compute secret containing your MKA1 API key under `ARTIFACT_TOKEN`.
Replace the credential placeholder before running this step, and use the returned secret ID in Step 5.
The image and job command contain no credentials.

<CodeGroup>
  ```ts TypeScript SDK theme={null}
  const result = await sdk.computeSecrets.createComputeSecret({
    "computeSecretCreate": {
      "name": "rl-artifacts",
      "data": {
        "ARTIFACT_TOKEN": "<mka1-api-key>"
      }
    }
  });
  if (result && 'error' in result) throw new Error(JSON.stringify(result.error));
  console.log(result.id);
  ```

  ```csharp C# SDK theme={null}
  using MeetKai.MKA1.Types.Components;
  using MeetKai.MKA1.Types.Requests;
  using Newtonsoft.Json;

  // Deserialize the wire-format payload into the SDK request model.
  var body = JsonConvert.DeserializeObject<MeetKai.MKA1.Types.Components.ComputeSecretCreate> ("""
  {
    "name": "rl-artifacts",
    "data": {
      "ARTIFACT_TOKEN": "<mka1-api-key>"
    }
  }
  """)!;

  var response = await sdk.ComputeSecrets.CreateComputeSecretAsync(body);
  var result = response.ComputeSecret
      ?? throw new InvalidOperationException(JsonConvert.SerializeObject(response));
  Console.WriteLine(JsonConvert.SerializeObject(result.Id));
  ```

  ```python Python SDK theme={null}
  from meetkai_mka1 import models

  result = sdk.compute_secrets.create_compute_secret(
      name="rl-artifacts",
      data={"ARTIFACT_TOKEN": "<mka1-api-key>"},
  )
  if isinstance(result, models.ComputeErrorEnvelope):
      raise RuntimeError(result.error)
  print(result.id)
  ```

  ```bash Bash theme={null}
  curl https://apigw.mka1.com/api/v1/compute/secrets \
    --request POST \
    --header "Authorization: Bearer $MKA1_API_KEY" \
    --header 'Content-Type: application/json' \
    --data '{
    "name": "rl-artifacts",
    "data": {
      "ARTIFACT_TOKEN": "<mka1-api-key>"
    }
  }'
  ```

  ```bash CLI theme={null}
  mka1 compute-secrets create \
    --output-format json \
    --body @- <<'JSON'
  {
    "data": {
      "ARTIFACT_TOKEN": "<mka1-api-key>"
    },
    "name": "rl-artifacts"
  }
  JSON
  ```
</CodeGroup>

## Step 4 - Quote the GPU job

Replace `<your-pushed-image>` with the image you built.
A quote does not reserve capacity. If `available` is false, choose another catalog configuration before proceeding.

<CodeGroup>
  ```ts TypeScript SDK theme={null}
  const result = await sdk.computeCatalog.createComputeQuote({
    "computeQuoteRequest": {
      "resourceType": "job",
      "compute": {
        "accelerator": "nvidia-rtx-4090-24gb",
        "gpuCount": 1,
        "ephemeralDiskGb": 100
      },
      "container": {
        "image": "<your-pushed-image>"
      }
    }
  });
  if (result && 'error' in result) throw new Error(JSON.stringify(result.error));
  console.log(result.available);
  ```

  ```csharp C# SDK theme={null}
  using MeetKai.MKA1.Types.Components;
  using MeetKai.MKA1.Types.Requests;
  using Newtonsoft.Json;

  // Deserialize the wire-format payload into the SDK request model.
  var body = JsonConvert.DeserializeObject<MeetKai.MKA1.Types.Components.ComputeQuoteRequest> ("""
  {
    "resource_type": "job",
    "compute": {
      "accelerator": "nvidia-rtx-4090-24gb",
      "gpu_count": 1,
      "ephemeral_disk_gb": 100
    },
    "container": {
      "image": "<your-pushed-image>"
    }
  }
  """)!;

  var response = await sdk.ComputeCatalog.CreateComputeQuoteAsync(body);
  var result = response.ComputeQuote
      ?? throw new InvalidOperationException(JsonConvert.SerializeObject(response));
  Console.WriteLine(JsonConvert.SerializeObject(result.Available));
  ```

  ```python Python SDK theme={null}
  from meetkai_mka1 import models

  result = sdk.compute_catalog.create_compute_quote(
      resource_type="job",
      compute={
          "accelerator": "nvidia-rtx-4090-24gb",
          "gpu_count": 1,
          "ephemeral_disk_gb": 100,
      },
      container={"image": "<your-pushed-image>"},
  )
  if isinstance(result, models.ComputeErrorEnvelope):
      raise RuntimeError(result.error)
  print(result.available)
  ```

  ```bash Bash theme={null}
  curl https://apigw.mka1.com/api/v1/compute/catalog/quotes \
    --request POST \
    --header "Authorization: Bearer $MKA1_API_KEY" \
    --header 'Content-Type: application/json' \
    --data '{
    "resource_type": "job",
    "compute": {
      "accelerator": "nvidia-rtx-4090-24gb",
      "gpu_count": 1,
      "ephemeral_disk_gb": 100
    },
    "container": {
      "image": "<your-pushed-image>"
    }
  }'
  ```

  ```bash CLI theme={null}
  mka1 compute-catalog create-compute-quote \
    --output-format json \
    --body @- <<'JSON'
  {
    "compute": {
      "accelerator": "nvidia-rtx-4090-24gb",
      "ephemeral_disk_gb": 100,
      "gpu_count": 1
    },
    "container": {
      "image": "<your-pushed-image>"
    },
    "resource_type": "job"
  }
  JSON
  ```
</CodeGroup>

## Step 5 - Start training

Replace the image, output repository, and secret ID with the values from the earlier steps.
Set `MKA1_REQUEST_ID` to a unique value for this experiment. Reuse it only when retrying the identical request.

<CodeGroup>
  ```ts TypeScript SDK theme={null}
  // Keep this value unchanged when retrying this request.
  const idempotencyKey = process.env.MKA1_REQUEST_ID;
  if (!idempotencyKey) throw new Error('Set MKA1_REQUEST_ID');

  const result = await sdk.computeJobs.createJob({
    "idempotencyKey": idempotencyKey,
    "computeJobCreate": {
      "name": "invoice-grpo",
      "compute": {
        "accelerator": "nvidia-rtx-4090-24gb",
        "gpuCount": 1,
        "ephemeralDiskGb": 100
      },
      "container": {
        "image": "<your-pushed-image>",
        "command": [
          "python",
          "/opt/workload/train.py",
          "--max-steps",
          "20"
        ],
        "env": {
          "OUTPUT_MODEL_REPO": "<org-from-create>/invoice-grpo",
          "ARTIFACT_ENDPOINT": "https://hf.mka1.com",
          "HF_HUB_DISABLE_XET": "1"
        },
        "secretEnv": {
          "ARTIFACT_TOKEN": {
            "secretId": "<secret-id-from-create>",
            "key": "ARTIFACT_TOKEN"
          }
        }
      },
      "limits": {
        "maxRuntimeHours": 2,
        "maxCostUsd": 5
      }
    }
  });
  if (result && 'error' in result) throw new Error(JSON.stringify(result.error));
  console.log(result.id);
  ```

  ```csharp C# SDK theme={null}
  using MeetKai.MKA1.Types.Components;
  using MeetKai.MKA1.Types.Requests;
  using Newtonsoft.Json;

  var idempotencyKey = System.Environment.GetEnvironmentVariable("MKA1_REQUEST_ID")
      ?? throw new InvalidOperationException("Set MKA1_REQUEST_ID");

  // Deserialize the wire-format payload into the SDK request model.
  var body = JsonConvert.DeserializeObject<MeetKai.MKA1.Types.Components.ComputeJobCreate> ("""
  {
    "name": "invoice-grpo",
    "compute": {
      "accelerator": "nvidia-rtx-4090-24gb",
      "gpu_count": 1,
      "ephemeral_disk_gb": 100
    },
    "container": {
      "image": "<your-pushed-image>",
      "command": [
        "python",
        "/opt/workload/train.py",
        "--max-steps",
        "20"
      ],
      "env": {
        "OUTPUT_MODEL_REPO": "<org-from-create>/invoice-grpo",
        "ARTIFACT_ENDPOINT": "https://hf.mka1.com",
        "HF_HUB_DISABLE_XET": "1"
      },
      "secret_env": {
        "ARTIFACT_TOKEN": {
          "secret_id": "<secret-id-from-create>",
          "key": "ARTIFACT_TOKEN"
        }
      }
    },
    "limits": {
      "max_runtime_hours": 2,
      "max_cost_usd": 5
    }
  }
  """)!;

  var response = await sdk.ComputeJobs.CreateJobAsync(idempotencyKey, body);
  var result = response.ComputeJob
      ?? throw new InvalidOperationException(JsonConvert.SerializeObject(response));
  Console.WriteLine(JsonConvert.SerializeObject(result.Id));
  ```

  ```python Python SDK theme={null}
  import os
  from meetkai_mka1 import models

  idempotency_key = os.environ["MKA1_REQUEST_ID"]  # Reuse for retries.

  result = sdk.compute_jobs.create_job(
      idempotency_key=idempotency_key,
      name="invoice-grpo",
      compute={
          "accelerator": "nvidia-rtx-4090-24gb",
          "gpu_count": 1,
          "ephemeral_disk_gb": 100,
      },
      container={
          "image": "<your-pushed-image>",
          "command": ["python", "/opt/workload/train.py", "--max-steps", "20"],
          "env": {
              "OUTPUT_MODEL_REPO": "<org-from-create>/invoice-grpo",
              "ARTIFACT_ENDPOINT": "https://hf.mka1.com",
              "HF_HUB_DISABLE_XET": "1",
          },
          "secret_env": {
              "ARTIFACT_TOKEN": {
                  "secret_id": "<secret-id-from-create>",
                  "key": "ARTIFACT_TOKEN",
              }
          },
      },
      limits={"max_runtime_hours": 2, "max_cost_usd": 5},
  )
  if isinstance(result, models.ComputeErrorEnvelope):
      raise RuntimeError(result.error)
  print(result.id)
  ```

  ```bash Bash theme={null}
  curl https://apigw.mka1.com/api/v1/compute/jobs \
    --request POST \
    --header "Authorization: Bearer $MKA1_API_KEY" \
    --header "Idempotency-Key: $MKA1_REQUEST_ID" \
    --header 'Content-Type: application/json' \
    --data '{
    "name": "invoice-grpo",
    "compute": {
      "accelerator": "nvidia-rtx-4090-24gb",
      "gpu_count": 1,
      "ephemeral_disk_gb": 100
    },
    "container": {
      "image": "<your-pushed-image>",
      "command": [
        "python",
        "/opt/workload/train.py",
        "--max-steps",
        "20"
      ],
      "env": {
        "OUTPUT_MODEL_REPO": "<org-from-create>/invoice-grpo",
        "ARTIFACT_ENDPOINT": "https://hf.mka1.com",
        "HF_HUB_DISABLE_XET": "1"
      },
      "secret_env": {
        "ARTIFACT_TOKEN": {
          "secret_id": "<secret-id-from-create>",
          "key": "ARTIFACT_TOKEN"
        }
      }
    },
    "limits": {
      "max_runtime_hours": 2,
      "max_cost_usd": 5
    }
  }'
  ```

  ```bash CLI theme={null}
  mka1 compute-jobs create-job \
    --idempotency-key "$MKA1_REQUEST_ID" \
    --output-format json \
    --body @- <<'JSON'
  {
    "compute": {
      "accelerator": "nvidia-rtx-4090-24gb",
      "ephemeral_disk_gb": 100,
      "gpu_count": 1
    },
    "container": {
      "command": [
        "python",
        "/opt/workload/train.py",
        "--max-steps",
        "20"
      ],
      "env": {
        "OUTPUT_MODEL_REPO": "<org-from-create>/invoice-grpo",
        "ARTIFACT_ENDPOINT": "https://hf.mka1.com",
        "HF_HUB_DISABLE_XET": "1"
      },
      "image": "<your-pushed-image>",
      "secret_env": {
        "ARTIFACT_TOKEN": {
          "key": "ARTIFACT_TOKEN",
          "secret_id": "<secret-id-from-create>"
        }
      }
    },
    "limits": {
      "max_cost_usd": 5,
      "max_runtime_hours": 2
    },
    "name": "invoice-grpo"
  }
  JSON
  ```
</CodeGroup>

`ARTIFACT_TOKEN` is resolved from the stored secret at launch.
`OUTPUT_MODEL_REPO` and `ARTIFACT_ENDPOINT` are read by the provided script.
The script verifies repository access before downloading the base model or starting training.

<Warning>
  Billing starts at allocation. Runtime limits, spending limits, and exhausted budgets can terminate a job without a final upload.
  The script publishes every five training steps and at completion; only completed uploads survive loss of the job's ephemeral disk.
</Warning>

## Step 6 - Monitor and inspect the result

Use the [job status, logs, and events examples](/docs/compute-fine-tune-job#step-4---poll-until-it-finishes) with the returned job ID.
Training metrics appear in user logs. The script emits `checkpoint_published` and `model_published` messages after successful uploads.
A failed upload makes the workload fail instead of reporting success with missing weights.

Download the output using the [repository artifact tools](/docs/repositories#push-and-pull-with-the-hugging-face-cli):

```bash Download results theme={null}
HF_ENDPOINT=https://hf.mka1.com HF_TOKEN="$MKA1_API_KEY" HF_HUB_DISABLE_XET=1 \
  hf download YOUR_ORG/invoice-grpo --local-dir ./invoice-grpo
```

The repository contains:

| File or folder                                      | Contents                                                                                     |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `model.safetensors`, `config.json`, tokenizer files | Merged model ready for a compatible serving runtime.                                         |
| `adapter/`                                          | LoRA adapter and tokenizer.                                                                  |
| `checkpoints/step-N/`                               | Intermediate checkpoints uploaded during training.                                           |
| `train.jsonl`, `validation.jsonl`                   | Exact prompts and expected totals used by the run.                                           |
| `experiment.json`                                   | Model revision, package versions, script hash, training configuration, and baseline results. |
| `evaluation.json`                                   | Baseline and trained-model answers and exact-match scores.                                   |

Compare the same held-out prompts before and after training.
Higher training reward alone does not establish better extraction; inspect errors and the exact-match score.
Twenty steps and eight validation prompts are a workflow check, not a benchmark.

## Step 7 - Serve or continue experimenting

Follow [Deploy a model server](/docs/compute-deployment#serve-a-model-from-mka1-repos) with your output repository as the model identifier.
The output already contains merged weights, so no separate LoRA merge is needed.
Creating a training job does not automatically deploy the model or register it for the Responses API.

Terminate any serving service when finished. Keep output artifacts until you have inspected them.
To train on your own task, replace `records()` and `score()` while keeping a separate evaluation set and the checkpoint export path.
The provided script starts a new run; it does not implement automatic resume.

## Validation boundary

* TypeScript and C# snippets are compiled against the pinned SDK releases.
* SDK requests are checked offline for their URL, headers, and serialized body.
* The workload's local test uses a tiny random model and a local artifact client; it exercises execution and does not measure learning quality or provider compatibility.
* A real GPU training run, image pull, and MKA1 artifact upload still need an end-to-end pilot.

## See also

* [Manage resources](/docs/compute-resources) - inspect capacity, workloads, secrets, and volumes.

* [Run a fine-tune job](/docs/compute-fine-tune-job)

* [Deploy a model server](/docs/compute-deployment)

* [Manage repositories](/docs/repositories)

* [API reference](/api-reference/introduction)
