Skip to content

User-dataset collection + admission gatekeeper - #139

Merged
aperson30 merged 3 commits into
mainfrom
feat/user-dataset-collection
Aug 13, 2026
Merged

User-dataset collection + admission gatekeeper#139
aperson30 merged 3 commits into
mainfrom
feat/user-dataset-collection

Conversation

@aperson30

Copy link
Copy Markdown
Collaborator

What

Collects users' CT scans + our segmentation into an in-house dataset — but only what's worth keeping, via an admission gatekeeper. Completes the data-saving feature and the "thing that decides what goes in and what doesn't."

Design principle: never touches the user

It runs on a daemon thread AFTER the result is already delivered, every failure is swallowed, and it's a no-op unless USER_DATASET_PATH is set. A user who uploads garbage still gets their result — the garbage just doesn't enter the dataset. Merging this changes nothing until it's switched on in the environment.

The gatekeeper (services/user_dataset.py) — four gates

  1. Real CT? valid 3D volume, plausible dimensions, CT-like HU range (air near −1000 + tissue/bone present), not constant.
  2. Duplicate? exact (sha256 of the file) and near-duplicate (downsampled, quantized perceptual hash) vs a registry — re-uploads that differ only in compression/metadata are caught.
  3. Usable? our own segmentation is the judge — reject if it didn't find plausible organs (voxel + distinct-organ thresholds), which is what garbage/non-abdominal input produces.
  4. Abuse? rolling 24h per-user / per-IP / global quotas + a file-size cap. A competitor dumping thousands of files hits the quota; identical floods hit dedup — either way they can't poison the dataset.

What accepted scans become (PanTS-mirroring layout)

UserData/
  image_only/USER_00000001/ct.nii.gz
  mask_only/USER_00000001/combined_labels.nii.gz
  mask_only/USER_00000001/segmentations/<organ>.nii.gz   # per-organ sublabels
  mask_only/USER_00000001/metadata.json                   # user, IP, model, date, hashes, stats
  registry.json        # case-id counter, fingerprints, rolling quota events
  rejections.jsonl     # audit trail — nothing is silently dropped

So it can sit beside PanTS/CancerVerse once a writable location is granted. Uses a file-based registry with atomic writes — no DB migration.

Wiring

  • api_blueprint.py fires collect_user_scan_async(...) right after a job is marked completed; client IP is captured in the request context (the worker thread has none) for the per-IP quota.
  • constants.py: documented USER_DATASET_PATH.

Tests / CI

  • tests/unit/test_user_dataset.py: quotas (user/IP/global + pruning), dedup, registry round-trip, CT validity (accepts a real CT, rejects constant / non-CT / 2D), segmentation-quality gate, and end-to-end promotion (accepted → full layout; duplicate → logged, not stored).
  • Added the test to the CI backend job (+nibabel to its deps). compileall covers the module too.

To switch on (deploy)

Set USER_DATASET_PATH to a writable staging dir (e.g. /home/visitor/UserData) and restart. Tune quotas via USER_DATASET_DAILY_PER_USER / _PER_IP / _GLOBAL, USER_DATASET_MAX_CT_BYTES, USER_DATASET_MIN_ORGAN_VOXELS.

Note: authored without server access (no VPN), so validated by CI rather than a live run — worth one manual smoke test (set the env, run one scan, confirm a USER_00000001 appears) before relying on it.

When a user runs inference we can keep their CT + our segmentation to grow an
in-house dataset -- but only if it clears an admission gate. Fully decoupled from
the user experience: runs on a daemon thread AFTER the result is delivered, every
failure is swallowed, and it's a no-op unless USER_DATASET_PATH is set (so merging
changes nothing until switched on).

Gatekeeper (services/user_dataset.py), four gates:
  1. real CT?    valid 3D volume, plausible dims, CT-like HU range (air + tissue)
  2. duplicate?  exact (sha256) + near-duplicate (downsampled perceptual) vs registry
  3. usable?     our own segmentation found plausible organs (voxel + organ counts)
  4. abuse?      rolling 24h per-user / per-IP / global quotas + size cap -> a
                 competitor dumping thousands of files can't flood the dataset

Accepted scans are promoted into a PanTS-mirroring layout under USER_DATASET_PATH
(image_only/USER_########/ct.nii.gz, mask_only/USER_########/combined_labels.nii.gz
+ segmentations/<organ>.nii.gz + metadata.json), so it can sit beside PanTS once a
writable location is granted. Rejections are logged to rejections.jsonl (audited,
not silently dropped). File-based registry (atomic writes) -- no DB migration.

Hook: api_blueprint fires collect_user_scan_async after the job is marked
completed; client IP is captured in the request context for the per-IP quota.

Tests: tests/unit/test_user_dataset.py (quotas, dedup, CT/segmentation validity,
end-to-end promotion). Added to CI (+nibabel to the backend test deps).
Constant/2D volumes compress below the size floor, so they're rejected as
too_small before the constant/not_3d checks. Adjust the constant-volume assertion
and give the 2D case enough entropy to reach the dimensionality gate. 8/8 pass.
Addresses a subagent review of the gatekeeper:
- CRITICAL (C1): the size cap was on the COMPRESSED .nii.gz, so a crafted header
  declaring e.g. 2048^3 could decode to ~34 GB and OOM-kill the worker (breaking
  the 'never affect the user' guarantee). Now bound the DECODED voxel count
  (MAX_VOXELS, default 400M) before any np.asarray, and load native dtype (int16)
  instead of forcing float32. Also require all values finite (reject +/-inf).
- H2: promote into <case>.partial staging dirs and publish with atomic os.replace,
  so a partial failure never leaves a half-written case the next admission merges
  into. next_id is committed only after the files are published.
- H4: run the expensive gates (validate/fingerprint/quality) OUTSIDE the registry
  lock; hold it only for dedup+reserve+promote+commit. Record an attempt event
  even on rejection, so a flood of REJECTED garbage is rate-limited too.
- H3: attribute the per-IP quota to request.remote_addr (trusted proxy) instead of
  the client-spoofable left-most X-Forwarded-For.
- H1: add a cross-process fcntl file lock around the registry critical section
  (threads-only fallback on Windows/CI), so it stays correct if workers ever > 1.
- M2: write uint8 sublabels with a fresh header (affine only, explicit dtype) so
  the label map's scl_slope/datatype can't misencode the binary mask.
- L3: skip collection when the input isn't a CT file (ShapeKit passes a dir).

Tests: +no-op-when-unset, +voxel-guard (anti-OOM), +non-finite rejection. 11/11 pass.
@aperson30
aperson30 merged commit 92510ad into main Aug 13, 2026
8 checks passed
@aperson30
aperson30 deleted the feat/user-dataset-collection branch August 13, 2026 10:53
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