Skip to content

Training progress events: a versioned, machine-readable JSONL stream for front-ends, dashboards and CI #1028

Open

Description

@cemililik

Embeddable engine · EMB-01 · Kind: code + docs · Size L · background: #1027

Summary

A program that starts a ForgeLM training run cannot find out how the run is going except by scraping human-readable log output. The library call blocks until the whole pipeline has finished, the audit log records only the start and the terminal outcome, webhooks announce lifecycle transitions and nothing else, and none of this is a documented, versioned contract.

This issue adds a versioned, machine-readable progress stream: step, epoch, loss, learning rate, elapsed time, ETA, phase transitions and exactly one terminal event, readable the same way by a front-end, a dashboard or a CI job.

What exists today (2059b59)

Channel What it carries Why it is not enough
ForgeTrainer.train() (trainer.py#L1686) A TrainResult after the whole pipeline: train → evaluate → save → gates → artefacts → approval. A blocking call with no per-step information and no callback parameter. The only callback is EarlyStoppingCallback (L1695-L1702).
Audit log (AuditLogger) training.started (L1597), then gate results and pipeline.completed / pipeline.failed. No progress, and no start event for the post-train phases (benchmark, safety, judge, artefacts), which can run for a long time. It is also a tamper-evident compliance record, not a telemetry sink.
Webhooks (webhook.py#L429-L560) training.start, training.success, training.failure, training.reverted, approval.required. One POST per event, no retry by design (logging-observability.md), and it needs a receiving server.
Console HF ProgressCallback / PrinterCallback (tqdm and print). Unstructured, transformers-version dependent, written to stdout. That is why --output-format json stdout is not a JSON document (#412).
TensorBoard (report_to, default tensorboard, L526) Scalars. Optional extra, binary format, cadence set by logging_steps = max(1, min(50, train_size // 100)) (L453). Not a ForgeLM contract.
trainer_state.json log_history. Written only when a checkpoint is saved; the layout belongs to transformers.

Two implementation facts constrain the design:

  • GRPO discards every callback. _build_grpo_trainer sets trainer_kwargs["callbacks"] = [] (L1077-L1085) to drop the eval-dependent EarlyStoppingCallback. A progress callback appended to the user list would silently vanish for GRPO.
  • OOM recovery rebuilds the trainer and restarts optimisation from step 0 at a smaller batch size (L1203-L1285). A reader must be told that the counter reset, not watch the step number jump backwards.

Prior art: LLaMA-Factory writes a trainer_log.jsonl with current_steps, total_steps, percentage, elapsed_time, remaining_time, loss, learning rate and epoch per line. The approach is proven. This design uses numeric seconds instead of H:MM:SS strings and adds a schema version.

Proposal (the open decisions are listed after it)

Sink. An append-only JSON Lines file, <output_dir>/progress.jsonl, switched on by a new validated config field (default off; see decision 1). Stdout is reserved for the final JSON envelope (logging-observability.md, rule 1) and stderr is for logs, so neither can carry events. A file also survives a front-end restart: the reader re-attaches by byte offset.

Envelope on every line: schema_version (integer, starts at 1), seq (strictly increasing within the file, starts at 1), ts (ISO-8601 UTC), run_id (the same id as the audit log), event.

Events, schema version 1

event Extra fields
run.started config_hash, trainer_type, total_steps (nullable), num_epochs, resumed_from_step (nullable), stage (index, count, name; pipelines only)
phase.started / phase.finished phase is one of model_load, data_prepare, train, evaluate, save, benchmark, safety, judge, artifacts, approval; phase.finished adds duration_s
train.step global_step, max_steps, epoch (float), loss, learning_rate, grad_norm (nullable), tokens_seen (nullable), elapsed_s, eta_s (nullable until the estimate is meaningful)
train.eval global_step, metrics (object)
train.checkpoint global_step, path (relative to output_dir)
train.restarted reason (oom_recovery), per_device_train_batch_size, gradient_accumulation_steps
run.finished status is one of success, failed, reverted, awaiting_approval, interrupted (the last arrives with EMB-02); error (nullable, screened like a webhook payload)

Writer rules

Reader rules (documented)

  • Tolerate a partial last line; read by byte offset and re-read from the last newline.
  • Filter by run_id; a resumed run appends and a new run in the same directory appends, so the file is never rewritten.
  • The absence of a run.finished line together with a stale modification time means the process was killed or crashed. Nothing can be written after a hard kill.

Where the code lives. The schema, the writer and the TrainerCallback go into a new module, forgelm/_progress.py. trainer.py is at its no-growth budget (#904), so it gains only the few lines that attach the reporter, including re-attaching it after an OOM rebuild and after the GRPO callback reset. The model_load and data_prepare phases happen in forgelm/cli/_training.py before a ForgeTrainer exists, so the reporter is created at the CLI seam and handed down.

Library surface. Ship the Python hook as Experimental tier first (library API tiers) and promote it to Stable after one minor release in which a real consumer has used it. The file schema is the primary contract; the Python hook is secondary.

Open decisions (record as a dated row in docs/roadmap/risks-and-decisions.md before coding)

  1. Config field and default. Proposal: training.progress_events: bool = false next to report_to. monitoring: is not suitable: it is a declarative Art. 72 plan "without runtime hooks". Extending report_to is not possible without a breaking type change, because it is a single-valued Literal that is passed to HF.
  2. Who writes the terminal event. The process exit code is known only at the CLI seam, so either the CLI emits run.finished (with exit_code) or ForgeTrainer emits it without one.
  3. Throttle. Reuse HF logging_steps (1–50 depending on dataset size) or add a minimum interval (for example 1 s) so a tiny dataset cannot flood the file.
  4. Pipelines. One root-level file with a stage field, or one file per stage directory plus stage markers in a root file.
  5. Tier. Experimental first (proposed) or Stable at once.

Alternatives considered

Alternative Why not
NDJSON on stdout Breaks the "stdout is the final envelope" contract and makes #412 worse.
Parse stderr Mixes logs with data; the format is not stable.
Tail TensorBoard or trainer_state.json Optional, binary, or checkpoint-only; the layout is not ours.
HTTP or SSE server Adds a server, ports and a firewall story to a headless tool.
Webhook progress One attempt, no ordering guarantee, needs a receiver.

Out of scope

Any UI; stopping a run (EMB-02); changing the --output-format json envelope (#412); GPU and system metrics such as VRAM or temperature (can be added later as optional fields); any change to the audit chain.

Acceptance criteria

  • The five open decisions are answered in a dated decision row.
  • forgelm/_progress.py defines the schema, the dumps_strict writer (flush per line, rank-0 only, never raises into training) and the TrainerCallback.
  • SFT, DPO, SimPO, KTO, ORPO and GRPO each emit train.step; a test proves the GRPO path keeps the callback.
  • An OOM-recovery retry emits train.restarted and seq stays strictly increasing.
  • Phase events cover evaluate, save, benchmark, safety, judge, artifacts and approval, and exactly one terminal event is written for success, failure, auto-revert and awaiting approval.
  • Multi-stage pipelines carry the stage fields according to decision 4.
  • With the stream enabled, --output-format json stdout is byte-identical to a run without it (guards against regressing Training --output-format json stdout is not a JSON document: print_trainable_parameters and HF Trainer log dicts precede the envelope (CR-A03-008) #412).
  • Non-finite values are written as null; a test covers it.
  • A reader test covers a torn last line and a gap-free seq.
  • Documentation in English and Turkish: a new docs/reference/progress_events.md (and -tr.md), the config reference, config_template.yaml, the library API reference tier row, a cross-link from the JSON output page, and a CHANGELOG Added entry. A test keeps the documented event list equal to the emitted one.
  • Tests are CPU-only and run on Linux, macOS and Windows.

Sequencing and dependencies

Stage B of #1027. Start after Phase 16 S8 (#894) has settled trainer.py; coordinate with the module split (#904) and land together with, or after, the stdout fix (#412). EMB-02 builds on the callback created here.

Related

#412 (stdout pollution), #470 (rank-0 gating), #569 (interrupted runs), #904 (trainer split), #894 (S8).


Raised on 2026-10-20 as part of the embeddable-engine decision. Code links point at 2059b59; line numbers may have moved. Tracking ID: EMB-01.

Activity

  1. added
    wave: 4Roadmap wave 4: low / info / watch, when touching the module
    severity: lowMinor defect, inaccuracy or rough edge
    and removed
    source: roadmapPlanned or deferred work recorded in docs/roadmap/
    on Oct 20, 2026
  2. added
    blockedWaits for another issue (see Blocked by); set and removed automatically, never by hand
    on Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: pipelineCode area: pipelinearea: trainerCode area: trainerblockedWaits for another issue (see Blocked by); set and removed automatically, never by handenhancementNew feature or requestseverity: lowMinor defect, inaccuracy or rough edgewave: 4Roadmap wave 4: low / info / watch, when touching the module

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions