Skip to content

[Storage] Implement large-dataset storedisk semantics - #168

Merged
suraj-ranganath merged 3 commits into
feature/eeglab-full-standalone-completionfrom
phase/162-large-dataset-storage
Jun 7, 2026
Merged

[Storage] Implement large-dataset storedisk semantics#168
suraj-ranganath merged 3 commits into
feature/eeglab-full-standalone-completionfrom
phase/162-large-dataset-storage

Conversation

@suraj-ranganath

@suraj-ranganath suraj-ranganath commented Jun 7, 2026

Copy link
Copy Markdown
Member

Add Python-native .fdt sidecar save/load, memory-mapped data handles, and storedisk offload/retrieve behavior across EEGPrepSession, pop_newset, STUDY save paths, and eegprep-console. Harden review-reported multi-index retrieval, memmap resave, and optional clean_rawdata browser behavior; documents storage semantics and Phase 5 matrix evidence.

Closes #162

@claude

claude Bot commented Jun 7, 2026

Copy link
Copy Markdown

Claude finished @suraj-ranganath's task in 4m 2s —— View job


Code review — PR #168 [Storage] Large-dataset storedisk semantics

  • Gather context (diff, CLAUDE.md/AGENTS.md, EEGLAB references)
  • Review storage.py core implementation
  • Review pop_saveset/pop_loadset .fdt + memmap behavior
  • Review eeg_store/eeg_retrieve storedisk offload/retrieve
  • Review test coverage
  • Post review

  • Overall assessment: Solid implementation; one correctness bug in multi-index retrieve.
  • Highest-risk area: eeg_retrieve multi-index offload path.
  • Merge recommendation: Needs changes.

The core .fdt round-trip is correct — channel-fast float32 layout is consistent between write_fdt/read_fdt/memmap_fdt, the F-order reshapes preserve frame = p + pnts*t, and the offload/retrieve cycle for the single-current-dataset path is sound. The import cycle between storage and pop_loadset is broken correctly with a local import.

Blocking

None.

Important

eeg_retrieve multi-index branch offloads the datasets it returnssrc/eegprep/functions/adminfunc/eeg_retrieve.py:18-23
The list/tuple branch calls offload_storedisk_datasets(alleeg, set()) with an empty current-set, so every saved resident dataset (including the requested ones) is offloaded, and the returned datasets are OffloadedData handles that raise on access. The scalar branch just below correctly keeps {current} resident and loads it. Net effect: session.retrieve([2]) returns offloaded data while session.retrieve(2) returns resident data for the same selection. Pass set(indices) (and ideally load offloaded selections via dataset_with_loaded_data, mirroring the scalar branch). Details + suggestion in the inline comment. Fix this →

Nits

_save_two_files default can silently collapse a sidecar dataset to one filesrc/eegprep/functions/popfunc/pop_saveset.py:528-533
When re-saving a dataset that was loaded as two files but savemode is empty (not 'resave') and option_savetwofiles is off, _save_two_files returns False, inlining the data into the .set and dropping the .fdt. This is defensible (explicit savemode wins), but worth a one-line note in the help/docstring so users re-saving large datasets don't unexpectedly inline them.

Test gaps

  • No coverage for multi-index eeg_retrieve under option_storedisk=1 (the existing tests/test_eeg_retrieve.py cases run with storedisk off). Adding one would have caught the Important issue above.
  • Memmap resave to the same .fdt (option_memmapdata=1, savemode='resave') is untested; write_fdt reads from the live memmap of the file it overwrites. Worth a round-trip test asserting values survive an in-place resave.

EEGLAB parity notes

  • Channel-fast float32 .fdt layout, datfile sidecar field, and savetwofiles/memmapdata/storedisk option gating match EEGLAB conventions. ✅
  • Single-current-dataset offload/retrieve matches the "one resident dataset" storedisk model. The multi-select asymmetry called out above is the one place it diverges.

Checked: correctness of .fdt serialization/reshape, memmap handles, offload/retrieve cycle, pop_saveset/pop_loadset option parsing, eeg_store/eeg_checkset/pop_savestudy integration, and test coverage.

Comment on lines 18 to +23
if isinstance(index, (list, tuple)):
datasets = [deepcopy(_dataset_at(alleeg, int(item))) for item in index]
return datasets, alleeg, [int(item) for item in index]
dataset = deepcopy(_dataset_at(alleeg, int(index)))
return dataset, alleeg, int(index)
indices = [int(item) for item in index]
if storedisk_enabled():
offload_storedisk_datasets(alleeg, set())
datasets = [deepcopy(_dataset_at(alleeg, item)) for item in indices]
return datasets, alleeg, indices

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important (correctness): The multi-index branch passes set() as current_indices, so offload_storedisk_datasets offloads every resident saved dataset — including the ones being retrieved. The returned datasets are therefore OffloadedData handles that raise RuntimeError on any [...] / np.asarray access.

This contradicts both the storedisk design (the current selection should stay resident) and the single-index branch right below, which keeps {current} resident and loads it via dataset_with_loaded_data. Concretely, EEGPrepSession.retrieve([2]) and retrieve(2) select the same dataset but the list form returns offloaded data while the scalar form returns resident data (session.py:219-220 chooses the branch purely on isinstance(indices, (list, tuple))).

Minimal fix is to keep the selected indices resident; ideally also load any that are currently offloaded, mirroring the scalar branch:

Suggested change
if isinstance(index, (list, tuple)):
datasets = [deepcopy(_dataset_at(alleeg, int(item))) for item in index]
return datasets, alleeg, [int(item) for item in index]
dataset = deepcopy(_dataset_at(alleeg, int(index)))
return dataset, alleeg, int(index)
indices = [int(item) for item in index]
if storedisk_enabled():
offload_storedisk_datasets(alleeg, set())
datasets = [deepcopy(_dataset_at(alleeg, item)) for item in indices]
return datasets, alleeg, indices
if isinstance(index, (list, tuple)):
indices = [int(item) for item in index]
datasets = [dataset_with_loaded_data(_dataset_at(alleeg, item)) for item in indices]
for item, dataset in zip(indices, datasets):
if 1 <= item <= len(alleeg):
alleeg[item - 1] = deepcopy(dataset)
offload_storedisk_datasets(alleeg, set(indices))
return datasets, alleeg, indices

There's currently no test for multi-index retrieve under option_storedisk=1 (the cases in tests/test_eeg_retrieve.py run with storedisk off), so this path is uncovered.

…ompletion' into phase/162-large-dataset-storage

# Conflicts:
#	.notes/implementation-notes.html
#	src/eegprep/plugins/clean_rawdata/pop_clean_rawdata.py
@suraj-ranganath
suraj-ranganath merged commit 8038d5e into feature/eeglab-full-standalone-completion Jun 7, 2026
@suraj-ranganath
suraj-ranganath deleted the phase/162-large-dataset-storage branch June 7, 2026 10:49
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