# Fine-tuning

`june/train` fine-tunes models through a small, provider-neutral contract. June orchestrates training; it doesn't train anything itself. The weights, GPUs and provider APIs belong to a **trainer**.

## When to fine-tune

Fine-tuning isn't the default way to improve an agent. Fix prompting, retrieval, tool behavior, data quality and eval coverage first. Fine-tune when held-out evals show a stable behavior gap and you have enough representative examples to teach it.

Before you train, check each of these:

1. **There's an [eval suite](https://june.dev/docs/training/evals.md) for the behavior,** and the current model has a baseline on it. If the baseline already does well, stop.
2. **Cheaper candidates have plateaued.** A better prompt, examples in the prompt, retrieval, a tool fix or another model were each [compared](https://june.dev/docs/training/comparing.md) on the same suite, and the gap stayed.
3. **The gap is stable.** It shows on held-out cases across runs, not on one noisy run or a handful of cases.
4. **You have enough good examples** of the behavior, split so the test set is [never trained on](https://june.dev/docs/training/datasets.md#deterministic-splits). Fine-tuning suits behavior that's hard to describe but easy to show: a consistent style, format or voice.
5. **You can serve and keep the result.** A tuned model is one more artifact to host, evaluate and replace; see [Serving the artifact](#serving-the-artifact).

Don't fine-tune to teach facts that change (retrieve them), to fix a tool the model calls wrong (fix the tool or its description), or because a training loss went down. A lower loss says the model fits its training data, not that the product got better; only the held-out comparison says that.

After training, evaluate the tuned model on the same held-out cases and [compare](https://june.dev/docs/training/comparing.md#comparing-a-fine-tune) it with the baseline before anyone promotes it. June doesn't decide when to train or whether a tuned model is better; it gives you the data, the job and the evidence to decide with.

## What June owns, and what trainers own

| June                                           | The trainer                                           |
| ---------------------------------------------- | ----------------------------------------------------- |
| Canonical datasets and their fingerprints      | Converting examples to its own format                 |
| Checking the trainer supports the method       | Running the training, wherever it runs                |
| Starting the job and polling it until it ends  | Reporting the job's status and progress               |
| Canceling the job when your `signal` aborts    | Stopping the job when asked                           |
| A JSON manifest of what was trained, from what | The artifact itself, and any provider-side provenance |

Swapping one trainer for another doesn't change the dataset, the call or the result's shape. It can change everything else: which methods and example shapes are supported, which hyperparameters are honored, what kind of artifact comes back, and where your data goes. See [Local or managed](https://june.dev/docs/training.md#local-or-managed).

## The lifecycle

A fine-tune goes through the same steps whichever trainer runs it:

1. **Check.** June normalizes `method` to a config object and rejects, before anything starts, if the trainer's `capabilities.methods` doesn't include it.
2. **Fingerprint.** June summarizes the train and validation datasets: their `source`, size and fingerprint.
3. **Start.** The trainer converts the examples to its own format, checks what it can't train on, and starts a job. It returns a JSON reference to that job.
4. **Poll.** June asks the trainer for the job's status every `pollInterval` until it's `succeeded`, `failed` or `canceled`, reporting each status to `onStatus`. The trainer's backend owns the job's real state; June keeps only the reference.
5. **Finish.** On success June checks the artifact's type is in the trainer's `capabilities.artifacts`, and builds the [manifest](#the-manifest), which it returns with the artifact for you to store. On failure or cancellation it rejects with a `JobFailedError`.

Steps 1 to 3 are `startFineTune()`; 4 and 5 are its job's `result()`. Because the job is a [reference you can save](https://june.dev/docs/training/jobs.md), the steps can happen in different processes.

## `fineTune(options)`

```ts
import { dataset, fineTune } from "june/train";

const result = await fineTune({
  name: "fonzie",
  model: "Qwen/Qwen3-8B",
  trainer,
  dataset: dataset([
    { type: "completion", prompt: "Say hi", completion: "Hey there." },
  ]),
  method: { type: "lora", rank: 32 },
});

result.artifact; // what the trainer produced
result.manifest; // how it was produced
```

| Option            |                                                                                                                                       |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `model`           | the base model, in whatever form the trainer expects                                                                                  |
| `trainer`         | a `Trainer`                                                                                                                           |
| `dataset`         | a [`Dataset`](https://june.dev/docs/training/datasets.md), or `{ train, validation }`                                                                              |
| `method`          | `"sft"`, `"lora"`, `"qlora"`, `"dpo"`, `"preference"`, `"reinforcement"`, or a config such as `{ type: "lora", rank: 32, alpha: 64 }` |
| `config`          | `epochs`, `learningRate`, `batchSize`, `maxSequenceLength`, `seed`                                                                    |
| `name`            | a name for the run, passed to the trainer and recorded in the manifest                                                                |
| `providerOptions` | options for one trainer, namespaced by its id: `{ vertex: { adapterSize: 4 } }`                                                       |
| `signal`          | cancels the job and rejects with the abort reason                                                                                     |
| `pollInterval`    | milliseconds between status checks (5000 by default)                                                                                  |
| `onStatus`        | called with the job's state and progress after every check                                                                            |

`fineTune()` rejects before starting anything when the trainer's `capabilities.methods` doesn't include the method. It rejects with a `JobFailedError` when the job fails or is canceled; `error.job` holds the job's ref, state and the trainer's error.

It resolves with:

```ts
import type {
  ModelArtifact,
  TrainingJobSnapshot,
  TrainingManifest,
} from "june/train";

interface FineTuneResult {
  artifact: ModelArtifact;
  metrics?: Record<string, number>; // as the trainer reported them
  manifest: TrainingManifest;
  job: TrainingJobSnapshot; // { id, trainer, state, progress?, error? }
}
```

## Long-running fine-tunes

A fine-tune can take hours, longer than the process that started it. `startFineTune()` takes the same options as `fineTune()` without the waiting ones, starts the job and returns a [`Job`](https://june.dev/docs/training/jobs.md) right away. Save its `ref`, and rebuild the job from it with `fineTuneJob()` in any process, with the same trainer:

```ts
import {
  dataset,
  fineTuneJob,
  startFineTune,
  type FineTuneRef,
} from "june/train";

declare function save(ref: FineTuneRef): Promise<void>;
declare function load(): Promise<FineTuneRef>;

const started = await startFineTune({
  model: "Qwen/Qwen3-8B",
  trainer,
  dataset: dataset("./fonzie.jsonl"),
  method: "lora",
});
await save(started.ref);

// later, maybe after a restart
const job = fineTuneJob(trainer, await load());
await job.status(); // { state: "running", progress: { fraction: 0.4 } }
const { artifact, manifest } = await job.result();
```

The ref is plain JSON: the trainer's job, plus the run's name, base model, method, config, dataset summaries and start time, so the manifest comes out the same however many processes it took. `fineTune()` is `startFineTune()` followed by `job.result()`, plus canceling the job if its `signal` aborts.

## Artifacts

A fine-tune doesn't always produce a model you can call. The artifact says what you got:

| `type`           | What it is                                                   | Fields                |
| ---------------- | ------------------------------------------------------------ | --------------------- |
| `adapter`        | weights to load on top of the base model, such as a LoRA     | `format`, `uri`       |
| `checkpoint`     | a complete set of weights                                    | `format`, `uri`       |
| `provider-model` | a model that only exists inside a provider, called by its id | `provider`, `modelId` |

Every artifact has `baseModel`, and may have `metadata`: provider-owned JSON namespaced by provider id, such as `{ vertex: { endpoint: "…" } }`. June keeps it as is and never reads inside it.

## Serving the artifact

June stops at the artifact. **June can't serve a tuned model for you**: it doesn't host, load or serve models, so using one is up to you and the provider:

- An `adapter` is loaded on top of `baseModel`: with PEFT in Python, or by a server that supports LoRA adapters, such as vLLM. Point an AI SDK provider (for example an OpenAI-compatible one) at that server.
- A `checkpoint` is a full model; serve it like any other open-weight model.
- A `provider-model` is called through the provider's own AI SDK package, by its `modelId`. It only exists inside that provider.

Once it's served, a tuned model is an ordinary AI SDK model or function, so [`evaluate()`](https://june.dev/docs/training/evals.md) and the rest of June use it like any other, and it's ready to [compare](https://june.dev/docs/training/comparing.md#comparing-a-fine-tune) with the baseline. Code that serves artifacts from more than one trainer looks at `artifact.type`.

## The manifest

The manifest is plain JSON to store next to the artifact:

```json
{
  "version": 1,
  "name": "fonzie",
  "trainer": "trl",
  "baseModel": "Qwen/Qwen3-8B",
  "method": { "type": "lora", "rank": 32 },
  "config": { "epochs": 2 },
  "datasets": {
    "train": { "source": "memory", "size": 1, "fingerprint": "sha256:9f2c…" }
  },
  "job": { "id": "trl-3f1a", "trainer": "trl" },
  "artifact": {
    "type": "adapter",
    "format": "peft",
    "uri": "/runs/fonzie/adapter",
    "baseModel": "Qwen/Qwen3-8B"
  },
  "metrics": { "loss": 0.42 },
  "startedAt": "2026-10-14T03:00:00.000Z",
  "completedAt": "2026-10-14T03:41:12.000Z"
}
```

`providerOptions` aren't recorded, since they can hold account details. A trainer that needs provenance of its own puts it in `artifact.metadata`.

## Writing a trainer

A trainer is an object with an `id`, its `capabilities`, and three calls. It needs nothing but the types from `june/train`:

```ts
import type { Trainer } from "june/train";

declare function submit(body: object): Promise<{ id: string }>;
declare function poll(id: string): Promise<{
  state: "pending" | "running" | "done" | "error";
  model?: string;
  message?: string;
}>;
declare function stop(id: string): Promise<void>;

export const myTrainer: Trainer = {
  id: "my-cloud",
  capabilities: { methods: ["sft"], artifacts: ["provider-model"] },

  async start(request) {
    const examples = await request.train.examples();
    const { id } = await submit({
      model: request.model,
      examples,
      ...request.providerOptions["my-cloud"],
    });
    return { id, trainer: "my-cloud" };
  },

  async status(job) {
    const remote = await poll(job.id);
    switch (remote.state) {
      case "pending":
        return { state: "queued" };
      case "running":
        return { state: "running" };
      case "error":
        return {
          state: "failed",
          error: { name: "RemoteError", message: remote.message ?? "" },
        };
      case "done":
        return {
          state: "succeeded",
          result: {
            artifact: {
              type: "provider-model",
              provider: "my-cloud",
              modelId: remote.model!,
              baseModel: "base",
            },
          },
        };
    }
  },

  async cancel(job) {
    await stop(job.id);
  },
};
```

`start` returns a `TrainingJob`: a JSON reference (`id`, `trainer`, and optional namespaced `metadata`) that `status` and `cancel` can use from any process. Keep whatever the trainer needs to find the job again in it.

This is intentionally a small abstraction. It has no retries, queues, storage or provider SDKs; a trainer brings whatever its backend needs. [Custom trainers](https://june.dev/docs/training/custom-trainers.md) covers capabilities, jobs and artifacts in full.
