Skip to content

Verify directory hashes, --paranoid, chunk retry, companions — and fixes from the first live offload - #2

Open
owenpkent wants to merge 19 commits into
mainfrom
roadmap-trust-items
Open

Verify directory hashes, --paranoid, chunk retry, companions — and fixes from the first live offload#2
owenpkent wants to merge 19 commits into
mainfrom
roadmap-trust-items

Conversation

@owenpkent

@owenpkent owenpkent commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Clears the roadmap's Next section: the three items it listed, plus the
directory-hash verification that was top of it, plus a sweep of the interface
that followed from exposing the new option.

Four commits, each reviewable on its own.

Verify the ASC MHL directory hashes

They were written from the first release and never read back. A rename leaves
every individual file hashing exactly as recorded, so no file-level check can
object to it — the structure hash exists precisely to catch that.

content structure verdict
matches differs RENAMED — bytes intact, a name changed or a file moved
differs differs CHANGED
  3 checked: 1 missing, 2 ok; 2 of 2 directory hashes differ
  MISSING   A001\Clips\A001_C001.mov
  RENAMED   (root)
            every file still hashes as recorded, so a name changed or a file moved
  RENAMED   Clips/
  not in manifest: A001\Clips\A001_C001_take2.mov

Recomputing hashes the files the manifest does not list — that is what proves
a rename is only a rename — while honouring the manifest's own ignore
patterns. Without that, one stray .DS_Store fails every directory above it;
there is a test that says so.

--paranoid

Closes the one gap nothing else here can see: a read that returns wrong bytes
without raising. The checksum is taken from whatever came back, so the
destination faithfully matches a corrupted source and verifies clean at every
level, including the directory hashes above.

The test worth reading is test_without_paranoid_the_same_source_verifies_clean
— it asserts that the same simulated bad source passes today at --verify full.
That is the gap, written as a passing test.

Three decisions:

  • An orthogonal flag, not a fourth VerificationMode. The existing modes
    are a ladder; you want paranoid and full, and a rung cannot say that.
  • A disagreement is retried, not adjudicated. There is no basis for deciding
    which read was true, so UnstableRead is transient by construction. A source
    that will not read the same twice fails the file and leaves nothing behind.
  • The eviction is the feature. Without it the second read comes out of the
    page cache and proves nothing. Where the platform cannot evict, the job says
    so rather than claiming the guarantee.

Chunk-level retry

The roadmap expected this to need the hasher state rewound to a chunk boundary.
It did not — a chunk is only hashed once it has been delivered whole, so a
failed read has produced no state to unwind. The retry lives in the reader
thread: reopen, seek to the failed offset, read again. A bad sector near the end
of a 79 GB clip cost 79 GB and now costs 8 MiB.

Writes still restart the whole file (a partial write leaves the destination at a
length the copy loop does not know), and retry.Exhausted stops the outer retry
repeating the same attempts against the same fault.

A deadlock was introduced here and is fixed in the same commit. Moving the
source's close out of a with block into a finally meant a close that raised
skipped the end-of-file sentinel and hung the consumer forever — the same
failure an existing regression test was written for. There is now a test for a
source whose close() raises.

Companion grouping

Sidecars (.sidecar, .rmd, .xmp) and proxies are matched to their clip by
stem. An ambiguous stem — two takes of the same name in different folders — is
left unlinked rather than guessed at: naming the wrong take is worse than saying
nothing when the only value of the link is that it can be trusted.

A clip that copies while a file belonging to it does not is now a job warning. A
BRAW delivered without its .sidecar has silently lost its grade, and one
Verified row and one Failed row twenty lines apart is not how anyone finds that
out. HTML shows them together; CSV gains a Companion Of column, appended so
existing index-based readers are unaffected.

Interface sweep

The preset editor was sixteen fields in one flat column with four blank labels.
Now three sections — Preset / Copying / Reports — with checkboxes stacked under
one Options label. Job name became Job name template (Simple mode has a
field of the same name taking a literal one), and Skip files already present at matching size gained a tooltip saying what it does not compare.

This also caught a gap: paranoid reached Preset.to_options() but was exposed
nowhere in the interface. A preset field the UI never shows is a field nobody
can use. It is now in both modes, worded identically, and persists.

