feat(verify): check LeRobot prepared-manifest deliveries against receipts - #454
Conversation
Sagar-024
left a comment
There was a problem hiding this comment.
Approved. The part I like most is exit_code_for: 0, 1, and 3 live in one function, 2 is raised for unreadable input before a report exists, and FORMAT.md documents the split so CI can tell an unverifiable delivery from a damaged one. That split is easy to get wrong per command.
Checked against what the #432 contract needs: VerificationReport with .ok and .findings, each finding carrying .uri, .reason, and .detail, and the module importing from catalog and storage only, never from snapshot.py or lerobot.py. Extras ignored, nothing rewritten, nothing re-converted. Holds.
One coordination note for the snapshot verifier (#428): it will consume these same types, so its reasons need to live beside REASON_MISSING and friends rather than inventing a second vocabulary, and once two verifiers share this module it may be worth separating the shared types from the per-delivery verifiers. Neither blocks this PR.
|
Thanks @Sagar-024 , agreed on both notes for #428. I'll keep snapshot reason strings beside the existing CI is green here; ready for maintainer merge whenever @kstonekuan has a slot. |
kstonekuan
left a comment
There was a problem hiding this comment.
The contract is right and I want to say that first, because the thing I am about to raise is not about the shape. VerificationStatus with ok/damaged/unverifiable, exit_code_for mapping 0/1/3 with 2 raised before a report exists, findings carrying uri plus a reason code, unlisted files ignored, bool rejected on size_bytes, parsing the manifest at the boundary and raising rather than returning a report when the input is unreadable. That is the contract you and @Sagar-024 agreed on #432, implemented as agreed. Gate clean, 1616 passed.
The problem is path resolution, and it defeats the use case the issue was filed for.
_findings_for_episode_receipt resolves each episode with fetch_uri(uri), and uri is what storage.uri_for recorded at publish time, which is absolute: a resolved filesystem path for a local root, a full URL for a bucket. So verify_lerobot_import(data_root) reads the manifest from data_root and then verifies whatever the receipt's absolute path points at, which need not be inside data_root at all.
I built a delivery, copied it the way a recipient would, and asked:
original verify -> ok
copy, original present -> ok findings=0
copy damaged, original ok -> ok findings=[]
copy alone, original gone -> damaged findings=['missing']
Read the third line carefully. I replaced the copy's episode with damaged bytes of the same length, and verify reported the copy clean, because it hashed the original. A verifier that certifies a damaged delivery as intact is worse than no verifier, because someone will trust it.
The fourth line is the ordinary case: a recipient on another machine, where the publishing path does not exist. An intact delivery reports every episode missing.
So the two outcomes are a false pass and a false failure, and #432 opened with exactly this: "a recipient of the delivery (landing MCAPs + prepared-manifest.json) cannot ask whether that delivery is still intact." As written, data_root is used to find the manifest and then ignored.
Your own test suite cannot see this, and it is worth understanding why: every case builds the delivery and verifies it in place, so the recorded absolute path and the root under verification are always the same directory. The bug lives exactly in the gap between them.
The fix
Resolve every episode relative to the root being verified, and treat the receipt's uri as provenance rather than as a lookup key: it records where the delivery was published, which is worth keeping in findings, and is not where it now lives.
The basename gets you there without a schema change: the importer owns the landing layout (_landing_relative_key is landing/lerobot_episode_{index + 1:04d}.mcap), so landing/<basename of uri> under the verified root is well defined for both local and bucket roots, and storage.exists / storage.fetch already take that shape. Say in the PR that you are relying on that layout, since it means the importer and the verifier now share a convention that is not written down in the manifest.
If you would rather record the relative key in the receipt, that is a schema-4 change and #432 puts it out of scope, so raise it separately rather than folding it in here.
Definition of done for the next round
- A test that copies a delivery to a second root and verifies the copy, with the original still present and intact. It must read the copy.
- A test that damages only the copy. That is the false pass above and it must go red before your fix.
- A test that deletes the original entirely and verifies an intact copy. It must report ok.
- The bucket path gets the same treatment. If you cannot test it without credentials, say what you reasoned instead of leaving it implied.
One smaller thing
episodes: [] currently returns unverifiable, and I am not sure that is right. A manifest with an empty episode list has a receipt in a format we understand and claims nothing, which reads more like ok than like "we could not tell". Exit 3 says a human should go and look, and there is nothing to look at. Say which you meant.
@Sagar-024 this one is worth reading before #428 lands. A snapshot's integrity.tables entries are paths relative to the export root rather than absolute, so you may already be safe by construction, but the trap is the same shape: verify what is under the root you were handed, never what a receipt says about where it used to live. Worth a test that moves the snapshot either way.
|
@kstonekuan Fixed: episodes resolve as |
Sagar-024
left a comment
There was a problem hiding this comment.
Re-reviewed the fix commit (25a69e3). Stale approval replaced, this still holds the #432 contract checklist:
- CLI verb:
hflow verify lerobot-import <root>present. - Report:
.okderived from status, findings in one list, no raise on first mismatch. - Per-finding
uri/reason/detail: present, reasons from the shared constants. - Shared types: single
hflow.verificationmodule, no verifier-local report shapes. - Exit codes: 0 clean, 1 damaged, 2 unreadable input (raises), 3 unverifiable, via
exit_code_for. - Extras ignored: unlisted files under
landing/produce no finding.
The fix itself, verified in a scratch worktree (test_verification.py: 12 passed):
- Episodes now resolve as
landing/<basename>under the root being verified; the receipt uri is provenance, never a lookup path. - Both directions are pinned by mutation: mutating only the original keeps the copy green, damaging only the copy goes red.
- The deleted-original case and the bucket-root case are covered, plus the empty-claim receipt flipping to clean (the right call: nothing claimed, nothing damaged).
- Publish-time absolute uris still parse after a copy, so no schema bump is needed.
One note, non-blocking: .zcode shows a stray pr454.diff in my scratch from the review run; it is mine, not part of the PR.
Re-approving on the fix commit.
|
@VARUN3WARE Kingston unblocked the order, so I am landing #428 first with your type shape verbatim: VerificationStatus, VerificationFinding, VerificationReport with the ok property, your three REASON constants and exit_code_for, plus one REASON_NO_RECEIPT beside yours, hyphenated to match your convention. When you rebase #454, drop your copy of verification.py and import from the landed one. Your fix stays in the import-verifier half (the landing resolution and the empty-claim case), so nothing is lost. Ping me after the rebase and I will review again. |
|
@kstonekuan carried into #428: the moved-root test exports a snapshot to one root, copies it to a second, deletes the first entirely, then verifies the copy clean and then damaged, plus a variant where the original stays alive and clean while the copy is damaged, so damage is never masked by the export root. PR #457 is up with it, and the shared types land in VARUN's exact shape so #454 re-lands as an importer. |
|
@Sagar-024 Thanks for the re-review. Ack on order: once #457 lands I’ll rebase #454, import the shared types from your |
|
Fix confirmed with the same probe that caught it. Before, the third line read The third line is the one that mattered: damage in the copy is found even with an intact original sitting next to it, so the verifier is reading what it was handed. The fourth is my probe still holding the damaged copy from the step before, so All four cases from the review are in, and You also changed Not approving yet only because main moved underneath you: #457 merged and landed When you do it: take the merged module as the base and add to it rather than reconciling two copies. The basename mapping is worth one line in the PR body when you push: it means the verifier and the importer share the landing layout without the manifest recording it. That is fine, and it is the sort of coupling that should be stated rather than discovered. Ping me when it is clean and I will merge. |
Import VerificationReport from the landed verification module (Hebbian-Robotics#457) and keep the import-delivery half in lerobot_verify: resolve landing/<basename> under the verified root, treat empty episodes as ok, and cover Kevin's copy cases.
25a69e3 to
771aa6a
Compare
|
@kstonekuan Rebased onto main after #457. Dropped our duplicate |
kstonekuan
left a comment
There was a problem hiding this comment.
Clean rebase. The verifier moved to src/hflow/importers/lerobot_verify.py, the shared types are imported rather than reconciled, and the only edit to verification.py is a docstring now naming both concrete verifiers. That is the right shape: one contract, two implementations, neither owning the other.
Validated on the rebased branch: full gate clean, 1632 passed / 6 skipped. The path fix stays fixed, which I checked with the probe from the first round rather than trusting the rebase.
Merging. Between this and #457 the verify family is real: same report, same reason strings, same exit codes, two receipt formats.
Worth writing down somewhere eventually, though not in this PR: the basename mapping means the verifier and the importer share the landing layout without the manifest recording it. You noted it, and the next person to change _landing_relative_key needs to know a verifier depends on it. If a schema-4 receipt ever happens, carrying the relative key is the thing that removes the coupling.
Good work on both rounds, and on settling the contract with @Sagar-024 in writing before either of you wrote code. That is why these merged in two days instead of arguing in review.
Summary
VerificationReport/VerificationFinding/VerificationStatusinsrc/hflow/verification.pyfor thehflow verifyfamily ([Feature]: LeRobot prepared-manifest receipts are written but nothing verifies a delivered import #432; contract with Snapshot integrity receipts are written but nothing reads them: no way to verify a delivered snapshot #428).verify_lerobot_importand CLIhflow verify lerobot-importto check schema-3 prepared-manifest receipts (uri,content_id,size_bytes) without re-converting.0clean,1damaged,2unreadable,3unverifiable; unlistedlanding/extras ignored.import-lerobot-v3.mdnames the real verify path;FORMAT.mddocuments verify exit3.Test plan
uv run ruff check/ruff format --check/ty checkuv run pytest tests/test_verification.py -quv run pytest -q(1594 passed, 6 skipped)verification.pyfirst for Snapshot integrity receipts are written but nothing reads them: no way to verify a delivered snapshot #428 to import