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¶
- Validates the configuration file against the published model and names what is wrong in the file's own terms.
- 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.
- 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.
- 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.
- Runs the image twice under the platform's confinement: no network, every
capability dropped, a read-only root filesystem with a
noexectmpfs 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. - Checks the document each run wrote.
- When the configuration has an optional channel type (
numberfrom 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 inspectand starts/bin/shin 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.peakunder cgroup v2,memory.max_usage_in_bytesunder 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:
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
--cpusand 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-limitsets 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 withoutcpis 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:
- The peaks are fitted by least squares to the configuration's length terms,
or to every term when it has none (
--termschooses them). No term is negative. - The base is 30% above the fitted base.
- 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.
- 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.