Skip to content

Logging and metrics

StableTested and in active use. Breaking changes will be called out.

Training runs emit metrics through a Logger. Which logger you attach decides where they go — nowhere, a CSV file, or a live dashboard.

from noeira.core.logger import NoOpLogger, CsvLogger, RemoteLogger, CompositeLogger
LoggerDestination
NoOpLoggerdiscards — the default
CsvLoggera local CSV file
RemoteLoggerHTTP POST to noeira cloud
CompositeLoggerseveral loggers at once

Loggers are passed to train as a pointer plus a compile-time type parameter:

var logger = CsvLogger("/tmp/run_metrics.csv", buffer_size=64)
logger.set_config("algo", "sac")
logger.set_config("env", "HalfCheetah")
_ = agent.train[L=CsvLogger](
env, NUM_STEPS,
logger=Pointer(to=logger).as_unsafe_any_origin(),
print_every=50_000,
diag_every=1_000,
)
logger.close()
_ = logger # keep it alive past the pointer

The _ = logger at the end is not decoration — it extends the lifetime past the raw pointer handed to the driver. Dropping it is a use-after-free.

The drivers emit at two rates, and separating them is what keeps a long run’s log both useful and small.

Driver cadence (print_every) — the coarse progress signal: avg_reward, episodes. Every 50,000 steps is typical for a 600,000-step run.

Diagnostic cadence (diag_every) — the full metric bundle for the algorithm: policy_loss, critic_loss, alpha, mean_q, train_steps. Every 1,000 steps.

Reward alone tells you that something is wrong. The bundle tells you what: a collapsing return with rising mean_q is value explosion; with flat mean_q it is a policy problem.

Streams to noeira cloud over HTTP as batched JSON POSTs. The run is announced by register() — call it through register_run with a RunContext, before step 0 — and ended by close(), which sends the finish.

from noeira.core.dotenv import load_dotenv
var env_vars = load_dotenv()
var logger = RemoteLogger(
server_url=env_vars.get("NOEIRA_CLOUD_URL", ""),
run_name="SAC HalfCheetah (GPU)",
buffer_size=64,
api_key=env_vars.get("NOEIRA_CLOUD_API_KEY", ""),
)
logger.set_config("algorithm", "SAC")
logger.set_config("env", "HalfCheetah")
logger.set_config("n_envs", String(N_ENVS))
logger.register() # or register_run(run, logger) — see below

A logger that is never registered still registers lazily on its first flush, so older drivers keep working — but a run that dies before that flush never appears on the dashboard. New drivers should use a RunContext and register_run; see registering a run.

Configuration comes from a .env file at the repo root, read by noeira.core.dotenv:

NOEIRA_CLOUD_URL=https://cloud.noeira.ai
NOEIRA_CLOUD_API_KEY=...

buffer_size controls batching (default 200). Smaller means fresher charts and more requests; 64–200 is the usual range. logger.total_logged() reports how many points were logged.

With no .env, or an empty URL, the logger stays inert and training runs unaffected.

A driver makes a RunContext — which creates the run’s directory and writes run.kv before any training — then a logger, and announces the run with register_run before step 0:

from noeira.core.dotenv import load_dotenv
from noeira.core.logger import RemoteLogger
from noeira.core.run import RunContext, register_run
var env_vars = load_dotenv()
var run = RunContext(
project="so101",
driver="examples/so101/act_so101_train_gpu.mojo",
slug="act-so101",
)
var logger = RemoteLogger(
server_url=env_vars.get("NOEIRA_CLOUD_URL", ""),
run_name=run.name(),
run_id=run.id,
buffer_size=64,
api_key=env_vars.get("NOEIRA_CLOUD_API_KEY", ""),
)
logger.set_config("algorithm", "ACT")
register_run(run, logger) # seeds run_id, project, driver, seed, host... then POSTs /runs
# ... training: logger.log_scalar(...), run.checkpoint_path("best") ...
run.close() # run.kv: status=done, finished=<now>
logger.close() # flushes, sends the finish, drains the queue

register_run is what makes a run that dies at step 0 visible at all. The task-family SAC driver (noeira/tasks/sac_family_driver.mojo), ACT on the SO-101 and on LIBERO, Forward-Backward and BFM-Zero on the G1 all do this. While the run is alive the logger also sends a heartbeat, which is how a run that was killed can be told from one that is simply quiet.

The POST never happens on the training thread. flush serialises the batch and queues it; a background thread in noeira/io/http_sink.mojo owns the connection, sends in order, and sends a heartbeat when the run has been quiet for a minute. There is no Python in this path.

Configuration keys are recorded once with the run, not per point:

logger.set_config("algorithm", "SAC")
logger.set_config("hidden", String(HIDDEN))
logger.set_config("batch", String(BATCH))
logger.set_config("buffer_capacity", String(REPLAY_CAPACITY))

Worth being generous here. Comparing two runs six weeks apart is only possible if the thing that differed was recorded.

Each algorithm defines a metrics struct. Representative fields:

AlgorithmMetrics
DQNloss, mean_q, explore_rate, td_error_abs_mean, train_steps
SACpolicy_loss, critic_loss, alpha, mean_q, mean_next_q, reward_mean
PPOpolicy_loss, critic_loss, approx_kl, clip_fraction, entropy
MuZeropolicy_loss, value_loss, reward_loss, avg_reward, buffer_size
FB / FB-CPRfb_measure_loss, fb_ortho_loss, policy_loss, disc_expert_loss, disc_policy_loss
ACTl1_loss, kl_loss, val_l1_loss, grad_norm, step_s

The shared training drivers additionally emit avg_reward and episodes at the driver cadence.

Metric names are flat snake_case, with no / namespaces, because the dashboard groups charts by exact name:

  • a loss ends in _loss (policy_loss, value_loss, val_l1_loss);
  • a statistic is <quantity>_<stat> (reward_mean, action_abs_mean);
  • an evaluation starts with eval_ (eval_return, eval_success_rate);
  • a per-task series appends the task name (eval_success_rate_<task>).

Settings are not metrics. A value that does not change during the run goes through set_config, which writes it to metrics.config.kv beside metrics.csv.

Drawn from the failures documented across these docs:

mean_q climbing without bound — value explosion. LayerNorm the critic. Hit by SAC on Humanoid, MBPO, and REDQ at high UTD.

Return collapsing after early progress — often a target-network problem. If BatchNorm is involved, check that the hard copy carried the running statistics.

Loss descending while return stays flat — the model is learning something, just not the task. Classic symptom of a misaligned target: prev-action off-by-one in a world model, or termination/truncation collapsed into one flag.

train_steps not advancing as expected — check updates_per_step. At the default of 1 with 32 environments you are training 32× less than intended.

var logger = CsvLogger("/tmp/muzero_cartpole_metrics_gpu.csv", buffer_size=64)

Right for headless boxes, CI and anything you want to plot yourself. Several examples default to it.