Repository navigation
docs: notebook metrics integration (house format first, TensorBoard as an adapter) - #285
Conversation
|
Kevin Cho (@chokevin) Could you please take a look? |
|
|
||
| ## 1. Problem | ||
|
|
||
| Ray workloads commonly produce TensorBoard-compatible outputs, but TauGrid does |
There was a problem hiding this comment.
I think I'm a bit conflicted here. While I'm ok with supporting tensorboard it does seem to be a bit orthogonal to how we want to display per job metrics? I also think its not the same format as offline W&B metrics which is what the industry seems to use today.
There was a problem hiding this comment.
I think you are right, and the document no longer treats TensorBoard as the output. House W&B-style
JSONL is the target; TensorBoard becomes an optional input. Worth noting the tag vocabulary is
already compatible -- the house format uses slash-delimited tags like train/loss, and
portal/internal/expimport/expimport_test.go asserts "TensorBoard card semantics" for them -- so
what is missing is transport, not modeling.
|
|
||
| ## 2. Decision | ||
|
|
||
| **TensorBoard runs as a sidecar container in the RayJob head pod, and its UI is |
There was a problem hiding this comment.
We do already have a metrics offload sidecar we should configure that container to handle the different metrics logging but again we should make sure it works as broadly as possible
There was a problem hiding this comment.
Agreed, and it is now the primary recommendation. Broadening the existing metrics path beats adding
a service on every axis we compared: no Service per job, no new image to pin and review, no new proxy
route, and no GOVERNANCE design gate for a new required service. The notebook loss pane already reads
the house format, so the same rows serve it and Stellar with nothing to reconcile.
| @@ -0,0 +1,281 @@ | |||
| # TauGrid Notebook TensorBoard Integration - Design | |||
|
|
|||
| Status: **proposal, awaiting review.** No implementation code is written yet. | |||
There was a problem hiding this comment.
I think I would need some analysis between the tensorboard format and the existing format we are exporting for taugrid metrics. I haven't looked closely at this recently but I feel like if they are similar this should be a simple integration but if they are too different we should try to coincide the logging as much as possible.
There was a problem hiding this comment.
Delivered, in the document's section 2. Short version: the two formats are not similar in encoding
(UTF-8 JSON lines versus TFRecord-framed protobuf) but they are isomorphic in scalar semantics --
Event.step -> _step, Event.wall_time -> _timestamp, Value.tag -> the JSON key,
simple_value/tensor -> the number. So a converter into the house format is small and bounded:
one reader, one direction, scalars only, no UI and no service. Two things make it cheaper than it
looks -- google.golang.org/protobuf is already a direct dependency of portal/go.mod, and CRC32C
is stdlib -- so no new module is needed. A parallel TensorBoard UI would duplicate Stellar and leave
two pipelines to keep in sync, which is what we want to avoid.
|
Overally I'm totally onboard with an interactive notebook. Whether tensorboard is the right output or another alternative or maybe even local taugrid without a backing adx cluster could be something to consider |
c49c92c to
377ae60
Compare
| remote-write path — and **no `tfevents` decoding**. The only TensorBoard tie is | ||
| *tag/card naming semantics* (`expimport_test.go:91`). The README line should say | ||
| the importer consumes TensorBoard-*shaped tags*, not event files. | ||
| 2. **`portal/README.md` names `taugrid-metrics-collector`, which does not exist in |
There was a problem hiding this comment.
The standalone collector already exists under metrics/experiment-metrics-collector, is packaged under images/taugrid-metrics-collector, and is what workload sidecars invoke. It owns source checkpoints, typed projection, durable spooling and queued ADX delivery, rather than the remote-write path shown in §5. Base the adapter ownership and architecture analysis on those boundaries, and assess protobuf dependencies in the collector module rather than portal/go.mod, so the proposal preserves one collection and delivery pipeline.
There was a problem hiding this comment.
Correct on both counts, and the boundary correction changed the analysis rather than just the wording.
§5 no longer draws the collector as a remote-write forwarder. It now shows what the module actually owns -- source checkpoints, typed projection, durable spooling (spool.go) and queued ADX delivery (adx_queued.go) -- with the tfevents adapter as an additional source at the typed-projection boundary. That placement is the argument: a decoder that emits rows through the same projection inherits retry, ordering and restart safety, while one that writes to ADX directly would bypass all three.
The dependency claim was assessed against the wrong module, and correcting it flips the conclusion. google.golang.org/protobuf is a direct dependency of portal/go.mod, which is what I cited, but metrics/experiment-metrics-collector/go.mod pulls only azkustodata, azkustoingest, azcore and azidentity -- no protobuf. So an adapter inside the collector does add a dependency to that module, while one beside expimport does not. The earlier "no new module needed" line was wrong for the collector placement; §6.1 and §9 Q2 now record the real trade-off instead of asserting it away.
429e2dd to
e9948be
Compare
|
|
||
| ``` | ||
| Event.step -> _step (int64 -> int) | ||
| Event.wall_time -> _timestamp (double -> int seconds) |
There was a problem hiding this comment.
Please preserve fractional epoch seconds instead of narrowing wall_time to integer seconds. With the existing projection, two observations of the same tag/step in the same source file within one second would receive the same event ID: value and history-line tags are excluded from that identity. The ADX dedup view would then hide one observation. Keep _timestamp as numeric seconds with its fractional part and require a regression case for this pair.
There was a problem hiding this comment.
Fixed. _timestamp now keeps the fractional part; the mapping line reads Event.wall_time -> _timestamp (double -> numeric seconds, fractional part kept) and no longer narrows to integer seconds.
The document now records the mechanism rather than just the rule: the projection identities an observation by source file, tag and step, and value and history-line tags are excluded from that identity, so a floored timestamp collapses two same-tag, same-step observations inside one second onto one event ID and the ADX dedup view shows only one of them. I also noted that a training step normally emits its metrics in a single burst, which makes this routine rather than exotic, and that the loss is silent. A regression case for the same-second pair is required in §2.4, and the correction is recorded as R7 in the revision summary.
| |- metrics-offload / collector (experiment-metrics-collector) | ||
| | | ||
| | source checkpoints (what has already been read from each source) | ||
| | typed projection (source records -> house JSONL rows) |
There was a problem hiding this comment.
The typed projection does not emit the _step/_timestamp house rows defined above: it turns them into canonical MetricEvent NDJSON, which both spool replay and queued delivery validate. Labeling its output and ADX input as house JSONL hides the adapter boundary and would direct an implementation toward a payload those consumers reject. Keep the existing pipeline, but show history-shaped scalar records before projection and canonical metric events afterward.
There was a problem hiding this comment.
Correct, and this was a regression from my own previous revision. I re-based the architecture on the collector's boundaries and then labelled the projection's output as house _step/_timestamp JSONL - precisely wrong, which is worse than the vagueness it replaced, because it would send an implementer toward a payload the consumers reject.
Verified against main rather than a working tree. The real boundaries:
- source -> checkpoints: raw history bytes +
SourceCheckpoint(metrics/experiment-metrics-collector/history.go:20) - source record -> projection input: history-shaped scalar records (
collector.go:456;core/exptelemetry/history.go:52ProjectJSONLHistoryRow) - projection output:
[]exptelemetry.MetricEvent+ canonical NDJSON (collector.go:446;core/exptelemetry/metric.go:46, schematau.experiment.metric.v1at:18,MarshalNDJSONat:177) - spool replay validates canonical and requires a byte-for-byte round-trip (
spool.go:481, called at:388) - queued delivery validates again and ingests as JSON (
adx_queued.go:284,:300,:426;MetricEventChunk{Events []MetricEvent; NDJSON []byte}attypes.go:20/29/30)
So ADX receives canonical MetricEvent NDJSON. Section 5 now shows history-shaped records before the projection and canonical metric events after it, with a boundary table carrying the file:line for each hop, and states outright that the projection output is not house JSONL. The adapter stops at the projection's input. I corrected the same error in §2.1, §2.5, §3 D2, §3.1, §4.1, §8 and §10.5 rather than only the line you flagged.
| lost from TauGrid's model: `tfevents` files are already classified as artifacts | ||
| by the store and UI (`portal/internal/expstore/store_test.go:722`, | ||
| `portal/frontend/src/stellar/evidence-helpers.ts:85`), so a histogram or image | ||
| remains reachable as run evidence without being a metric row. |
There was a problem hiding this comment.
The cited store fixture explicitly registers an event-file artifact; it does not establish automatic retention of files consumed by the proposed adapter. The collector persists scalar chunks/checkpoints, and artifact offload processes files already registered for the run. Dropping non-scalars therefore does not itself leave their evidence reachable. Qualify this promise on durable retention and run-associated registration of the original event files, rather than treating artifact representation as preservation.
There was a problem hiding this comment.
Agreed - "representable as an artifact" is not "durably retained and registered", and the document treated those as the same thing.
What the cited fixture actually does: portal/internal/expstore/store_test.go:875-887 registers an ArtifactRecord{Type:"tensorboard", URI:"artifacts/seed-1/events.out.tfevents.test"} inside RecordRunData for run seed-1 (same fixture at adx_export_test.go:347-358). It establishes that an event file can be registered, not that anything registers it automatically. And offload only uploads what is already registered: store.ArtifactsForRun(ctx, opts.RunID) at portal/internal/artifactoffload/offload.go:129.
Section 2.4 now makes preservation conditional on durable retention and run-associated registration, states that dropping non-scalars is otherwise unrecoverable, and records that the adapter registers nothing itself. I qualified the same claim in §8 and §10.2, and added §9 Q6 plus a §11 item for the retention/registration dependency, since it is now an explicit assumption rather than an implied guarantee.
Two incidental fixes while verifying: a stale line reference (store_test.go:722 -> 875) and a citation to portal/frontend/src/stellar/evidence-helpers.ts, which does not exist on main - I had cited it without checking. Removed.
0ed5e47 to
e208a6f
Compare
| Placement therefore matters more than "add a converter". A `tfevents` reader is an | ||
| additional **source** that emits history-shaped scalar records, so it enters at the | ||
| typed projection's input rather than at its output. The existing checkpoints, spool | ||
| and queued delivery keep working unchanged, and the decoder inherits retry, ordering |
There was a problem hiding this comment.
The source checkpoint layer cannot remain unchanged here. readSource currently truncates at the last newline and tracks line counts (metrics/experiment-metrics-collector/history.go:123-148), while Runner.project splits those bytes on newlines and decodes JSON objects (collector.go:449-463). A protobuf payload may contain 0x0a, and a live event file may end midway through a TFRecord, so reusing this reader can checkpoint in the middle of a frame and make restart permanently fail or misdecode. Please make a framing-aware source/checkpoint abstraction part of the design: advance only through a complete length/CRC/payload/CRC record and define truncated-tail retry behavior. The spool and ADX delivery can be reused, but checkpointing and ordering are not inherited for free.
There was a problem hiding this comment.
Correct, and this invalidated the document's load-bearing claim rather than a detail. The "source checkpoint layer is reused unchanged" statement is withdrawn.
The review's reading checks out against main: readSource truncates at the last newline and tracks line counts (metrics/experiment-metrics-collector/history.go:118-129, 144-150), and Runner.project splits those bytes on newlines and JSON-decodes them (collector.go:449-463). A protobuf payload containing 0x0a therefore splits a frame, and a file that ends mid-record checkpoints inside it - which makes restart fail or misdecode rather than merely lose a record.
New §5.1 defines a framing-aware source/checkpoint abstraction:
- advance only across a complete TFRecord frame - uint64 length, masked CRC32C of length, payload, masked CRC32C of payload
- a CRC mismatch is a hard error that does not advance the checkpoint
- a truncated tail stops at the last verified frame, is retained by the checkpoint, and is retried on the next pass - never checkpointed past
- ordering is per logical run (strict file order within a source, prefix-digest gate, segments in write order), explicitly not a global total order
The spool and queued ADX delivery are still reused. §5's boundary table, §8 risk 3 and §9 Q2 are updated to match, and §0 R10 records the correction. I also annotated the older R6 row, which had claimed the source checkpoints were reused - it now notes that R10 narrows the source half of that row, rather than leaving two rows contradicting each other.
| ``` | ||
|
|
||
| **Do not floor `wall_time` to whole seconds.** The projection identifies an | ||
| observation by the source file, the tag and the step; value and history-line tags |
There was a problem hiding this comment.
This mapping is missing TensorBoard’s logical-run identity. The relative event-file directory/logdir hierarchy distinguishes Tune trials and TensorBoard runs; the tag only identifies a metric within that run. TauGrid groups a series by its Tau run_id and metric name, while its deterministic event ID also includes MetricFileID (core/exptelemetry/metric.go:89-117). Without an explicit mapping, multiple trial directories can be merged into one Tau series with overlapping steps, while rotation/replay of one logical run can receive different file identities and evade deduplication. Please define how the TensorBoard logical run maps to Tau run/group/tag/prefix identity and how source identity remains stable across event-file rotation.
There was a problem hiding this comment.
Correct - the mapping was missing, and the consequence is worse than losing a label: trial directories merge into one series, and rotation evades deduplication.
Verified against main: the deterministic event ID folds in metric_file_id alongside run_id and run_group_id (core/exptelemetry/metric.go:91-122 - the review cited 89-117, the function runs to 122), collector.go:471 sets MetricFileID from the reading path, and history.go:205-208 hashes the cleaned physical path - which is why rotation changes the event ID and defeats dedup.
New §2.4.1 defines the mapping: trial directory -> run_group_id (distinct per trial), run directory -> run_id (a rotation segment is NOT a new run), tag -> metric_name, with a run-scoped prefix applied by the adapter and project/experiment from the §3.2 submission contract.
New §2.4.2 makes source identity stable: metric_file_id derives from a run-scoped logical source key rather than the physical path, and the physical segment (basename plus dev/inode) lives in the checkpoint. This requires the projection to take metric_file_id from the source rather than re-hashing read.path, which §2.4.2 states as a required change.
One dependency I could not verify and have recorded as §9 Q7 instead of asserting: whether the submission convention actually guarantees the logdir hierarchy this mapping needs. I could not find anything in-tree that enforces or documents it.
| | **D2** | Treat `tfevents` as an **input adapter** that normalizes into history-shaped house JSONL rows, and stop it there. | Scalar loss curves from TensorBoard-producing frameworks (Ray Tune, Lightning, HF, Keras) become available to every existing consumer. The adapter emits the projection's *input*; it does not emit canonical `MetricEvent` NDJSON. No new service. | | ||
| | **D3** | **Defer** the per-job TensorBoard sidecar + proxy service. | Avoids a new required service, a new Service per job, and a new proxy route. See §4. | | ||
| | **D4** | Treat "local TauGrid without ADX" as a **separate design**, not part of this one. | Keeps this proposal reviewable; see §10.5 for why it interacts with D1. | | ||
| | **D5** | Keep the notebook loss pane as the **primary** surface. | No iframe/subpath problem, no extra container, works with no opt-in. | |
There was a problem hiding this comment.
This does not currently work without opt-in. metrics.offload.enabled defaults to false and the collector requires a history path, project/experiment identity, durable storage, and ADX configuration (core/runconfig/schema.go:146-154, core/runconfig/config.go:703-711). The companion notebook proposal also emits {"step":3,"loss":1.25} (notebook-plugin.md:268), but the supported online row contract requires _step and _timestamp, and its panel API does not specify experiment/offload identity. Please add the submission contract that enables and mounts the collector, resolves/stamps project/experiment/group/run identity, and either emits the existing house row shape or introduces an explicit normalizer. Otherwise the promised live/durable loss pane will have no accepted data.
There was a problem hiding this comment.
Correct - the live/durable loss pane assumed an opt-in that is off by default, so it had no accepted data path.
Verified on main: metrics.offload.enabled defaults false (core/runconfig/schema.go:148); it needs a pinned image (run_job.go:556-558) and a writable storage.output PVC under /data (storage/paths.go:12, run_job.go:515-521); metrics.history must be non-empty (config.go:702-704) and resolve under the durable root (run_job.go:563-573); identity requires experiment.project/name (config.go:705-712) with group passed through (719-723); and the collector requires run/project/experiment/group/source (collector.go:60-68).
New §3.2 is the submission contract: what enables and mounts the sidecar, what identity is resolved and stamped, ADX-only delivery (schema.go:152, metricsoffload/options.go:131-139), and an explicit NORMALIZER for the row-shape gap the review identified - the supported online contract needs _step/_timestamp (schema.go:146) while the companion proposal emits {"step":3,"loss":1.25} (notebook-plugin.md:268), which fails because historyStep returns nil for _step (core/exptelemetry/history.go:71-74, 185-188). The normalizer table covers both the metric-file line and the stdout path.
§3's D5 row, which claimed this "works with no opt-in", is corrected. The MetricEvent boundary is preserved - the adapter emits history-shaped rows and the projection still assigns event_id; no "house JSONL output" wording was reintroduced.
| kept in sync with the first — which is precisely the outcome the review asked to | ||
| avoid. | ||
| - "Coincide the logging as much as possible" is therefore **already true at the | ||
| schema level**. The remaining work is transport, not modeling. |
There was a problem hiding this comment.
This conclusion overstates compatibility. W&B-shaped JSONL is a supported source shape, but the canonical exchanged/stored contract is tau.experiment.metric.v1 MetricEvent. The cited “TensorBoard card semantics” test only verifies Tau’s card classification for train/loss; it does not cover TensorBoard run identity, dtype conversion, duplicates, ordering, or non-scalars. The projection normalizes values to finite float64, timestamps to microseconds, requires nonnegative int64 steps, and drops non-scalars, so this requires modeling/normalization decisions in addition to transport. Please narrow the claim to “TensorBoard scalar events can be normalized into the existing canonical MetricEvent pipeline” and specify those normalization rules.
There was a problem hiding this comment.
Agreed, and the claim is narrowed to "TensorBoard scalar events can be normalized into the existing canonical MetricEvent pipeline".
The rules are verified on main and now stated in a new §2.4.3 table: finite float64 values (history.go:91-94, 211-250), timestamps truncated to microseconds via time.UnixMicro in UTC (203-208), nonnegative int64 steps (144-201), non-scalars dropped (95-97), _-prefixed keys treated as metadata (82). And the cited test does only pin card classification - expimport_test.go:91 asserts tau.metric.card == "World model" for train/loss, with ResearchMetricCard a name-to-card switch (mapping.go:53-62).
One refinement to your list, which I think matters for an implementation: it conflates two different behaviours. Non-finite numeric values are fatal - rejected, not skipped - while non-scalar values are dropped. §2.4.3 states them separately rather than as one "normalizes and drops" rule, because an implementer who treats NaN as droppable would silently lose data the pipeline is supposed to refuse.
| ADX (canonical MetricEvent NDJSON, schema tau.experiment.metric.v1) | ||
| | | ||
| v | ||
| Stellar / expstore --> Portal /api/stellar/series |
There was a problem hiding this comment.
For collector-only runs, this endpoint reads legacy metric projections rather than the canonical MetricEvent rows delivered to ADX, so the notebook can miss successfully ingested metrics. Please use the existing /api/v2/stellar/runs/{run_id}/series API and document its scoped request and points response instead of adding a parallel legacy export.
There was a problem hiding this comment.
Correct, and stronger than the review states: the legacy route is not merely less canonical, it is deprecated.
Verified on main: portal/README.md:134 lists /api/v2/stellar/runs/{run_id}/series; README:138-142 notes canonical typed reads use TauExpRunCatalogRows/TauExpMetricEventRows while raw ExperimentMetrics is legacy-only and not a v2 fallback; the route is wired at server.go:311 and handled at v2.go:502-559 with the structs at v2.go:128-145. The old /api/stellar/series is served with Deprecation: true and a successor-version link (server.go:314-328, 971) - so a notebook reading it can miss successfully ingested metrics AND is building on a route already marked for removal.
New §5.2 documents the read API: the scoped request ({run_id} path, required target, required metric, workspace/project, start_step/end_step/max_points) and the points response (v2SeriesPoint {int64 step, float64 value} inside v2SeriesResponse.points). §5's diagram, §7 item 4 and §9/§11 are updated, and §0 records it as R14.
One related gap this surfaced that you did not raise: the companion document docs/design/notebook-plugin.md:257 also uses the deprecated /api/stellar/series. It is out of scope for this one-file change, so I recorded it in §7.4 and asked it as §11 item 7 rather than silently leaving it or widening this PR.
…s an adapter) Design review for how a researcher sees training metrics from a notebook-submitted run, replacing the original per-job TensorBoard sidecar proposal. Recommendation: converge on the metrics path TauGrid already has and treat TensorBoard's tfevents as an input adapter. The document provides the requested format analysis (the house W&B-style JSONL contract beside tfevents), records the decisions, compares costs against the deferred service, and preserves the original sidecar design as a documented alternative that is deferred rather than rejected. The adapter is placed at the collector's typed-projection boundary, so the existing source checkpoints, durable spool and queued ADX delivery are reused instead of bypassed. That boundary is stated precisely: the projection consumes history-shaped scalar records and emits canonical MetricEvent NDJSON, which spool replay and queued delivery validate before ADX ingests it. Non-scalar preservation is conditional on durable retention and run-associated registration of the original event files, since representing them as artifacts does not by itself keep them reachable. Also records what verification found: portal/internal/expimport decodes no event files despite its README wording, monitoring/gpu-metrics-collector writes Node conditions rather than reading training metrics, and an earlier revision's claim that taugrid-metrics-collector does not exist was wrong - the component is real at metrics/experiment-metrics-collector.
e208a6f to
4ea2008
Compare
…contract
Five review corrections, all changes to the document rather than replies.
The load-bearing claim in this design was wrong: the source checkpoint layer
cannot be reused unchanged. `readSource` truncates at the last newline and tracks
line counts, while `Runner.project` splits those bytes on newlines and decodes JSON
objects - so a framed tfevents source, whose protobuf payload may contain 0x0a and
whose file may end mid-record, can checkpoint inside a frame and make restart fail
permanently or misdecode. The new framing-aware abstraction advances only across a
complete TFRecord frame (length, masked CRC32C of length, payload, masked CRC32C of
payload), treats a CRC mismatch as a hard error that does not advance, and retains a
truncated tail for the next pass rather than checkpointing past it. Ordering is per
logical run, explicitly not a global total order.
Run identity was also undefined. TensorBoard's logical run lives in the event-file
directory hierarchy while Tau's deterministic event ID folds in the metric file, so
Tune trial directories could merge into one series and event-file rotation could
evade deduplication. The document now maps the relative hierarchy onto Tau
project/experiment/group/run and the metric prefix, and derives the file identity
from a run-scoped logical source key rather than the physical path, with the segment
in the checkpoint.
The live/durable loss pane also assumed an opt-in that is off by default. A
submission contract now states what enables the collector, what identity is resolved
and stamped, and - because the supported online row contract needs `_step` and
`_timestamp` while the companion proposal emits `step` and `loss` - an explicit
normalizer for both the metric-file and stdout paths.
The compatibility claim is narrowed to what the evidence supports: TensorBoard
scalar events can be normalized into the canonical MetricEvent pipeline. The
normalization rules are stated and separately: non-finite numerics are rejected as
fatal while non-scalar values are dropped, which the review conflated.
The notebook read API now uses the existing /api/v2/stellar/runs/{run_id}/series
rather than a parallel legacy export - and the review understated it, since the
legacy route is served with `Deprecation: true` and a successor-version link.
Summary
Design review for how a researcher sees training metrics from a notebook-submitted run.
Recommendation: converge on the metrics path TauGrid already has, and treat TensorBoard's
tfeventsas an input adapter. This revision replaces the original proposal to add a TensorBoardsidecar service; that option is preserved in the document as a documented alternative, deferred
rather than rejected.
What is in it
A single document,
docs/design/notebook-tensorboard-integration.md, containing:_step,_timestamp, tagkeys, scalar import, closed immutable chunks) beside
tfevents(TFRecord framing with maskedCRC32C, protobuf
Event,wall_time,simple_value/tensor, append-only). Verdict: differentencodings, isomorphic scalar semantics, so a converter is the right integration and a parallel UI
is not.
Related issue
Closes #
Validation
Docs-only change; no code or tests changed.
Findings were checked against
mainrather than a working tree, which is what the earliermistake came from:
Compatibility and operational impact
None. No implementation is proposed for merge in this change.
For reviewers
Two repository inconsistencies, both re-verified against
main, are recorded in the document:portal/internal/expimportis described as a TensorBoard importer but decodes no event files (itsonly TensorBoard tie is tag/card naming semantics), and
monitoring/gpu-metrics-collectorwritesNode conditions rather than reading training metrics.
The document also records one correction to its own earlier revision. It claimed
portal/README.mdnames ataugrid-metrics-collectorthat does not exist in-tree. That was wrong:the component is real, at
metrics/experiment-metrics-collector/(a Go module withcmd/taugrid-metrics-collector) plusimages/taugrid-metrics-collector/Dockerfile, listed as afirst-party image in
scripts/lib/image-specs.sh. The mistake came from searching a checkout thatwas stale relative to
main. This settles the open question about which component should accept atfeventsadapter: it is the training-metrics sidecar TauGrid already ships, so that question isclosed in the document rather than left for a maintainer.
Checklist