Notes

  • ROADMAP.md's Next is emptied by this branch, so three gaps already
    documented in docs/ are promoted rather than new ideas invented:
    --skip-existing by checksum, previousPath, and a lock file between
    instances.
  • docs/data-safety.md had a paragraph stating that a retry restarts the whole
    file "because a partial read leaves the running checksum meaningless" — the
    opposite of what the copy loop now does for reads. Corrected.
  • The PDF's header string is untouched; it is pinned to the reference report's
    wording, so the second read is said in the HTML summary and the CLI instead.

434 tests (+25), 86% line coverage, ruff clean.


Fixes from the first live card offload

A first real offload — 512 GB exFAT card, BRAW, one destination — then surfaced
a cluster of problems in how the app reports itself and what it does by
default. Nine further commits, one per fix.

What a real job exposed

  • The throughput readout lied. bytes / total elapsed folded the card scan
    and every between-file stall into the number forever: the queue read
    3.5 MB/s while clips were demonstrably flying past, and the ETA was wrong the
    same way. Now a 5-second trailing window that visibly decays during a stall
    and survives the copy→verify counter reset.
  • MD5 sat next to XXHash3-64 looking like an equal choice. Measured on real
    hardware it is ~40x slower, and the engine hashes every byte on the copy
    path, so the choice caps copy speed. Every picker — the GUI combos, --hash
    help, offloader info — now says what each algorithm costs.
  • The drive panel waited on the slowest network share. Probes were serial
    and batched. Now they run concurrently, local drives are delivered before
    network shares, known share rows persist while re-probing, and Refresh shows
    a busy state.
  • A running job barely looked like one. The queue gained a summary line
    (stage, current file, percent, live rate, ETA), a progress bar that is
    readable on the auto-selected row and carries a percent label, fixed-width
    numeric columns, and a once-a-second repaint so a stall shows as a falling
    rate rather than a frozen one. Simple-mode form rows no longer clip at 125%
    display scale.
  • "Start offload" promised what it couldn't do while a job was running. It
    now says "Add to queue", and the ready line says the job runs after the
    current one.

Defaults and paperwork

  • Full verification is the default. The read-back is the only mode that
    proves what is on the destination device. Cost: one extra read of each copy
    at the destination's read speed — measured 73 MB/s cold on the offload
    target used for testing, roughly doubling that job; a fast SSD destination
    adds a few percent. source-only stays one flag or one dropdown away.
  • A decoder ffmpeg lacks is probed once per job, not four spawns per clip.
    Stock ffmpeg cannot decode BRAW; the first clip of a suffix that produces no
    frames marks it dead for the rest of the job. Camera proxies are unaffected.
  • Root offloads are named after the volume label. A card offloaded from
    E:\ has no folder name, so the job and all its paperwork were called
    "Offload". Now they are called what the operator calls the card — A003
    in the engine, the {card} naming token, and Simple mode's placeholder.
  • The PDF document title carries the route and the date, so a stack of
    reports can be told apart in a file manager.

467 tests (+33 from this round), ruff clean. The queue readout, drive panel,
form layout and button states were also verified against the live desktop app
on Windows at 125% scale.

@owenpkent owenpkent left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all four commits and ran the CLI end to end. The chunk-retry work is the strongest part of this — Exhausted closing the outer whole-file retry, non-transient errors still propagating raw, and _BadSector logging read offsets so a test can assert log.count(0) == 1 is a genuinely good way to prove "did not restart the file". UnstableRead being transient by construction, and warning rather than claiming the guarantee when the cache can't be evicted, are both right.

One blocker though: the directory-hash verification fails on a tree that nothing has touched. Details inline on verify.py. The short version is that <name>_Reports/ is folded into the recomputed root hash but was never in the recorded one, so offloader verify exits non-zero straight after a successful offload — the default --report pdf path.

