- The local spool format: what the SDK writes to
./.cirron/. Public API, stable within a major SDK version, consumed by the Cirron ingestion worker and by any third-party tool. - The platform wire schemas: what ends up in the Cirron database after ingestion. Useful when you’re writing queries, building a custom consumer, or exporting to your own storage.
Local spool format (v1)
Every spool file is valid RFC 8259 JSON. The SDK never emits the bareNaN, Infinity or -Infinity tokens that Python’s json module
produces by default, so any conforming parser in any language can read a
spool file. Non-finite floats are substituted instead; see
marks[].value_nonfinite and snapshots[].stats.nonfinite below.
Directory layout
<created_ns>: wall-clock time the batch was sealed, nanoseconds since Unix epoch, zero-padded to 20 digits. Filenames sort lexicographically in chronological order; the flush thread uses this for oldest-first eviction when the spool cap is exceeded.<batch_id>: 32-char lowercase hex (UUID4 without dashes).- Files are written via a
.json.tmp→os.replace()handoff, so a reader that opens a*.jsonfile always sees a complete batch. - Readers MUST ignore
*.json.tmp. A temp file is either a write currently in flight or, if its writer was hard-killed between the write and the rename (SIGKILL, OOM kill, node preemption, ENOSPC), an orphan holding a partial batch. Either way it is not a readable batch. Its bytes do count towardspool_max_bytes, and the SDK deletes any it finds older than one hour during a cap-enforcement pass. The age gate matters because every rank of a distributed run shares one spool directory, so a recent.json.tmpmay be another rank’s in-flight write; an operator cleaning up by hand should apply the same rule. - The cap counts every byte the SDK put in the directory, sealed
*.jsonand unsealed*.json.tmpalike. Orphaned temp files are swept before any batch is evicted, so a batch is never dropped to make room for garbage. If in-flight temp files alone meet or exceed the cap, the SDK logs a warning and evicts nothing, since dropping batches could not get the directory under the cap anyway.
Batch JSON
spans[]
cpu_ns, gpu_ns, and memory_peak_bytes default to null.
gpu_ns is set by torch CUDA event pairs when profiling a CUDA
forward/backward pass. cpu_ns and memory_peak_bytes are reserved
and not populated today. mark_ids holds the IDs of every mark
attached to this span.
marks[]
cirron.session root.
Marks emitted before ci.profile() was called (or after
shutdown()) use the legacy "root" sentinel instead of a real
span ID.
A diverged loss is legitimate data, and ci.mark("loss", float("nan"))
records it. A float mark whose value is nan, inf or -inf is written
as "value": null with a sibling "value_nonfinite" naming which one it
was. value_type stays "float": the mark is still a float mark, and a
reader that ignores the new field sees a correctly typed record with a
missing value rather than a type contradiction. value_nonfinite is
absent on every finite mark, so its presence is the only test a reader
needs. Only value_type: "float" marks can carry it.
Non-finite floats inside attrs, at any nesting depth, become the
strings "nan" / "inf" / "-inf" instead. attrs is free-form and
user-owned, so a companion field naming the substitution has nowhere to
live without risking a collision with one of your own keys.
snapshots[]
mode values:
"stats": inline statistics only.blob_uriisnull. Default."sampled": stats + a safetensors blob onrandom() < sample_rateepoch boundaries. Records that lose the roll staymode="stats"withblob_uri=null."full": stats + blob every epoch. Debug-only; not recommended for 100M+ parameter models.
nan / inf statistics, which is precisely
the run you enabled snapshots to debug. Any of mean / std / min /
max / norm that is non-finite is written as null, and a companion
nonfinite object inside stats records which fields were affected and
what they were:
nonfinite is absent when every statistic is finite. norm can be inf
on its own: it is derived algebraically rather than by a second pass, so
it can overflow on a large but entirely finite tensor while the other
statistics stay meaningful.
The histogram key is omitted entirely when the tensor’s extremes are
non-finite. bins is a fixed-length array of numbers, so nulls inside it
are not representable, and a histogram over a non-finite range carries no
information anyway. When histogram is present it always has exactly 17
bins and 16 counts.
Sampled and full write one safetensors file per (span, kind):
./.cirron/snapshots/<span_id>/weights.safetensors for weights and
gradients.safetensors for gradients. Every record for that span
shares the same blob_uri; tensor_name is used verbatim as the key
inside the container, so consumers can call container[record["tensor_name"]]
directly with no sanitization.
Gradient records use tensor_name = "<param>.grad" (e.g.
"layer1.0.conv1.weight.grad") and only appear when the gradient was
non-None at capture time.
Canonical scope shape
Trainer over a PyTorch
DataLoader), only the highest-priority hook owns epoch and step
(transformers > tensorflow > torch); others yield on those names so
no semantic scope is duplicated.
Operations executed before the training loop runs (warmup
forwards, sanity checks, optimizer construction) have
parent_id == session_id, not an epoch. No epoch exists yet; this
is correct behavior, not a bug.
For inference, the top-level scope per call is request instead of
epoch.
Reading the spool
Forward compatibility
Readers must tolerate unknown top-level keys and unknown per-span / per-mark fields, so minor SDK bumps can add optional metadata. Removing or renaming existing fields, or changing their types, requires aschema_version bump and follows SemVer.
Wire format: POST /v1/traces
When the HTTP transport is active (external runs with an API key),
the SDK batches spans / marks / snapshots into the same JSON shape
documented above and posts it to POST /v1/traces on the Cirron
platform API. The body wraps the batch like this:
202 Accepted with the batch ID.
Idempotent by batch_id (24-hour dedupe window server-side), so
retrying the same batch after a timeout is safe. Rate-limited
responses return 429 with a Retry-After header the SDK respects
via exponential backoff.
For self-hosted installs, this is the full wire contract: a custom
ingestion worker that accepts the above payload is sufficient to
consume SDK traffic.
Platform wire schemas
After ingestion, traces land in these tables. Field names are camelCase (Prisma conventions); the SDK sends snake_case and the ingestion worker maps it.TraceSpan
Indexes:
(workspaceId, runId, startNs),
(workspaceId, pipelineId, startNs),
(workspaceId, deploymentId, startNs), (traceId, parentSpanId).