Data
The data resource publishes robot data to Qualia and downloads exact published
trees. Uploaded bytes go straight to storage; Qualia verifies the manifests and
publishes them together with the data, never handling the bulk bytes itself.
Install
Section titled “Install”pip install 'qualia-sdk==0.8.0rc11'
# or with uvuv pip install 'qualia-sdk==0.8.0rc11'
This deployment runs ahead of the public release, so it needs the matching
pre-release wheel — 0.8.0rc11. Pin it rather than upgrading:
the upload protocol is negotiated exactly, so the client and the endpoint
it talks to have to be the same generation. A plain pip install qualia-sdk resolves to the current public release and
will be turned away by this deployment.
The native transfer engine that powers upload/download is bundled in the wheel — there is no separate step.
Upload a dataset
Section titled “Upload a dataset”Stage a local dataset directory and atomically publish its validated source, Git history, and lakehouse rows in one call. Episodes and frames become visible only after the paired publication succeeds.
from qualia import Qualia
client = Qualia()
result = client.data.upload("/data/gongsta", name="gongsta")print(result["dataset_id"], result["episode_count"], result["total_frames"])Response
{ "repo_path": "acct-01234567-89ab-4cde-8fab-0123456789ab/gongsta", "name": "gongsta", "file_count": 128, "committed_files": 128, "dataset_id": "…", "dataset_version_id": "…", "episode_count": 42, "total_frames": 51840}Parameters
Section titled “Parameters”| Parameter | Default | Description |
|---|---|---|
local_dir | — | Directory of dataset files (or an MCAP session) to upload. |
name | — | Dataset name within your account (a single path segment). |
source_format | None | One of lerobot_v2_1, lerobot_v3, or mcap_ros2. None reads it from the directory: codebase_version in meta/info.json for LeRobot, any .mcap for a session. |
max_workers | None | Files read and chunked at once; None means 4. Bounds CPU, RAM and disk so a large upload can’t saturate your machine. |
num_threads | None | Native chunk/hash thread-pool size; None means about half your cores. |
quiet | None | True silences the live per-phase progress on stderr. QUALIA_QUIET=1 does the same for a whole process. |
on_progress | None | Called with one dict per progress state (source, message, elapsed_s, and more), so you can render progress yourself. Read it tolerantly: new keys ship without an SDK release. |
allow_underivable | False | Upload an MCAP session even when no bag in it can be derived. It cannot lift a LeRobot structure refusal. |
raise_on_underivable | False | Raise UploadUnderivableError (carrying the receipt) when no episode could be derived, instead of returning the receipt with ingest_verdict="underivable". |
merge_target | None | Existing merged dataset to append this upload to, as a full resource name (accounts/{account}/datasets/{dataset}); a bare id is refused. None uses the destination saved by qualia dataset destination set, but only under the API host, account and workspace it was saved for. |
standalone | False | Keep this upload its own dataset even when a saved destination matches. Cannot be combined with merge_target. |
rig | None | Gripper to convert a handheld (UMI) recording at as it lands: its resource name, id, display name or rig key ("umi-handheld"). See Handheld (UMI) recordings. |
Re-uploading a dataset only sends the chunks that changed — unchanged files are skipped client-side, and edited files upload just their changed regions.
Chunk reuse across client restarts
Section titled “Chunk reuse across client restarts”Until now an ordinary upload() kept no record of how the previous version of
a file was cut into chunks, so an edited file went through the ordinary cold
upload path every time.
upload() now keeps a small on-disk cache of each file’s chunk boundaries
(chunk IDs plus offsets and sizes; never bytes, credentials, URLs or manifest
IDs) that survives client restarts. On the
next upload of a changed file the SDK re-chunks the current file as usual,
compares the unchanged prefix and suffix against the cached boundaries, and
asks the server — under your current authorization — whether those chunks
still exist. Only chunks the server confirms are kept; everything else uploads
normally. A missing, damaged or unreadable cache is silently treated as a miss.
What this saves is bytes stored, not time. On the harness’s 36.3 MB changed-video fixture, re-uploading the edited file after a client restart stored 2,922,261 bytes without the cache and 26,741 bytes with it. No upload-time speedup is claimed: the file is still read and chunked in full.
Uploading a directory writes the cache once at the end, however many files it holds, so the database work does not grow with the file count. The work of preparing each eligible file’s hint does grow, though it stays small: on one development machine, offering 1,009 multi-chunk files took about 97 ms and 10,009 took about 301 ms.
The cache applies to changed files of at most 64 MiB in ordinary directory uploads. See Configuration for where it lives, how to disable it, and how it is bounded.
Import an exact Hugging Face dataset
Section titled “Import an exact Hugging Face dataset”The SDK sends only an exact lowercase 40-hex commit and optional token to the service. The import runs entirely server-side, streaming the pinned Hugging Face tree straight into storage; dataset bodies and scratch files never touch the SDK or any Qualia service:
from qualia import Qualia
client = Qualia()repo = "qualia-robotics/openarm-cube-in-box-v1"revision = "17c29c487d886878ddcb3c5649101b2860fbc047"
result = client.data.import_huggingface( repo, revision, "cube-in-box", source_format="lerobot_v3",)print(result["dataset_id"], result["dataset_version_id"])For private or gated repositories, pass hf_token=os.environ["HF_TOKEN"].
The token appears only in the initial submission and is never returned in poll
results or progress events. The worker authenticates the exact remote file
identities and publishes an immutable DatasetVersion.
An import always creates a new dataset. The service checks the name before anything runs:
- If a dataset already has the name, the import fails at once with a 409
(
dataset_name_taken). Pick another name. - If another import into the name is still running, the import fails with a 409
(
dataset_name_importing), unless it is the same import: same repository, revision, format and name. Then your call joins the running import and returns its receipt, so a second start never imports twice. - Calling again with the same
idempotency_keyrejoins the import while it runs, and returns its final receipt once it has finished. If the earlier wait timed out, this is how you pick the import up again. Reusing a key for different coordinates fails with a 409 (idempotency_key_reused).
Qualia intentionally rejects dot-prefixed Git-control paths. The importer permits
only the exact root names .gitattributes and .gitignore: it downloads and
verifies them, commits their path, size, Git blob OID, and content identity under
excluded_repository_metadata, and removes them before publication. Any
other hidden path, .lock path, or meta/qds/** attempt fails preflight. The
provenance sidecar itself is published at the non-authoritative,
meta/qualia/imports/huggingface-dataset.json path.
The pinned medium validation fixture
TobiBrtnr/openarm_shirt_folding_new_converted@c34057e9cb7760f939dbd5759679c12818fa896b
is LeRobot 2.1 and about 2.08 GB. Its pinned media inventory reports AV1 even
though legacy metadata may label it avc1; Qualia treats validated media bytes as
authoritative. The public metadata does not establish collection hardware, so
do not describe it as real-robot data without separate provenance evidence.
Upload a robot MCAP session
Section titled “Upload a robot MCAP session”Recorded robot sessions upload through the same call with
source_format="mcap_ros2". Lay the session out as one subdirectory per
recording, each holding its .mcap bags. Every .mcap is ingested on its own,
so rosbag2 split files (bag_0.mcap, bag_1.mcap, …) are accepted; a take that
straddles a split becomes two episodes. An episode.json sidecar may provide
normalized task and mode fallbacks when the bag does not. An optional
meta/ingest.toml at the session root declares state and action topics, the
clock and episode boundaries; see
Declaring state and action channels:
session-2026-07-20/ meta/ ingest.toml # optional episode-000/ episode-000.mcap episode.json # { "task": "fold the towel", "mode": "teleop" } episode-001/ episode-001_0.mcap # rosbag2 split files episode-001_1.mcap episode.jsonresult = client.data.upload( "/data/session-2026-07-20", name="dk1-kitchen", source_format="mcap_ros2",)print(result["dataset_id"], result["episode_count"], result["total_frames"])A handheld (UMI) recording uploads the same way with rig="umi-handheld", the
workspace’s UMI gripper. The server writes the gripper’s rig and TCP calibration
into the upload’s meta/ingest.toml, so the pair converts to gripper poses as it
lands; your local file is never written:
result = client.data.upload( "/data/umi-session", name="umi-kitchen", source_format="mcap_ros2", rig="umi-handheld",)Then pick its robot and solver and resolve its poses at the robot’s TCP; see Set up a handheld (UMI) dataset.
The upload checks every bag before a byte moves. A damaged or empty bag is
listed and uploads as recorded; the rest of the session publishes. Only a
session with no .mcap at all, or with every bag underivable, raises
UploadBlockedError and uploads nothing; allow_underivable=True uploads it
anyway. A missing sidecar is valid; Qualia derives task and recording purpose
from authenticated bag contents when available.
Read the ingest report
Section titled “Read the ingest report”Ingest degrades instead of failing: a bag missing a camera drops that camera from the whole dataset, and an episode that never produced video for a camera publishes without it. Every such decision is in the source’s ingest report, which is the answer to “which cameras does this dataset actually have”:
report = client.data.ingest_report(dataset.path) # acct-<uuid>/<name>for camera in report.camera_summary(): print(camera.camera, camera.recorded_sources, camera.dropped, camera.detail)for finding in report.findings: if finding.lost: print(finding.code, finding.camera, finding.message)report.sources holds one entry per ingested bag with its topics, cameras and
findings. The model keeps unknown fields, because the ingest adds findings
without an SDK release. A dataset without a report (an imported LeRobot tree)
raises QualiaAPIError with status 404.
client.datasets.ingest_report
reads the same report by dataset id.
Download a dataset
Section titled “Download a dataset”Download a dataset’s version into a folder. Name the dataset alone for its
newest version, or add a version after a colon. format="original" downloads
every file uploaded to the dataset instead, byte for byte:
result = client.data.download("my-dataset") # newest version, into ./my-dataset-<tag>result = client.data.download("my-dataset:1.0", "/data/robot-run", format="mcap_ros2")result = client.data.download("my-dataset", format="original") # ./my-dataset-originalprint(result.destination, result.file_count, result.byte_count)The dataset is its slug, its id or its full name. The version is a tag, stable
(the newest) or a digest prefix. A version id (DatasetVersion UUID) is not
accepted. The head, what a dataset yields right now, has no download:
tag it first. A dataset with no version yet says so; publish
one first.
| Parameter | Description |
|---|---|
dataset | "<dataset>" or "<dataset>:<version>" |
dest | New folder; must not exist. Default <dataset>-<tag> here, plus -v21 or -mcap for those formats |
format | lerobot_v3 (default), lerobot_v2_1 (for loaders that predate v3), mcap_ros2 or original |
on_progress | Called with progress steps (below) |
timeout | Seconds to wait for the server to build the version (default 30 minutes) |
parallel | Files fetched at once (default 4) |
format="original" is every file uploaded to the dataset, byte for byte. It
also holds what ingest stored beside those files: the annotations under
meta/qds, meta/ingest_report.json and, for an MCAP upload, the LeRobot data
and videos ingest made from the recordings. Trims and splits are not applied:
the takes are whole, and a split’s pieces are only annotations under meta/qds.
When the dataset has any, the call warns with UncuratedDownloadWarning, saying
how many and how to download the curated episodes (tag a version, then download
it). It takes no version, and only an upload has files of its own: a merged
dataset or mixture is refused. The folder defaults to <slug>-original, and the
call returns an OriginalDownload (destination, dataset, file_count,
byte_count, downloaded_file_count, reused_file_count). For now it goes
over the older /v1/datasets/download routes, which answer organization keys
only.
Uploads accept and flag video rates that differ from the state and action row rate, but downloading a version and fine-tuning require re-exporting the video at the row rate and uploading again.
Progress arrives as dictionaries, one at a time:
def show(event): if event["step"] == "preparing": print("The server is building", event["version"], event["state"]) elif event["step"] == "listing": # format="original" only print("Listing the files") else: print(f'{event["files_done"]}/{event["files_total"]} files')
client.data.download("my-dataset:1.0", on_progress=show)The first time a version is downloaded in a format, the server builds it, and
that needs a key that can write to the dataset (data:write). After that, any
key that can read the dataset downloads it. Bytes come straight from object
storage. The SDK checks every file against its sha256 in a private folder next
to dest (.<dest>.qualia-version-partial) and creates dest only when all
files match, so a failed download never leaves a partial dest. If a download
stops, run the same call again: the files already checked are kept. The call
returns a VersionDownload (see
Download a version).
format="original" checks every file against its digest the same way, staged
in .<dest>.qualia-download-partial; a large file’s plan is asked for in pages.
If it stops, that folder is kept and the SDK prints where it is; run the same
call again to resume, a partly downloaded large file included. To discard the
progress instead, delete that folder and the .<dest>.qualia-download-state
folder beside it.
A version that contains a blocked episode (a NaN, an infinity, a missing value
or a wrong width in its state or action rows) is refused, because a download
carries every episode. Publish a curated version without it first, with
apply_curation(evidence, idempotency_key=..., name="<new dataset>"), and
download that. If every episode is blocked, fix the values at the source.
format="original" is not refused: it renders nothing, so it downloads the
upload’s files, blocked episodes included.
Explore the episode lake
Section titled “Explore the episode lake”Filter episodes across every source dataset in the authenticated account with one bounded query. The result includes one offset page, totals over the complete filtered set, and task, tier, and embodiment facets. Each facet axis excludes its own filter while retaining every other filter.
from qualia import LakeExploreQuery, LakeNumericPredicate
query = LakeExploreQuery( tasks=["fold shirt"], tiers=["retrainable"], tags=["reviewed"], numeric_predicates=[ LakeNumericPredicate(field="jerk_mean", op="lt", value=0.4) ], limit=50,)page = client.data.explore(query)print(page.total, page.total_frames, page.facets.by_task)for episode in page.episodes: print(episode.dataset_name, episode.episode_index, episode.metrics)Numeric fields are the lake columns duration_s, frame_count, and
quality_score, plus Qualia’s typed computed metrics. Channel predicates support
action, state, and telemetry streams with min, max, mean, or std.
Every matching episode carries channel_predicate_evidence: the exact native
channel, index, aggregate observation, operator, and threshold that satisfied
each predicate. The SDK binds that evidence back to the ordered request and
rejects missing, changed, non-finite, or non-satisfying observations.
Search is a literal, case-insensitive substring match over task and dataset
name; % and _ have no wildcard meaning. A facet axis returns at most 512
values, while by_task_total_values, by_tier_total_values, and
by_interface_hash_total_values report the complete cardinality. Active
selections are retained when an axis is truncated.
Each response’s page, totals, and all facet axes come from one database
statement and therefore one live MVCC snapshot. page_limit and page_offset
bind the response to the request. Offset pagination is not mutation-stable
between calls: after publishing data or changing review state, restart at
offset zero. Use exploration for analysis and review, then publish a pinned
DatasetVersion for reproducible training. The SDK rejects malformed rows,
duplicate identities, inconsistent totals or facets, altered evidence, and
truncated or mismatched pages.
Split episodes at their recorder markers
Section titled “Split episodes at their recorder markers”A continuous teleop session is uploaded as pause/resume-delimited sequences. The presses inside one — a foot pedal calling a take a success or an issue — are recorded as markers. Splitting draws the windows to keep on each take’s draft. The recording and dataset head stay unchanged; publishing a version from a Publish preview creates the pieces that version contains.
These calls are on client.episodes (qualia.v1, since 0.8.0rc1). They need
your account, and they name an episode through the upload it was recorded in:
a bare id with dataset=, or its full name.
episode = client.episodes.get(episode_id, dataset=upload)for marker in episode.markers: print(marker.time_seconds, marker.label, marker.role)
result = client.episodes.split_at_markers( upload, [episode_id], labels=["success", "issue"])for split in result.splits: print(split.episode, split.piece_count, split.draft.etag)for skipped in result.skipped: print(skipped.episode, skipped.reason)labels names marker labels as the recording wrote them, never the role the
ingest assigned. Each cut closes the span before it, and the draft piece keeps
that cut’s labels as tags. To cut at instants of your choosing, use
client.episodes.split(upload, {episode_id: [12.0, 32.8]}),
seconds from the start of the recording.
start_labels and end_labels narrow each episode first, to ITS first and last
marker so labelled; given without labels, each draft keeps one piece, the
window. dropped_lead_frame_count/dropped_tail_frame_count say what was
left out. Pass a list of episodes or one filter= to cut a whole session; an
episode the server cannot cut that way (no marker matched, or a split is already
drawn) is listed in skipped with its reason and the rest go ahead. Takes another
person is editing are listed in locked with their holders.
A split is planned inside the draft’s kept window, or the take’s existing trim
when it has no draft. dropped_lead_frame_count and dropped_tail_frame_count
include the frames that trim leaves out. A saved split returns its draft and
piece_count; children is empty. Pass validate_only=True to see the plan
without saving it. client.episodes.unsplit discards a split draft, and
client.episodes.discard_draft discards any drawing. Existing published versions
keep their pieces and pins while the take’s draft remains editable.
Qualia privileges no marker vocabulary — the mapping from recorded values to roles is declared per upload.
Trim, name, and undo episode edits
Section titled “Trim, name, and undo episode edits”Trim and subtask edits save a draft, just as studio edits do. Task and tag labels still publish a new revision on head. An SDK update combining both sends the drawing first and the labels second; those requests are not atomic.
trimmed = client.episodes.trim_to_markers( upload, episode_ids, start_labels=["ready"], end_labels=["done"])restored = client.episodes.clear_trims(upload, episode_ids)for skipped in trimmed.skipped: print(skipped.episode, skipped.reason)Each episode is narrowed to start at ITS first marker so labelled and end at ITS
last, so one call fits a whole selection whose presses landed at different
instants. The recording is never overwritten: clear_trims draws the whole
recording again. An episode with no such marker, or one outside its frames,
lands in skipped with a reason.
name_subtasks uses the same boundary rule to label a span instead of carving
it — useful when you want the structure recorded but the episode left whole:
named = client.episodes.name_subtasks( upload, episode_ids, labels=["grasp", "place"], boundary="ends_at", # or "starts_at" to have each marker OPEN its span)Each drawing helper works from the effective draft, preserving its other edits,
and sends expected_draft_etag so a concurrent drawing returns QualiaAPIError
with code "aborted". A trim over a drafted split is refused; a subtasks edit
preserves the split’s pieces. For one episode, client.episodes.update edits
trim, subtasks, task, tags and the other editable fields:
episode = client.episodes.get(episode_id, dataset=upload)client.episodes.update(episode.name, tags=["reviewed"], etag=episode.etag)client.data.list_episode_revisions(episode_id) still reads the episode’s
append-only revision chain, the audit trail of who changed it and when.
Analyze an immutable DatasetVersion
Section titled “Analyze an immutable DatasetVersion”Run native action/state health analysis against one exact published version:
report = client.data.health_for_version( "44444444-4444-4444-8444-444444444444")print(report.health_score, report.grade)for flagged in report.flagged_episodes: print(flagged.member_ordinal, flagged.episode_revision_id, flagged.reasons)Qualia re-proves the complete ordered membership and dataset-snapshot identity. It then fully proves and reads exact Parquet ranges for an evenly spaced, deterministic sample of at most 120 members. Every flag is bound to an immutable episode revision and is a review recommendation, not an automatic deletion.
The sample is explicit in report.sampling. This synchronous analysis is not a
byte scrub of unsampled artifacts or video, and it does not claim model-based
visual or behavioral metrics without a pinned model revision and output
artifact; those fields are marked unavailable in evidence_completeness.
Review and apply bad-data suggestions
Section titled “Review and apply bad-data suggestions”Analyze one or more immutable DatasetVersions without changing them. Every recommendation includes a risk score, machine-readable reasons, and supporting evidence. The response binds the complete recommendation set to an evidence digest and the exact paired Git/lakehouse head.
evidence = client.data.suggest_curation( ["44444444-4444-4444-8444-444444444444"])for suggestion in evidence.suggestions: print(suggestion.episode_index, suggestion.risk_score, suggestion.reasons)After review, publish the analysis’s sealed recommendation set as a new
immutable DatasetVersion. apply_curation starts a durable publication and waits
for its receipt; reuse the same idempotency key after a lost response or a
timeout to resume it. The source version, source bytes, and Git history remain
available.
from uuid import uuid4
receipt = client.data.apply_curation( evidence, idempotency_key=uuid4(), definition_id="55555555-5555-4555-8555-555555555555", note="Reviewed automated bad-data evidence",)print(receipt.version_id, receipt.membership_hash, receipt.git_oid)Application fails closed if the analysis selection, evidence digest, repository head, target definition, membership counts, snapshot, or Git receipt changes between review and publication.
Resuming with the same idempotency key inside the same process — passing the
same evidence object again — replays cleanly. A later process that no longer
has that evidence (a retried CLI invocation, say) should call
resume_curation_publication(idempotency_key) first. It follows a publication
that is queued, running or published to its receipt, and raises when the
publication failed: a failed key stays failed, so publish again under a new key.
It returns None when no publication exists for the key, or when its lease is
retryable; then suggest fresh evidence and publish with the same key.
receipt = client.data.resume_curation_publication(idempotency_key)if receipt is None: evidence = client.data.suggest_curation([version_id]) receipt = client.data.apply_curation( evidence, idempotency_key=idempotency_key, name="curated" )Run a durable curation analysis
Section titled “Run a durable curation analysis”health_for_version reads one version’s health directly. When you want the
analysis to survive a disconnect — or to span several pins at once — start a
durable one and read its progress back:
import time
analysis = client.data.start_curation_analysis( [version_id], idempotency_key=operation_uuid,)
while analysis.status not in ("ready", "failed"): time.sleep(2) analysis = client.data.get_curation_analysis(analysis.analysis_id)
if analysis.status == "failed": raise RuntimeError(analysis.failure_detail or analysis.failure_code)
page = client.data.list_curation_suggestions(analysis.analysis_id)for suggestion in page.suggestions: print(suggestion)The analysis is keyed to exact DatasetVersion pins, so the same
idempotency_key recovers the running analysis instead of starting a second
one. Suggestions are only listable once status reaches ready — the server
enforces that seal — and they page by opaque keyset — pass page_token to continue. Applying them is
apply_curation, which publishes a new immutable DatasetVersion and deletes
nothing.
List datasets
Section titled “List datasets”list() is the legacy list: it reads the older list route and returns
qualia.Dataset. client.datasets.list(filter="kind != MIXTURE") replaces it,
reads the qualia.v1 Datasets API and returns qualia.v1.Dataset; it needs your
account (the API key’s own). Uploads merged into one dataset
(client.datasets.merge) are listed under it, as a dataset of kind MERGED;
filter kind = MERGED for those. A merge refuses uploads that differ in what
training freezes, and asks about what a write settles: preview it with
validate_only=True, which returns the conflicts and writes nothing, then
answer each with resolutions. A label or Task needs no answer: the merged
dataset takes its uploads’ commonest value, or the one you pick, as its own;
each upload keeps its own. A whole merge cannot be undone: the merged dataset
holds the uploads (their reads keep answering), and every write goes through
the merged dataset. remove_merged_member takes one upload out again; it
becomes a standalone upload with its data and review decisions, and the
merged dataset’s history records the removal.
uploads = ["week-a-id", "week-b-id"]conflicts = client.datasets.merge(uploads, display_name="Cable bench", validate_only=True)answers = [ {"field": c.field, "channel": c.channel, "value": c.sides[0].value} for c in conflicts if not c.blocking]merged = client.datasets.merge(uploads, display_name="Cable bench", resolutions=answers)page = client.data.list()for d in page.items: print(d.artifact_id, d.path)client.datasets, client.episodes, client.versions and client.mixtures
cover the rest of the qualia.v1 API: mixtures, episode rulings and edits, and
version tags. The SDK README has examples.
Edit collection tags
Section titled “Edit collection tags”client.datasets.update(..., tags=...) edits collection classifications through
the saved method, environment, origin and controller metadata. The recognized
tags are teleop, vr, umi, egocentric,
policy-rollout, sim, real-world, human and gello; you can also keep
your own tags. The limit is 64 distinct tags. A changed, nonempty classification
set replaces those metadata choices and the custom tags in one atomic update.
Nonempty custom-only input, or unchanged classifications with edited customs,
replaces only custom tags; undeclared or explicit Unknown methods and inference
are preserved. When classifications are present, echo them to clear customs without
changing methods. tags=[] explicitly makes every collection dimension Unknown and clears
customs, preventing inference from refilling the set.
dataset = client.datasets.get("dataset-uuid")dataset = client.datasets.update( dataset.name, tags=["umi", "real-world", "human", "gello", "warehouse-a"], etag=dataset.etag,)dataset = client.datasets.update(dataset.name, tags=[], etag=dataset.etag)# Custom-only replacement preserves the collection classifications and Unknown.dataset = client.datasets.update(dataset.name, tags=["warehouse-b"], etag=dataset.etag)The SDK first reads the owner and uses its etag when none was supplied. A stale
caller etag is refused rather than retried with a new one. These edits require a
server that returns the independent collection metadata fields. Contradictory
values on one axis, such as vr together with gello, are refused.
A merged dataset owns this set: edits change C alone, and adding uploads
preserves its tags, including Unknown. Mixture selection, collection filters
and reach eligibility use the owner’s tags. Recording methods on episodes,
episode curation tags and immutable version tags retain their own meanings.
The older methods= update changes only the six-name compatibility view,
preserving other dimensions and free tags. Use tags= separately from those
legacy collection arguments.
Set up a handheld (UMI) dataset
Section titled “Set up a handheld (UMI) dataset”client.datasets does what the dashboard’s Setup strip does: pick the robot a
handheld upload plays on, pick the solver and placement, and move the poses to
the robot’s TCP. It acts in the API key’s own account.
poses() says where an upload’s poses are: root (the gripper root’s
<side>.ee.* poses, as ingest placed them), tcp (each hand’s TCP too) or
recorded (a raw recording, which a resolve converts). It also names the capture
gripper, the workspace gripper whose rig_key is the upload’s rig, and whether
its calibration has changed since the poses were resolved.
poses = client.datasets.poses("dataset-uuid")print(poses.at, poses.rig, poses.current)
setup = client.datasets.setup( "dataset-uuid", robot="robot-uuid", # an id or accounts/{a}/robots/{r} solver="pyroki", # "pyroki", "dls", or "" to follow the robot's model base=[0.0, 0.0, 0.0], # metres in the robot's base frame, within 5 m heading_deg=0.0,)print(setup.changed, setup.resolve, setup.resolve_skipped)Only what you pass is written; the rest of the reach settings are kept, and a call that changes nothing writes nothing, so it can be repeated.
Resolving the poses at the TCP
Section titled “Resolving the poses at the TCP”Picking a robot with robot= starts a resolve at the TCP by itself when all of
this holds:
- the dataset is an upload with handheld poses (not a mixture or a merged dataset);
- every arm of the robot has a TCP;
- the workspace has a gripper for the upload’s rig with a tracker-to-TCP calibration, the capture gripper;
- the poses are not at the TCP yet, or are there with another calibration.
The resolve uses the capture gripper’s calibration, not the robot’s own
gripper’s: the robot only decides when. Otherwise nothing starts and
resolve_skipped says why (robot-without-tcp, no-capture-gripper,
no-calibration, up-to-date, not-an-upload, no-poses). resolve=False
never resolves; resolve=True resolves with the capture gripper whatever the
robot.
A resolve re-derives the whole upload, so it takes as long as ingesting it did:
minutes to hours. wait=True stays for it, and on_progress sees each state.
setup = client.datasets.setup( "dataset-uuid", robot="robot-uuid", wait=True, on_progress=lambda r: print(r.state, r.progress),)
# Or move the poses by hand, and wait laterstarted = client.datasets.resolve_poses("dataset-uuid", "tcp", wait=False)client.datasets.wait_resolve(started.operation)client.datasets.resolve_poses("dataset-uuid", "root") # back to the gripper rootA failed re-derive raises QualiaError with the server’s reason (a ruling or a
trim made while it ran is one cause: resolve again). A wait that runs out
(TimeoutError, two hours by default) leaves the work running; wait for it with
wait_resolve, and do not resolve again before the poses have moved.
Run the solver
Section titled “Run the solver”Run solver is never started by itself. setup(run_solver=True) presses it once
the poses have settled, after waiting for a resolve it started; checks.run(dataset, "reach") presses it without touching the setup. Follow and stop the run with the
checks resource:
client.datasets.setup("dataset-uuid", run_solver=True)
status = client.checks.reach_status("dataset-uuid")run = status.last_runprint(status.settings.solver, run.state, run.done, run.total)client.checks.cancel_reach("dataset-uuid") # solved takes keep their resultscurrent_key is empty when the server cannot key a run on the dataset’s robot
(none, no model, another workspace): a run would start nothing, and setup
refuses to press it. datasets.update(dataset, reach_settings={"solver": "dls", "base_m": [0.1, 0, 0], "home_heading_rad": 0.0}) writes the settings alone; it
replaces all three, so what you leave out follows the robot’s model, and it
starts no solve.
Watch in-flight uploads
Section titled “Watch in-flight uploads”upload() narrates the server’s side of a transfer on stderr, but only in the
process running it. The account’s pending feed, the one the dashboard’s pending
band renders, is also readable directly, so an upload running detached on a rig
can be watched from anywhere:
for op in client.data.uploads(): print(op.name, op.phase, op.progress_line or op.message)Rows in uploading carry the SDK’s heartbeat (transferred_bytes,
staged_files); rows in preparing carry the worker’s live step in progress,
refreshed with its lease every 30 s; a failed row survives 24 h with
message, failure_retryable and, when ingest got that far, ingest_report.
The model is tolerant on purpose: phase is a plain string and unknown fields
are kept, because the platform adds steps without an SDK release.
Dismiss or cancel an upload, follow an import
Section titled “Dismiss or cancel an upload, follow an import”op = client.data.uploads()[0]client.data.cancel_upload(op.repo_id) # stops it from any machineclient.data.dismiss_upload(op.repo_id, dismissed_through_unix=op.updated_at_unix)
for row in client.data.list_huggingface_imports(): print(row["import_id"], row["status"], row["progress_percent"])client.data.get_huggingface_import(import_id)client.data.cancel_huggingface_import(import_id)Cancel refuses the uploading process’s next step and stops server-side work on
its next heartbeat; nothing is published, and it is refused once the upload has
published. Dismiss hides a failed or finished entry from the feed and keeps the
record of why it failed; a live upload is never hidden (phase_now names it).
A key restricted to workspaces is refused on these routes.
Browse DatasetVersions and their sources
Section titled “Browse DatasetVersions and their sources”list returns canonical datasets. A DatasetVersion is an immutable pin of one
— what training actually consumes. Since 0.8.0rc1 versions are qualia.v1 tags
on a dataset, and each tag carries the DatasetVersion id it pins:
for tag in client.versions.list(dataset).items: print(tag.tag, tag.dataset_version_id, tag.create_time)
tag = client.versions.get("stable", dataset=dataset) # or a tag, or a digest prefixcommits = client.versions.list_source_commits(tag.name)for commit in commits.items: print(commit.dataset, commit.repo_id, commit.git_oid)list_source_commits returns the exact repository commits that contributed the
version’s members, which is how you answer “which upload did this training set
actually come from”. Lists page with limit / cursor.
Saved lake queries
Section titled “Saved lake queries”A dataset definition is a saved lake query plus the versions curation published into it.
for definition in client.data.list_definitions().defs: print(definition.id, definition.name)
definition = client.data.get_definition(definition_id)
consumers = client.data.list_definitions_by_source(source_dataset_id)list_definitions_by_source answers the reverse question — which definitions
consume a given source, directly or through an immutable pin — so you can see
what a re-upload would affect before you publish it.
Tag a dataset
Section titled “Tag a dataset”An upload registers a dataset. Training features, health, curation and materialization all take an immutable DatasetVersion, so a fresh upload needs a tag before anything downstream can run:
tag = client.versions.create(dataset, "1.0", note="first cut")print(tag.reference, tag.dataset_version_id, tag.episode_count)A tag freezes exactly what the dataset yields now: an upload’s kept episodes,
a mixture’s episodes it has not rejected. Dropping flagged work is
apply_curation’s job. Tags never move, and a later tag of a changed dataset
pins a new version. client.versions.compare(dataset) shows what changed since
the last tag and suggests the next name.
client.versions.create tags head and leaves saved trim, split and subtask
drawings out. The returned version’s drafts_not_included counts those drafts.
Use the dashboard’s Publish dialog to include them in a measured version;
SDK and CLI helpers for publishing previews will follow separately.
Define a saved query
Section titled “Define a saved query”create_definition saves a lake query as a definition, a target apply_curation
can publish versions into. pin_version freezes what the query selects now as
the definition’s next DatasetVersion:
from qualia.models import DatasetSavedQuery
definition = client.data.create_definition( "kitchen-2026-09-16", query=DatasetSavedQuery(dataset_ids=[source.artifact_id]), description="one recorded session",)version = client.data.pin_version(definition.id, note="first pin")Pinning a definition stays on the /v1 path: saved queries have no qualia.v1
equivalent. Tag an upload or a mixture with client.versions.create instead.
DatasetSavedQuery mirrors ExploreLake’s filters field for field:
dataset_ids, task, tier, min_duration_s / max_duration_s, tags,
review_state, search, numeric_predicates, predicates and an exact
episodes selection. An empty query selects the whole lake.
Get immutable training features
Section titled “Get immutable training features”Training accepts a pinned DatasetVersion, never a mutable repository name or
external dataset identifier. Retrieve its exact camera descriptors: their key
values are the cameras the dashboard’s training launcher maps onto a model’s
camera slots.
features = client.data.get_training_features( "44444444-4444-4444-8444-444444444444")for camera in features.camera_features: print(camera.key, camera.stream, camera.timebase_numerator, camera.timebase_denominator, camera.frame_timestamp_mapping)Response
{ "dataset_version_id": "44444444-4444-4444-8444-444444444444", "training_adapter_revision_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "camera_features": [ { "key": "observation.images.wrist", "stream": "video/wrist", "timebase_numerator": 1, "timebase_denominator": 1000000, "frame_timestamp_mapping": "time_start_plus_frame_over_fps" } ]}