Comment thread src/offloader/verify.py
listed = {p.resolve() for p, _, _, _ in entries}
for candidate in _described_root(manifest, listed).rglob("*"):
patterns = _ignore_patterns(ascmhl_root) if ascmhl_root is not None else []
scan_root = (managed_root if ascmhl_root is not None

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker — this fails on a tree nothing has touched.

Reproduced with a plain CLI run: offload with --report ascmhl,csv, then verify the manifest it just wrote.

2 checked: 2 ok; 1 of 2 directory hashes differ
  DIR .      CHANGED
  DIR Clips  OK
passed: False
unlisted: ['JobReport.csv']

Nothing was renamed, moved or corrupted. scan_root is managed_root — the destination root — so every unlisted file underneath gets hashed into the recomputed root content hash, including <name>_Reports/JobReport.csv and the PDF, HTML and thumbs/ beside it. Those were written after the manifest and were never in the recorded hashes: write_manifest defaults ignore_patterns to [ASCMHL_DIRNAME] only (ascmhl.py:324), so the report directory is unignored on the read side and absent on the write side.

Combined with passed now including directory_failures, offloader verify exits non-zero — and the README says a format script can gate on that exit code. On main the same tree passes, so this is a regression, and it hits the default path since --report defaults to pdf.

Both halves of the fix look worth doing: record the report directory in the manifest's ignore patterns at write time, and skip a sibling *_Reports directory here regardless — manifests already written won't carry the pattern.

Comment thread tests/test_ascmhl.py
return next(v for v in report.directories if v.relative == relative)


def test_verify_rechecks_the_recorded_directory_hashes(history):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the verify.py blocker slipped through: every new directory-hash test builds its tree from the history fixture, which has no <name>_Reports/ directory, so none of them exercise the layout a real offload actually produces.

A test that runs cli.main(["offload", ..., "--report", "ascmhl,pdf"]) and then verifies would have caught it, and is worth having permanently — the report directory sitting inside the managed root is exactly the kind of thing that gets re-broken.

Comment thread src/offloader/verify.py
line = f"{self.checked} checked: " + ", ".join(parts)
if self.directory_failures:
line += (f"; {len(self.directory_failures)} of "
f"{len(self.directories)} directory hashes differ")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This composes into 2 checked: 2 ok; 1 of 2 directory hashes differ — the first thing a reader's eye lands on is "2 ok", on a report that did not pass. Worth leading with the failure, the way the file-level branch does.

Comment thread src/offloader/verify.py
# The directory pass needs the unexpected files hashed, so it scans even
# when the caller did not ask for them to be reported.
if find_unlisted or ascmhl_root is not None:
listed = {p.resolve() for p, _, _, _ in entries}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth calling out in docs/data-safety.md: the directory pass hashes every unlisted file under the destination root, with cache eviction, and does it even when the caller passed find_unlisted=False. On a destination that holds anything besides this job that's unbounded extra I/O, and --allow-cache only removes the eviction, not the hashing.

Comment thread src/offloader/engine.py Outdated
# Recovered without restarting the file, which is why the copy
# succeeded at all — but the sector that needed it is real.
job.warnings.append(
f"{source.name}: recovered a failed read at byte {offset} "

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One warning per recovered chunk means one line per 8 MiB across a bad region, not one per file. A card failing over a contiguous stretch could bury the rest of job.warnings — worth collapsing to a count and a range per file.

return Path(path).suffix.lower() in COMPANION_SUFFIXES


def in_proxy_directory(path: Path) -> bool:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches on directory name alone, so a card that happens to file originals under a folder named Proxy gets every one of them treated as a companion and reported as belonging to something else. Given how carefully group() refuses to guess on an ambiguous stem, this is the looser half of the same judgement — maybe require the suffix to be one of PROXY_SUFFIXES as well.

@owenpkent owenpkent left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the full suite on this branch finished clean here — 433 passed, 1 skipped, no failures. The skip is test_a_firmlinked_system_volume_is_not_a_card (macOS-only), so the collected count of 434 in the docs is right.

The wall-clock figure isn't, at least not on this machine — see inline. Worth noting a green suite is also consistent with the verify.py blocker above: the directory-hash tests all build from the history fixture, which has no <name>_Reports/ directory, so nothing in the suite exercises the layout that breaks.

Comment thread README.md Outdated
```sh
pip install -e ".[dev]"
pytest # 400 tests, ~33s
pytest # 434 tests, ~50s

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The run took 12:51 here, not ~50s. I can't pin that on this branch — Windows, Qt GUI tests and real file I/O, and #1's 44-test subset was slow on the same box too — so it may well be environmental rather than a regression. But the figure is stated as fact in three places (here, line 304, and CONTRIBUTING.md:40) and it's the number someone uses to decide whether the suite has hung, so it's worth re-timing on the machine you'd want it to describe.

The test count checks out: 433 passed + 1 macOS-only skip = the 434 claimed.

I didn't measure coverage, so no comment on the 86% on line 304.

They were written from the first release and never read back. A rename or a
moved file leaves every individual file hashing exactly as recorded, so no
file-level check can object to it; the structure hash exists precisely to catch
that.

Content matching while structure does not is now reported as RENAMED, which is
a far stronger statement than the "not in manifest" line it used to produce.

Recomputing means hashing the files the manifest does not list — that is what
proves a rename is only a rename — while honouring the manifest's own ignore
patterns, or a deliberately ignored file would fail every directory above it. A
directory whose mismatch is already accounted for by a file that failed on its
own hash says so, rather than repeating itself once per level up to the root; a
directory that gained an unexpected file never counts as accounted for, because
no file verdict can report an arrival.

The hashing itself is the writer's own, exposed as `ascmhl.directory_hashes`
and pinned to the reference implementation's published values.
Three engine changes that share the copy loop, so they land together.

--paranoid reads every source file a second time and compares. The gap it
closes is a read that returns wrong bytes without raising: the checksum is
taken from whatever came back, so the destination faithfully matches a
corrupted source and verifies clean at every level. A disagreement is not
adjudicated — there is no basis for deciding which read was true — so it is
retried, and a source that will not read the same twice fails that file and
leaves nothing behind. The page cache is dropped first, and the job says so
when it could not be, since a second read served from memory compares the
first read against itself. It is opt-in because it costs a full extra pass.

A transient read failure is now retried at the chunk that failed rather than by
restarting the file: recovering a bad sector near the end of a 79 GB clip cost
79 GB and now costs 8 MiB. This needed no hasher rewind after all — a chunk is
only hashed once it has arrived whole, so a failed read has produced no state
to unwind. The source is reopened and sought back to the offset, because a
reader that dropped off the bus needs its handle re-established. Writes still
restart the whole file: a partial write leaves the destination at a length the
copy loop does not know. retry.Exhausted stops the outer retry from repeating
the same attempts against the same fault.

Sidecars and proxies are matched to their clip by stem. A .sidecar carries a
BRAW's grade; delivered without its clip it is nothing, and the clip delivered
without it has silently lost the grade. An ambiguous stem — two takes of the
same name in different folders — is left unlinked rather than guessed at. A
clip that copies while a file belonging to it does not is now a job warning
instead of two rows twenty lines apart.

Also fixes a deadlock introduced while doing the above: closing the source
moved out of a `with` block into a `finally`, and a close that raised skipped
the end-of-file sentinel and hung the consumer forever. Closing now swallows
everything, and there is a regression test for a source whose close() raises.
The editor had sixteen fields in one flat column, four of them checkboxes
sitting on blank labels. The two or three that bear on any given change were
never next to each other. They are now three sections — Preset, Copying,
Reports — with the checkboxes stacked under a single Options label.

"Job name" is now "Job name template", because Simple mode has a field of the
same name that takes a literal one. "Skip files already present at matching
size" gained a tooltip saying what it does not compare, since it is the one
place the tool takes something on trust.

--paranoid was reachable from the CLI and from Preset.to_options but exposed
nowhere in the interface, which makes it a field nobody can use. It is now a
checkbox in both the preset editor and Simple mode, worded and explained the
same way in each, and it persists with the preset.
`offloader verify` prints directory-hash failures alongside the file ones, and
`offloader offload` takes --paranoid. The run summary says when a second source
read was done, kept out of the PDF's header string because that is pinned to
the reference report's wording.

The roadmap's Next section is emptied by this branch, so three gaps already
documented in docs/ are promoted into it rather than new ideas invented:
--skip-existing by checksum, previousPath, and a lock file between instances.

docs/data-safety.md had a paragraph stating that a retry restarts the whole
file "because a partial read leaves the running checksum meaningless" — the
opposite of what the copy loop now does for reads. Corrected, and the limit it
listed for unrepeatable source reads now points at --paranoid.

434 tests, 86% line coverage.
Verifying the directory hashes recomputes them from what is on disk, which
means hashing the files the manifest does not list — that is what proves a
rename is only a rename. But the job's own reports are written into the
destination after the manifest, so they are on disk when a verifier recomputes
and were never in what it recomputes against. A card that had just been copied
reported its own JobReport.pdf as a change to the tree, and `offloader verify`
exited non-zero on the path the README says a format script can gate on.

The format already has the mechanism: `ignore`, which the verifier honoured and
the writer only ever used for `ascmhl`. The report directory goes in the same
list for the same reason — neither is managed data. The path is recorded rather
than the conventional name, because `--report-dir` moves it, and both are
recorded because they can differ: thumbnails land in `<name>_Reports` wherever
the PDF goes.

A history written before this says nothing about its reports, so `*_Reports` is
allowed for when recomputing, keyed off the absence of any recorded pattern but
`ascmhl` — it stops applying the moment a manifest describes its own layout.
Deliberately not applied to the unlisted list, which still names those files,
and scoped to that one directory: a stray file anywhere else still moves the
hash it belongs to.

The tests that missed this built their destination with `write_manifest`
directly, so it never had a reports folder in it. The new ones go through the
CLI, which is what writes the reports.
The app section described a drive panel, a preset list and a queue without
showing any of them. Three screenshots: preset mode with a job running, simple
mode, and the preset editor.

Generated rather than captured, because a screenshot taken by hand is wrong the
next time the interface moves and nobody notices. `tools/screenshots.py` drives
the real app — nothing is mocked but the two things that would otherwise leak
this machine into a public README: the config directory is a throwaway, so real
presets, settings and history are neither read nor written, and the drive panel
is fed invented volumes rather than whatever is mounted. The queue items are
built directly instead of enqueued, so no job runs and nothing is copied.

The window is opened wider than it starts and the queue splitter pushed down:
the default split leaves the queue a row and a half tall, which is the one part
of that screen a reader needs to see.
Two things the directory-hash and chunk-retry work reported badly.

A report where every file matched and a directory hash did not opened
"3 checked: 3 ok", which reads as a pass to anyone scanning — on the one
verdict where the file hashes agreeing is the point rather than the
reassurance. That case now leads with the directories and says what the
combination means. A report with file failures is unchanged: it already
opened with them.

Recovered reads were one warning each, so a card failing over a contiguous
stretch produced one line per 8 MiB and buried every other warning in the job.
Now one line per file: a single bad sector still names its offset exactly,
because there the byte is the useful fact; a run of them is bounded by first
and last, because there it is not.

Also documents what recomputing directory hashes actually reads. Proving a
rename is only a rename means hashing what the manifest does not list, so a
destination root holding anything besides this job is read too.
Neither branch could see this on its own. Companion grouping matches a sidecar
to its clip by stem, which is the only relationship a camera records. The data
profile's whole claim is that nothing is treated as a clip — so on a dataset it
would announce that `capture.xmp` belongs to `capture.h5` on no evidence beyond
a shared name, and warn when one copied without the other.

Gated on the same `probes_media` the CLI summary already uses, at both call
sites: `run` and `rescan`.
The data profile and this branch landed independently, so three things were
describing a tool that no longer exists in that shape.

`CONTRIBUTING.md` still claimed 434 tests; the merged suite collects 453. Both
files now say so, and neither claims a wall-clock figure any more — two runs of
identical code here differed by three and a half minutes, so the number was
measuring the machine rather than the suite.

The changelog gained the two reporting changes it was missing, and the sidecar
entry now says the grouping is media-profile only.

The README's generic-transfer section claimed nothing is treated as a clip
while companion grouping was still doing exactly that; it now says what that
means for a dataset.
@owenpkent
owenpkent force-pushed the roadmap-trust-items branch from fa36bb1 to 9677448 Compare August 9, 2026 18:08
@owenpkent owenpkent changed the title Verify directory hashes, --paranoid, chunk-level retry, companion grouping Verify directory hashes, --paranoid, chunk retry, companions — and fixes from the first live offload Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant