Skip to content

Checking an image

yousleep-verify runs an analysis image the way the platform does and reports whether it follows the contract. It is the same check the platform team runs at registration, so an image that passes locally passes there.

pip install yousleep-common
yousleep-verify --image ghcr.io/example/my-analysis:1.0.0 --config my-analysis.yaml

In a project written by yousleep-init, make verify builds the image and runs this command on it.

The command needs docker on the path and the image present locally, or --pull. It reads only the image and the configuration file, never the source, so it works the same for an analysis in any language.

What it does

  1. Validates the configuration file against the published model and names what is wrong in the file's own terms.
  2. Synthesises a recording that satisfies the configuration: the exact channels, or the fewest of each declared type (at least one, so optional types are exercised), at the lowest acceptable sample rate, slots filled in order. The recording is five minutes long by default, or longer when the configuration requires it. No real data is used.
  3. Adds 20 signals the configuration does not ask for after those channels, at their highest rate, as a polysomnogram carries them: ECG, oximetry, respiration, leg movements and others. The manifest does not select them. An analysis that reads every signal of the file before keeping its own needs memory for all of them, and the peak shows it.
  4. Writes the manifest the platform would write: the selected channels by index, the parameter defaults, the declared cores, the memory the platform would reserve for a recording of this length, an upstream events document when an events input is declared, subject metadata when that is.
  5. Runs the image twice under the platform's confinement: no network, every capability dropped, a read-only root filesystem with a noexec tmpfs at /tmp, an unprivileged user that cannot gain privileges, a limit on processes and threads, a hard memory limit and a CPU allowance, the thread variables set to the declared cores, and one argument. The first run reports its peak memory beside that limit.
  6. Checks the document each run wrote.
  7. When the configuration has an optional channel type (number from 0) or a metadata input, runs once more on the least a recording can offer: only the required channels, and every subject value null.
ghcr.io/example/my-analysis:1.0.0  (ghcr.io/example/my-analysis@sha256:…)
fixture: 300 s, eeg1 (EEG, 100 Hz, header 'EEG 1'), and 20 signals it does not select; 1 core(s), 512 MiB
PASS configuration  'My Staging Analysis v1' by The authors, 1 output label(s)
PASS image          linux/arm64, digest ghcr.io/example/my-analysis@sha256:…
PASS image-labels   licence and source labels present
PASS run            exit 0 after 0.8 s, peak 62 MiB of 512 MiB
PASS document       10 events in 1 blocks
PASS non-empty      at least one event
PASS labels         every label is declared under outputs.events
PASS channels       every channel is one the manifest named (or the whole recording)
PASS values         probability and value appear only where declared
PASS times          every event ends within the 300000 ms recording
PASS determinism    two runs decode to the same events
OK
Check Passes when
configuration The file loads as an analysis configuration, a third-party analysis names where its source is published, under which licence and on what basis it may be offered (data_rights), and at least one output label is declared
data-rights Warning. A third-party analysis references the developer's signed Analysis Declaration (basis: declaration); pending, meaning no declaration is on record yet, is reported as a warning. The declaration is needed before an analysis is offered to every user; the right to use the software itself follows from its licence
image The image is present; its registry digest is reported when it has one
image-labels Warning. org.opencontainers.image.licenses and org.opencontainers.image.source are set
run The container exits 0 within the timeout, under the confinement above. The line also reports the container's peak memory and the limit it ran under
memory Warning. The peak leaves at least 30% headroom under the memory claimed for the fixture, so it is at most about 77% of it. Reported only when it does not: base_system_memory_mib is then probably too low
document A document exists at outputs.events.path and reads as an events document
non-empty Warning. It holds at least one event
labels Every label is one the configuration declares under outputs.events
channels Every channel is one the manifest named, or the list is empty. The fixture's manifest names each channel differently from the file's header label, so output attributed to header labels fails here rather than passing by coincidence
values probability and value appear only on labels declared probabilistic or valued
times Every event ends within the recording
determinism A second run on the same input decodes to the same events
required-only Without the optional channel types and with no subject values, the run exits 0 and writes a readable document. Only when the configuration has something optional
expected With --expected, the events match the recorded document: labels, times and channels exactly, probabilities and values within --tolerance (default 1e-6)

A failed required check fails the run with exit status 1. A warning is printed and does not affect the exit status. --json prints the report as a JSON document, and --keep DIR leaves the fixture, the manifest and the output on disk for inspection.

Reading the peak memory

Every run reports the most memory the container held, read from the kernel's accounting for the container rather than from inside the analysis, so it works for an image in any language.

  • The check reads the image's entrypoint with docker image inspect and starts /bin/sh in its place. The shell runs that entrypoint with the one argument, keeps its exit status and its output, and after it exits writes the container's peak into the output directory.
  • The peak comes from the container's cgroup: memory.peak under cgroup v2, memory.max_usage_in_bytes under cgroup v1.
  • The peak includes page cache charged to the container: files the analysis read or wrote, the recording among them, as well as the memory it allocated. The kernel reclaims page cache before a container runs out of memory, so the peak can be higher than what the analysis needs. For the five-minute fixture the difference is a few MiB.
  • The analysis runs as a child of the shell rather than as the container's first process. Its exit status, its output and every check are the same.

The peak is reported as peak not measurable, with the reason, and the run is otherwise unchanged, when:

  • the image has no /bin/sh, as a distroless image does not;
  • the image declares no entrypoint;
  • the kernel exposes neither file, as kernels before 5.19 do under cgroup v2.

Set base_system_memory_mib so that the peak of this run is at most about 77% of it. The memory that grows with the recording's length is measured separately, as the next section describes.

Measuring memory against recording length

The five-minute fixture shows the memory an analysis needs before any length is counted. A long recording can need several times more, and an analysis that runs out of memory fails only on the long recordings. The claim for a run is the sum of terms in the configuration's resources block:

Term Multiplied by
base_system_memory_mib 1
per_hour_system_memory_mib hours
per_hour_per_khz_system_memory_mib hours × the highest native sample rate of the selected channels, in kHz
per_hour_per_channel_system_memory_mib hours × selected channels
per_hour_per_channel_per_khz_system_memory_mib hours × selected channels × kHz

yousleep-verify --meter measures the peak on long recordings and compares it with the claim at each:

yousleep-verify --meter --quick --image ghcr.io/example/my-analysis:1.0.0 --config my-analysis.yaml

Choosing the recordings

The recordings are synthetic, with the 20 undeclared signals of the fixture at the same rate as the selected channels. The points are chosen so that the effects of length, channels, rate and padding show:

  • every length (1, 8, 24 and 48 hours; 1, 8 and 24 with --quick) at the middle rate with the most channels the configuration accepts;
  • the fewest channels at the middle rate, and the most channels at the lowest and the highest rate, at 8 hours and at the longest length;
  • for each rate, the length just past a power-of-two number of samples within the measured range, 18.4 hours at 128, 256 or 512 Hz;
  • the fewest channels at the highest rate and the longest length.

The rates are 128, 256 and 512 Hz; --rates sets others, and a rate the configuration does not accept is left out. --hours sets other lengths. The default matrix is 14 runs, and 13 with --quick. An unbounded channel count is measured at 6 channels of that type.

The power-of-two point exists because libraries that resample with a fast Fourier transform, MNE among them, pad each channel to the next power of two. Memory then steps with length and roughly doubles just past a boundary, so a recording slightly longer than a boundary can need as much as one twice its length.

--recording PATH measures on a real EDF recording instead, repeatable for several. The file is cut or extended to each length by copying its data records and rewriting the record count in its header, and it keeps every signal it has. Its channels are chosen by the types their labels imply, in file order, and its rate is its own.

Running each point

Each run is the platform's run:

  • the same confinement, the declared cores as --cpus and as the thread variables, and the manifest the platform would write, with the claim for that recording as its memory;
  • a memory limit of 16 GiB, or the memory the docker engine has when that is less, so that the measurement is not cut short (--meter-limit sets it);
  • before the run, a separate container copies the recording into a docker volume with the image's cp. The recording's page cache is then charged to that copy, as it is to whatever writes the file on the platform, and the peak is the memory the analysis holds itself. An image without cp is measured on the recording in place, and the report notes that its page cache counts towards the peak.

Each run is allowed --timeout-seconds plus 60 seconds per hour of recording.

Reading the verdict

The example below measures YASA's sleep staging (a local build of version 1.0.1, tagged dev) with --quick, under emulation on an arm64 machine whose docker engine has 9.7 GiB. Every point has at least 30% headroom, the lowest being 39% at the power-of-two point for 128 Hz.

ghcr.io/yousleep-ai/yousleep/analysis-yasa:dev
claim: 800 + 450*h*kHz + 90*h*ch*kHz MiB; 1 core(s); each run limited to 9934 MiB
13 synthetic recordings, each with 20 signals the manifest does not select

  hours  ch    Hz  peak MiB  claim MiB  headroom  point
   1.00   3   256       502        985       96%  length
   8.00   3   256      1217       2275       87%  length
  24.00   3   256      3189       5224       64%  length
   8.00   1   256      1014       1906       88%  channels
  24.00   1   256      2294       4118       80%  channels
   8.00   3   128       892       1538       72%  rate
  24.00   3   128      1895       3012       59%  rate
   8.00   3   512      1910       3750       96%  rate
  24.00   3   512      5755       9648       68%  rate
  18.39   3   128      1796       2495       39%  padding
  18.39   3   256      2839       4190       48%  padding
  18.39   3   512      5159       7579       47%  padding
  24.00   1   512      4240       7436       75%  channels

PASS every point has at least 30% headroom under the claim
13 run(s) in 9 min 8 s: OK

The command exits 1 when:

  • a point has less headroom than --headroom (default 0.30, so the peak must be at most about 77% of the claim);
  • the claim has no term that scales with length, and the peak grows with length;
  • a run fails, or its peak reaches the run's limit;
  • the image's peak cannot be measured.

Proposing terms

--propose fits the peaks and prints a resources block that holds at every point:

  1. The peaks are fitted by least squares to the configuration's length terms, or to every term when it has none (--terms chooses them). No term is negative.
  2. The base is 30% above the fitted base.
  3. Every fitted rate is multiplied by one factor: the smallest that gives each point the headroom, and at least 1.25, so that the claim grows faster than the peaks beyond the longest recording measured. The points just past a power-of-two boundary usually set it, which carries their padding to the boundaries between the points.
  4. The rates are rounded up to round numbers and the base to a multiple of 50 MiB.
resources:
  cpu_cores: 1
  # Measured 2026-09-26 with yousleep-verify --meter: the
  # peak memory of ghcr.io/yousleep-ai/yousleep/analysis-yasa:dev
  # on synthetic recordings, each carrying 20 signals it does not select:
  #
  #   hours  ch    Hz   peak  claim  headroom
  #    1.00   3   256    502    795       58%
  #    8.00   3   256   1217   2157       77%
  #   24.00   3   256   3189   5270       65%
  #    8.00   1   256   1014   1645       62%
  #   24.00   1   256   2294   3734       63%
  #    8.00   3   128    892   1379       55%
  #   24.00   3   128   1895   2935       55%
  #    8.00   3   512   1910   3713       94%
  #   24.00   3   512   5755   9939       73%
  #   18.39   3   128   1796   2389       33%
  #   18.39   3   256   2839   4178       47%
  #   18.39   3   512   5159   7755       50%
  #   24.00   1   512   4240   6867       62%
  #
  # Least squares: 432 + 233.2*h*kHz + 74.8*h*ch*kHz MiB (rms 216 MiB).
  # The base is 30% over the fitted one; the rates are 1.65
  # times the fitted ones, the least factor of at least 1.25
  # that gives every point 30% headroom.
  base_system_memory_mib: 600
  per_hour_per_khz_system_memory_mib: 385
  per_hour_per_channel_per_khz_system_memory_mib: 125

The proposal is close to the claim YASA's configuration declares, 800 + 450*h*kHz + 90*h*ch*kHz: 3% higher for 24 hours of three channels at 512 Hz, and 19% lower for one hour.

A metering run takes minutes to hours: an image built for another architecture runs under emulation, and each recording of 24 hours at 512 Hz is about 2 GB on disk while it is measured.

Adding a regression test

The checks above verify that the image follows the contract. They do not verify that it still produces the same output as before, which a rebuild can change: a dependency that moved, a model file that is no longer copied in, a change of units. To catch that, record the output once and compare against it on every later run:

yousleep-verify --image … --config … --expected conformance/expected-events.json.gz --record
yousleep-verify --image … --config … --expected conformance/expected-events.json.gz

Record it from a build for the platform's architecture, linux/amd64 (the scaffold's Makefile builds for it by default, also on an arm64 machine). A model's probabilities differ between architectures by more than rounding: YASA's differ by up to 0.03 between arm64 and amd64 builds of the same image, with the same labels. A mismatch names the first differing event and field.

Commit the recorded document beside the configuration. It is a test of your image on a synthetic recording. It says nothing about accuracy, and the platform does not read it as a claim. Re-record it when the analysis changes intentionally.

Running in continuous integration

Run the same command on the image the pipeline has just built:

- run: pip install yousleep-common
- run: docker build -t my-analysis:ci .
- run: yousleep-verify --image my-analysis:ci --config my-analysis.yaml \
         --expected conformance/expected-events.json.gz

The platform runs linux/amd64. On another architecture the check runs the image natively, which verifies the contract but not the build for the platform. Add --platform linux/amd64 to verify that too, at the cost of emulation.

What it does not check

The check does not assess the quality of the analysis. Accuracy is the author's claim, stated under evidence and labelled as such. It does not assess the size of the image or how it was built, because the platform registers an image for following the contract. It does not apply the platform's wall-clock ceiling; --timeout-seconds only bounds the fixture run. Without --meter it measures memory only on the five-minute fixture.

The check at registration

When you submit an image, the platform team runs this command on the platform's architecture and pins the digest it reports. There is no second set of rules. The team then runs yousleep-verify --meter --propose and sets the terms that scale with recording length from its measurements. If the check passes for you, the remaining questions at registration concern provenance and licence, not the container.