Skip to content

Merge main into Vnext_Update - #997

Merged
stevevanhooser merged 434 commits into
Vnext_Updatefrom
main
Sep 16, 2026
Merged

stevevanhooser merged 434 commits into
Vnext_Updatefrom
main

Conversation

@stevevanhooser

Copy link
Copy Markdown
Contributor

Bring main forward into the Vnext_Update staging branch so v2 work can pick up the fixes that have landed on main since the last sync (2026-08-13).

Since the last sync

main has moved from the state merged into Vnext_Update on 2026-08-13 (3442d4f) to 6fdec7e (2026-09-16). Notable landings on main:

Known conflict shape

The prior Vnext_Update merge (69dd7da) resolved conflicts in src/ndi/+ndi/validate.m and the badge SVGs. validate.m was removed on the Vnext line long ago, so any conflict on it should be resolved as deleted on our side wins. Badges regenerate; keep whichever, they'll be overwritten by the badge workflow anyway.

Not to merge as-is

This PR is for reviewing the diff before we do the merge locally. Once we're happy, the actual sync will be a git merge origin/main on Vnext_Update with the conflict resolutions above, then push. We may close this PR without merging.


Generated by Claude Code

github-actions Bot and others added 30 commits September 3, 2026 14:48
writeSyncIndex: atomic write, checked fopen, no leaked handles
…name/email in every UID-taking method

Two coordinated changes for the NDI Cloud demo, done in lockstep with
Waltham-Data-Science/NDI-python#168.

Filename and SecretsFilename now resolve to fullfile(userHome, '.ndi',
...) via a new userPrefDir() static, matching ndi-python's default. A
one-time migrateLegacyIfNeeded() runs on load: if ~/.ndi/ has no
profiles file but the old prefdir pair still exists, it is copied over
so upgraders keep their saved profiles. ensurePrefDir() creates the
directory on demand from saveToDisk and aesWriteSecret so the first
write does not fail before anyone has interacted with the file.

findIndex now accepts a UID (exact), a Nickname (exact), or an Email
(case-insensitive), tried in that order; an ambiguous nickname/email
raises NDI:cloud:profile:ambiguousProfile naming the candidate UIDs
rather than silently picking one. setCurrent, setDefault, remove, and
switchProfile always store the resolved UID rather than the caller's
raw key so every downstream comparison stays UID-based.

Test suite is updated accordingly: the prefdir-anchored filename test
now checks that filename() lives under userPrefDir(); new tests cover
lookup by nickname and email, the ambiguous-match error, and the
"resolved UID is stored" invariant on setCurrent/switchProfile/remove.

Refs #920, Waltham-Data-Science/NDI-python#168.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zDzZ9joBkZjzeMLfWZZk7
+cloud/profile: move preferences to ~/.ndi/ and let findIndex accept nickname/email/UID
….ndi/

Follow-up to #920 / Waltham-Data-Science/NDI-python#168. Both
cross-language stores that already agreed on ~/.ndi/ (NDI_Preferences,
NDI_Cloud_*) are now joined by the local sessiontable.

PathConstants.Preferences retargets from fullfile(userpath,
'Preferences', 'NDI') to fullfile(userHome, '.ndi'), matching the
location ndi-python writes to. The value is inlined here rather than
delegated to ndi.cloud.profile.userPrefDir() because Constant property
initializers cannot reliably call static methods across every
supported MATLAB release. The `Todo: Use prefdir` comment is now
obsolete and removed. sessiontable.localtablefilename() inherits the
new location automatically; no other in-tree caller of
PathConstants.Preferences exists.

ndi.session.sessiontable.migrateLegacyIfNeeded runs on first
getsessiontable(): if ~/.ndi/local_sessiontable.txt is absent and the
old fullfile(userpath, 'Preferences', 'NDI', 'local_sessiontable.txt')
exists, both it and its `_bkup*` siblings are copied over. Legacy
files are left in place (copy, not move) so a downgrade to a
pre-migration MATLAB keeps working -- same rule as
ndi.cloud.profile.migrateLegacyIfNeeded. Failures warn under
NDI:sessiontable:migrateFailed rather than raising.

Coordinated with Waltham-Data-Science/NDI-python#172.
Closes #922.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zDzZ9joBkZjzeMLfWZZk7
Add a comment on the existing copy-before-open in OldDatasetTest so the
reason is visible where it matters: ndi.dataset.dir runs
repairDatasetSessionInfo on a legacy dataset_session_info document during
construction, which writes and deletes documents. Opening a shared
example directly would mutate it.

MATLAB has no equivalent test that opens a shared external corpus in
place, so the actual fix for the sibling issue lands on the Python side
(ndi-python#99). Cross-linking here so the pattern stays visible if
MATLAB later grows such a test.

Refs ndi-python#99.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbK6Xis72nMM1EpBYEKsPK
Codecov flagged 22.22% patch coverage on #923 because the new
migrateLegacyIfNeeded and its helpers are only reached when a legacy
fullfile(userpath, 'Preferences', 'NDI', 'local_sessiontable.txt')
exists, which never happens on a fresh CI runner. The existing
ndi_test_sessiontable script exercises the API surface but not the
migration, so codecov correctly measured the new lines as unexercised.

Adds tests/+ndi/+unittest/+session/sessiontableMigrationTests, seven
tests covering every branch:

- PathConstants.Preferences resolves to ~/.ndi/ (regression guard).
- localtablefilename() lands under PathConstants.Preferences.
- migrateLegacyIfNeeded copies the legacy table into the new location
  and leaves the legacy in place.
- Backup siblings (_bkup001.txt, _bkup002.txt) are copied too.
- Migration is a no-op when the new file already exists (new wins).
- Migration is a no-op when no legacy is present.
- getsessiontable() triggers the migration end-to-end.

TestClassSetup/Teardown snapshot and restore any pre-existing files
at either location (a real developer's saved sessiontable) so the
suite is safe to run locally, matching the pattern already used by
profileTests.

No changes to sessiontable.m -- the test is purely additive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zDzZ9joBkZjzeMLfWZZk7
+common/PathConstants + +session/sessiontable: land preferences in ~/.ndi/
…w45q

OldDatasetTest: explain why the corpus is copied before opening (parity note for ndi-python#99)
…opened the binarydoc (#509)

Before this change ndi.dataset/database_closebinarydoc unconditionally
called ndi_dataset_obj.session.database_closebinarydoc(...), i.e. the
dataset's INTERNAL session, even for binarydocs the open path had
routed to a linked/member session via open_session(session_id). That
mismatch meant the wrong session's autoclose_listeners map was
consulted and the wrong database driver was asked to close the
handle, leaking the DID lock on the linked session's store.

Fix: track (binarydoc key -> owning session) on the dataset itself.

- New protected property BinaryDocSessions = containers.Map, keyed by
  the binarydoc's fullpathfilename (the same key the session's
  autoclose_listeners already uses).
- database_openbinarydoc calls rememberBinaryDocSession(binarydoc,
  session) whenever it dispatches to a linked session.
- database_closebinarydoc calls lookupBinaryDocSession first and
  dispatches to that session's close; falls back to the internal
  session when no owning session is recorded (matches prior
  behavior for internal-session docs and for handles opened
  outside this dispatch path).
- forgetBinaryDocSession clears the entry so the map does not
  grow across a session's lifetime.

The autoClose path was already correct: the SESSION's
database_openbinarydoc registers the ObjectBeingDestroyed listener,
so when a binarydoc handle is GC'd the owning session's callback
fires. This fix is only about the explicit-close path.

The Python side of this issue (dataset.database_openbinarydoc missing
cross-session dispatch on the open path) was landed in the sibling
repo as Waltham-Data-Science/NDI-python#175 / PR #176. Python file
handles rely on GC/`with`, so there is no analogous close-mismatch to
fix there.

Test: tests/+ndi/+unittest/+dataset/datasetBinaryDocCloseTests.m,
three cases: close through the dataset for a member-session doc is
warning-free; the map entry is gone after close; and the recorded
session for a linked-session binarydoc is the member session, not
the internal one.

MATLAB test suite has not been run in this session (no MATLAB
installed). Reviewer should run
`runtests('ndi.unittest.dataset.datasetBinaryDocCloseTests')` and
the surrounding `ndi.unittest.dataset` package locally before merging.

Closes #509.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zDzZ9joBkZjzeMLfWZZk7
… #11288)

The initial commit set the property default to a containers.Map. A
handle-typed property default in MATLAB is shared across every
instance of the class, so two open ndi.dataset objects would have
written into and read from the SAME owning-session map -- a close on
one dataset could try to route through a session recorded by a
different dataset. Code Analyzer flagged it and it is a real bug.

Fix: default the property to [] and lazily allocate the map in a new
ensureBinaryDocSessions helper called from rememberBinaryDocSession
(and reachable from lookup/forget, which already tolerate a non-map
value). Every dataset instance now gets its own map on first use.

Test coverage from the previous commit is unchanged and remains
valid; the map's per-instance behavior is exercised implicitly by
the tests, which use a single freshly-constructed dataset per test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zDzZ9joBkZjzeMLfWZZk7
+ndi/dataset: route database_closebinarydoc through the session that opened the binarydoc (#509)
Line 21 of apps/calculations/simple_calc_v2_schema.json was
`"parameters": [-Inf,Inf,0]`. `Inf` / `-Inf` are MATLAB literals but
not valid JSON tokens; strict JSON parsers (Python's json module in
particular) reject the file. MATLAB's own jsonencode emits `Infinity`
(not `Inf`) when `ConvertInfAndNaN=false`, and jsondecode only
accepts `Infinity`, so `Infinity` works for both languages. The
sibling v1 schema uses `Infinity` -- v2 was likely hand-edited to
`Inf` thinking it was equivalent.

The same file lives in ndi-python (synced from here in
Waltham-Data-Science/NDI-python#177) and turned CI red on main after
#178 landed a tripwire test that parses every bundled schema. Fix
pushed in lockstep in ndi-python#(TBD) so the next sync stays clean.

Five-character diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zDzZ9joBkZjzeMLfWZZk7
Sibling of the NDI-python change of the same name; completes the deferred
symmetry item from Waltham-Data-Science/NDI-python#78 (b). PR #87 there
explicitly noted no battery was added and "worth discussing separately
whether an ensemble round trip belongs in it." This is that battery for
filter, the one +ensemble function that is pure and in-memory. The other
eight need a session, a clock, or a display and would turn the battery into
a fixture-management exercise rather than a cross-language comparison.

Fifteen cases pin every rule stated in the MATLAB help: includes are a
UNION across names/ids/index/Keep, excludes always win over includes,
IncludeIndex is 1-based on both sides, all-zero trailing columns are
trimmed EXCEPT when nothing is kept -- MATLAB's isempty guard returns
early and the 0-by-Smax width survives. That last one is the one a port
is most likely to smooth over, so shapeOut is compared alongside
activityOut and nothingKeptPreservesWidth pins it as 0-by-3 rather than
0-by-1.

Activity is encoded row by row rather than as a 2-D matrix, per section 3
of FUN_CASES_SCHEMA.md: a 2-D matrix would render in MATLAB's column-major
order against Python's row-major, which the schema rules out. Rows are
1-D, so the encoding follows parseText's inputRendered pattern.

Files:

  tests/+ndi/+symmetry/+fun/ensembleFilterCases.m
  tests/+ndi/+symmetry/+makeArtifacts/+fun/ensembleFilter.m
  tests/+ndi/+symmetry/+readArtifacts/+fun/ensembleFilter.m
  tests/+ndi/+symmetry/FUN_CASES_SCHEMA.md      (section 10)

Every input field and every output field carries into the artifact, joined
by case name. identifier and message are recorded but never compared, per
the standing rule that MATLAB identifiers and Python exception names
cannot match.

/!\ AUTHORED WITHOUT A MATLAB RUNTIME -- the expected values are derived
by reading src/ndi/+ndi/+fun/+ensemble/filter.m branch by branch. Every
case class is documented and the standing prediction/measurement workflow
(predict, defer, then settle) applies to any that turn out wrong on the
first real run. Each generator carries its own /!\ marker as reminder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRaABckSNjs3e3bYN4FmHS
CI runs MATLAB, so the "authored without a runtime, validate before relying
on it" warning is not useful to a reader looking at the merged file -- the
symmetry workflow already exercises it. The predict/defer/settle workflow
for a case that turns out wrong on first run is documented in
FUN_CASES_SCHEMA.md sections 5 and 9 and applies whether the author had a
runtime or not.

Removes the markers from the three new files; no code changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRaABckSNjs3e3bYN4FmHS
simple_calc_v2_schema: use Infinity, not Inf (invalid JSON)
Symmetry battery for ndi.fun.ensemble.filter
The sparse code path is what production actually takes -- spike_matrix
returns sparse, and ndi.util.readSparse / writeSparse is the NDI-specific
round-trip format built exactly so an ensemble moves sparsely between the
two languages. Dropping the "keep the rendering symmetric" argument, since
both filter implementations densify their sparse output before rendering
and the resulting signature is identical to a dense twin's.

Two new cases:

  sparseInputBasic                 mirrors includeNamesBasic
  sparseNothingKeptPreservesWidth  mirrors nothingKeptPreservesWidth

Case definitions grow a `storage` field ("dense" | "sparse"); the runner
wraps activity in a scipy csr_matrix before calling filter when it says
sparse. `input_signature` carries `storage`, so one language sparse and
the other dense would fail as a real disagreement rather than pass
because the numbers happened to match. The nothingKept twin is
deliberately included: the isempty guard in local_trim_columns has to
fire on a 0-row sparse slice too, and the pair is what pins that.

Also fixed _rendered_rows / _output_shape to densify a sparse matrix
before iterating (np.asarray on a CSR returns an object array
pointing at it, not a dense array); both `filter`s return sparse output
when handed sparse input.

Battery grows from 15 to 17 cases; make-artifact assertion updated.

Verification: 4 new tests pass (2 skip on matlabArtifacts as usual),
23 unit tests on ndi.fun.ensemble.filter still pass. Ruff/black clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRaABckSNjs3e3bYN4FmHS
…nmk2

ensembleFilterCases: add sparse activity coverage
…cs-hdf5-format-9jn3l9

# Conflicts:
#	.github/badges/code_issues.svg
…mat-9jn3l9' into claude/spatial-genomics-hdf5-format-9jn3l9

# Conflicts:
#	.github/badges/code_issues.svg
#	.github/badges/tests.svg
A pyramid's ladder is split across two document types: the ladder, grid,
origin and base pixel size live on spatialGeneExpressionPyramid, while each
level's pixel dimensions, tile size and stored-tile count live on its own
spatialGeneExpressionTiles document. Until now only a private helper inside
readViewport reached across them, so anything else that wanted to set up a
viewer, size a figure or pick a level had to re-derive the join.

levelTable does that join once, returning one row per level, finest first,
with the shared frame (origin, extent, base pixel size, index order) in
UserData. Finest first because that is the order a multiscale viewer wants
a ladder in.

readViewportBase fixes the frame mismatch. readViewport takes its rectangle
in the pixels of one level, zero-based and relative to that level's
upper-left -- right for the tile arithmetic, wrong for a caller, who has a
rectangle in the source coordinates the data was acquired in, the same
frame cell centroids and contours use. The conversion subtracts the origin
and divides by the bin size, and the rounding has to go outward or the
caller silently gets less than it asked for. Low edge floors, high edge
ceils, and info.rectSourceCovered reports exactly what came back, so the
usual case of not landing on a bin edge is visible rather than assumed.
Panning off the pyramid returns an empty image rather than throwing,
because a viewer does that routinely.

chooseLevel picks the coarsest level that still resolves a rectangle.
Documented as NOT needed for napari, which takes the whole ladder and picks
a level itself from the shape ratios; it is for the callers that render one
image -- a figure, a thumbnail, an export, a tile server. Missing the target
returns the finest level and says so rather than erroring, since asking for
more detail than the data holds is a legitimate request with a definite
answer.

Tests use a non-zero origin and a wider-than-tall extent so an origin-blind
or transposing implementation fails rather than coincidentally agrees, and
they check the two things that go wrong quietly: the cross-document join,
and the rounding at the edges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHxsZ56GZsZ3hJ98TJ81t9
The spec names cells.tsv's columns cell_index, cell_id, x, y, area,
dnb_count, total_counts, n_genes, but only the first four are required and
writers differ on the rest. The extraction spike that produced the first
opossum cells wrote 'dnbCount' and 'n_genes_by_counts' where the spec says
'dnb_count' and 'n_genes' -- so a reader that took columns by position
would have mapped one measurement onto another's name and said nothing.
Columns are therefore matched by header, and any column the spec does not
name keeps the header the writer used rather than being renamed or
dropped.

cell_id stays text. It is a 14-digit identifier that loses precision as a
double and then no longer matches the source file it came from.

Contours are not read. contours.bin holds the boundary polygons and needs
its own reader; info.contoursPresent says whether there is more, so a
caller can tell the difference between "no contours" and "not read yet".

A row count disagreeing with the document's n_cells warns rather than
raising or being trusted silently: the file is what was read, and the
mismatch is worth saying out loud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHxsZ56GZsZ3hJ98TJ81t9
…mat-9jn3l9' into claude/spatial-genomics-hdf5-format-9jn3l9
claude and others added 26 commits September 9, 2026 13:41
The figure and its layouts were already on c.offWhite. The CONTROLS were
not: a uitable, a uitextarea, a uilistbox and a uieditfield each default
to MATLAB's own grey chrome, and the table is the largest surface in the
window -- so the app read as a grey one with a tinted border rather than
like the other NDI applets, which are white bodies under a navy header.

A paper() helper beside accent() puts them on c.white with c.darkBlue
text, and Browse... picks up the accent that every other button in the
app already had.

Defensive in the same way accent() is, and for the same reason: a missing
handle, or a control with no BackgroundColor, is a no-op. Losing a window
over a colour would be a poor trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHxsZ56GZsZ3hJ98TJ81t9
onView ended in uiwait(d), so pressing View froze the MATLAB prompt and
the manager window until the dialog closed -- while the whole point of
the dialog is to read a command and compare it against the list of
pyramids it came from.

Removing uiwait is safe because the dialog's callbacks are NESTED
functions: MATLAB keeps this workspace alive for as long as a handle to
one of them exists, so localGo, localRefresh and localBrowse still see d,
the fields and the selected row after onView has returned. The dialog's
default close request deletes it, which is what the old post-uiwait
cleanup line did by hand.

Two things follow from it becoming a real top-level window:

  - PRESSING VIEW TWICE now raises the open dialog instead of building a
    second one. Nothing stops a non-modal button being pressed again, and
    two dialogs would leave the user editing whichever landed on top
    while an older one still held a different pyramid.
  - CLOSING THE MANAGER takes the dialog with it. An orphan whose Launch
    button still worked would report its result to a status bar that no
    longer exists. The napari process is deliberately NOT touched: it was
    detached on purpose, it is somebody's picture, and closing the window
    that launched it is not a request to close it.

`launched` goes with the wait. Its only reader was the post-uiwait
cleanup, so it was left assigned twice and read nowhere -- worth deleting
rather than silencing with a pragma.

The tests check the source rather than opening a window: CI has no
display, and what would actually go wrong here is somebody reinstating
the wait, which is visible in the text and not otherwise observable
headlessly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHxsZ56GZsZ3hJ98TJ81t9
The viewer is a separate process started with system(), so it inherits
MATLAB's environment -- and NDI_CLOUD_TOKEN lives there. That is the
whole of the viewer's cloud credentials: it cannot read the MATLAB
Vault, and a login in some other shell does not reach it either. So the
token MATLAB happens to be holding at the moment of the click is what
the picture gets, for as long as the window stays open.

Left alone that expires, and what it looks like from the viewer is not a
login prompt but MISSING DATA. Tiles already in the local cache keep
drawing; the ones that were never fetched fail one at a time as the user
pans -- "Binary file 'tile.bin_280' not found for document ..." -- which
reads as a broken pyramid rather than a lapsed token. Blurring makes it
worse, since map_overlap gives each visible tile its eight neighbours
and so reaches tiles a still view never asked for.

freshenCloudToken renews it just before the launch, and says so plainly
if it cannot. NO TOKEN AT ALL IS NOT AN ERROR and does not prompt: its
absence says this MATLAB has not been talking to the cloud, and a login
dialog thrown up by a button that says "View in napari" would be a
surprise. Only an expired token is acted on, with interaction off -- the
vault and the environment are read, nothing is asked. A token that
cannot be decoded is left to the server to judge, which is
isTokenExpired's own choice.

The decision is split into cloudTokenNeedsRenewing so it can be tested:
the renewal itself reads the vault, whose contents differ on every
machine. Six tests, including one that pins the refresh AHEAD of the
system() call -- refreshing after it would hand the child the stale one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHxsZ56GZsZ3hJ98TJ81t9
…ommand line

Six laminar markers, in cortical depth order so the layers come up
reading down the cortex rather than in an order that has to be
explained: HPCAL1 green, RORB blue, FEZF2 bop orange, TSHZ2 yellow,
NXPH4 red, SST cyan. The colours run along the spectrum with depth,
which makes the stack legible without a legend, and all six are
distinct because the layers blend additively -- two genes in one
colormap make a picture that is neither of them.

'bop orange' is a napari colormap, not a typo; napari ships bop blue,
bop orange and bop purple. The space in it is why the whole value is
shell-quoted as one argument: unquoted it would reach the viewer as two,
and the second would be read as a session path.

THEY ARRIVE BESIDE THE BASE LAYER, NOT INSTEAD OF IT. That is the
difference between the new --gene-layers and the existing --genes:
--genes filters the base image down to a subset and leaves one picture,
while this adds a layer per gene on top, so "All genes" stays and the
six are comparable against it. A test pins that the demo command carries
the one and not the other.

THE PRESET LIVES IN MATLAB. The viewer takes genes and colours as
ordinary command-line options, so a demo is a preset command rather than
a mode the viewer has to know about -- anyone can build the same picture
by hand, and a second demo needs no Python change at all.

Nine tests, all headless: the preset's contents and the distinctness of
its colours, the spec builder including a gene left without a colour,
and the four things viewCommand has to get right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHxsZ56GZsZ3hJ98TJ81t9
…at-9jn3l9

GEF Manager, an HDF5 table of contents, and one-based tile files
The navigator's Apps menu opens the manager unconditionally, so a second
click on the same session used to build a second top-level window against
the same database; both listed the same pyramids and only one carried
the state the user was working in (last-selected row, a Reload the user
just did). The View dialog inside the manager already handled this for
itself; the manager window now does too.

The tag is set on the figure and embeds session.id(), so findOpenFigure
uses graphics as its own registry -- a closed window disappears from the
search on its own. Different sessions still get their own windows.

Fixes #984.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1HUSKgeD1VfzgjK5sSAh1
GEF Manager: raise the open window for a session instead of duplicating it
DID-matlab#201 (merged as PR #202) opened the read path for a series
whose manifest bytes are only remote: seriesMemberPath now offers the
manifest's file_info location to the customFileHandler, caches the
result at filecachepath/<manifestUid>, and proceeds. That path had no
NDI-side coverage: FileSeriesRoundTripTest requires NDI Cloud
credentials and only covers SyncFiles=true (the manifest arrives with
its bytes), and TestDownloadedSeriesManifestKeepsItsCloudLocation
checks only the file_info shape without touching the database.

Add TestSeriesWithCloudOnlyManifestResolvesThroughHandler: an offline
suite that builds a demoNDISeries with ndi.document.addFileSeries,
reshapes its file_info into the SyncFiles=false shape via
ndi.cloud.sync.internal.updateFileInfoForRemoteFiles, adds the doc to
a DID sqlite database, and opens a member with a uid-keyed mock
handler. Four tests:

  * The handler is called for the manifest first (ctx.uid = manifest's
    own uid, seriesName='', mode='open', sourcePath = the ndic:// URL
    verbatim), and the member reads back byte-for-byte.
  * The manifest lands at filecachepath/<manifestUid> so the next
    cached_path_for_uid on that uid is a hit.
  * Three different member opens share ONE manifest handler call, the
    28,000-member-level guarantee.
  * A handler that serves nothing for the manifest still raises
    DID:SQLITEDB:open with the "not on this machine" message shape --
    the identifier and phrase callers already pattern-match on.

The test uses did.implementations.sqlitedb directly rather than
ndi.dataset.database_openbinarydoc: NDI's didsqlite hard-codes
@download_file_from_cloud at open time (right at runtime, wrong here),
and ndi.session.database is not publicly reachable to swap. The
document is still built through ndi.document / addFileSeries and
reshaped through updateFileInfoForRemoteFiles, so the NDI-side pieces
this test exists to cover are still exercised.

Deferred to a follow-up on #986: retiring the cloud-reference
workaround in updateFileInfoForLocalFiles.m:96-101. That change needs
verification against the existing FileSeriesRoundTripTest, which
requires NDI Cloud credentials.
Adds testMembersSurviveADownloadFromTheCloudWithSyncFilesFalse to
FileSeriesRoundTripTest. Companion to testMembersSurviveADownloadFromTheCloud
above (SyncFiles=true), reusing the same upload/dataset lifecycle. The
new test downloads with SyncFiles=false -- so no bytes are installed
locally and the manifest arrives as an ndicloud/ingest=0 file_info
entry only -- and opens each member end-to-end.

VH-Lab/DID-matlab#201 (merged as PR #202) opened the read path for a
cloud-only manifest: DID's seriesMemberPath now offers the manifest's
file_info location to the customFileHandler, caches the result at
filecachepath/<manifestUid>, and proceeds. That call shape had no
cloud coverage before now: every existing round-trip test downloads
the manifest bytes at SyncFiles=true and only asks the handler for
members, so the real ndi.cloud handler's manifest-fetch branch had
never been exercised in CI. The offline half of #986 is pinned in
ndi.unittest.TestSeriesWithCloudOnlyManifestResolvesThroughHandler
(#987, offline mock handler); this one exercises the actual
download_file_from_cloud.

What the test pins beyond byte equality:
  * Precondition -- neither the manifest nor any member is on this
    machine before the first open. Without this a run that silently
    installed the manifest would still open the members and say
    nothing about the path.
  * After the first open, the manifest lands at
    filecachepath/<manifestUid>, so subsequent member opens on the
    same series pay no network for the manifest again. Every open in
    the loop but the first depends on this.
  * batch presign uidMisses == 0, i.e. every uid (the manifest's
    included) answered from the batch. signerCalls is not tightly
    asserted -- SyncFiles=false may reasonably cost one call (a
    whole-document scope answers both) or two (manifest then series
    scope), and pinning one over the other would guess at a server
    behaviour this suite does not otherwise cover.

Deliberately not used: the ndi.test.lightsheet.makeBlobFixture on the
in-development lightsheet-zarr-ndi-viewer branch. Basing this PR on
an unmerged branch would either block on that branch or duplicate
the fixture, and the question this test needs to answer is narrower
than a lightsheet ingest -- byte-for-byte comparison against
demoNDISeries content proves it. A lightsheet-based end-to-end test
is a natural follow-up once that branch lands.
… helper

Two of the four new tests failed on CI because they read back
`calls{1}` as empty even though the member reads succeeded --
proving the handler had run but the recorded calls list was not
what the caller saw.

Root cause: the previous `uidKeyedHandler` helper defined the mock
as a nested function inside the helper and returned `callsHandle`
as an output. MATLAB nested functions share the enclosing
function's workspace, so writes went into the helper's own
`callsHandle` variable -- but a cell array returned from a
function is a value copy. The caller's `calls` was a snapshot
taken at return time and never updated afterwards. Every call to
the handler kept updating the helper's captured workspace, which
the test could not read.

The fix mirrors DID-matlab's own convention (see
TestSeriesMemberFetch.testHandlerGetsTheSeriesNameAndTheMemberUid):
define the mock as a nested function inside each test method that
needs to inspect `calls`. The closure then writes into the test
method's own workspace, which the assertions read directly. No
helper return-by-value, no lost updates.

testTheManifestLandsAtFilecachepath and testDifferentMembersShareOneManifestFetch
also inline their handlers for consistency, and the unused
uidKeyedHandler helper is removed.
updateFileInfoForRemoteFiles now reconstructs `files.series_info.ingest_locations`
for any series with n_present > 0, in the same shape SyncFiles=true produces via
reconstructSeriesIngestLocations. This closes the DID-matlab #185
MembersNotLocatable guard that was refusing every SyncFiles=false download of a
dataset carrying a series document (the failing test in
FileSeriesRoundTripTest.testMembersSurviveADownloadFromTheCloudWithSyncFilesFalse
was hitting exactly that shape).

The manifest bytes are pulled to a scratch directory just long enough to read
each present slot's uid, then discarded. Manifests do NOT land in the DID file
cache here; the first member open triggers DID-matlab #201's fetchSeriesManifestBytes
which is what installs the manifest at filecachepath/<uid>. If the cache is
evicted on a later session, the same handler-fetch path re-populates it.
Nothing on the caller depends on a persistent local manifest file.

Fetches go through batchSignedUrlLookup (with the document scope), then fall
back to per-uid getFileDetails, mirroring didsqlite's own download_file_from_cloud
handler. A manifest that cannot be fetched leaves the entry un-reconstructed
so DID#185's guard raises with the document's own identity on the following
add_docs -- the right voice for a partial download.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dKpDXCo8VTS4AHiibNnCp
- Drop the %#ok<NASGU> on the onCleanup binding: current MATLAB no longer
  flags the assignment, so the suppression is stale (Code Analyzer 11382).
- Split the two-line `try, rmdir(d,'s'); catch, end` into explicit lines
  so the comma flagged as unnecessary at line 204 (Code Analyzer 11383)
  is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dKpDXCo8VTS4AHiibNnCp
updateFileInfoForRemoteFiles now accepts an optional customFileHandler
name-value pair. When provided, the manifest fetch goes through the
same DID contract (destPath, sourcePath, context) that DID#201's
fetchSeriesManifestBytes uses on the read side, so a caller can hand
in one handler that serves both the download-time reconstruction and
the later member opens.

The default (no handler passed) still mints a signed URL and fetches
directly via ndi.cloud.api.files.*, matching what didsqlite.m's own
download_file_from_cloud does for a non-member ndic:// file. No caller
in the tree needs to change -- downloadNdiDocuments continues to call
this without a handler and gets the same behavior as before.

Offline test coverage:
tests/+ndi/+unittest/+database/TestUpdateFileInfoForRemoteFilesShape
covers the shape work and the reconstruction path without cloud
credentials. The reconstruction test rounds a demoNDISeries doc
through did.implementations.sqlitedb so its ingest_locations are
stripped (matching a cloud read-back), then reshapes with a mock
handler that serves the manifest bytes by uid -- the same handler
shape TestSeriesWithCloudOnlyManifestResolvesThroughHandler uses on
the read side. Asserts that ingest_locations comes back with one
entry per present member, ndic:// URLs against the given dataset,
and that the manifest fetch happens exactly once with seriesName=''
in the context (a manifest, not a member).

The cloud-CI FileSeriesRoundTripTest still exercises the default
(no-handler) path against a real NDI Cloud dataset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dKpDXCo8VTS4AHiibNnCp
…986)

- makePlainDocWithOneFile referenced 'plain.bin' on a demoNDISeries doc.
  The schema only declares 'chunkdata.bin', so add_file raised
  "file must match an expected name". Switched the plain-doc fixture to
  the demoNDI class (schema declares one plain file, no series), which
  is the shape this test wants to exercise anyway.
- testReconstructsIngestLocationsThroughAMockHandler passed the doc
  returned by did.implementations.sqlitedb/get_docs straight into
  updateFileInfoForRemoteFiles. get_docs returns a did.document; the
  SUT is an NDI helper that calls has_files (an ndi.document method),
  so the call raised NoSuchMethodOrField. Wrap the returned doc in
  ndi.document(...) the same way didsqlite.do_read does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dKpDXCo8VTS4AHiibNnCp
…false-test

Offline test for a cloud-only series manifest (SyncFiles=false shape) (#986)
…lse-companion

Cloud companion: SyncFiles=false round-trip in FileSeriesRoundTripTest (#986)
MATLAB wrappers for the doc-level file-tier control endpoints landing on
the ndi-cloud-node side (Waltham-Data-Science/ndi-cloud-node PR for
manuals/file-tier-design.md).

New under src/ndi/+ndi/+cloud/+api/+files/:
- setFileTier(cloudDatasetID, documentIDs, targetTier, ...)
    POST /datasets/{datasetId}/file-tier-jobs. documentIDs is a string
    array or cell array; each entry may be a cloud _id or an NDI id and
    the server infers by shape unless idNamespace= is set explicitly.
    targetTier is one of STANDARD | STANDARD_IA | GLACIER_IR | GLACIER |
    DEEP_ARCHIVE.
- getFileTierJob(jobID)
    GET /file-tier-jobs/{jobId}. Returns state, targetTier, per-phase
    counts, and a bounded error list.
- waitForFileTierJob(jobId, ...)
    Polls with exponential backoff until the job reaches a terminal
    state (completed | failed | superseded) or the timeout elapses.
    Same shape as waitForSignedURLSetJob.

Plus the implementation classes under +implementation/+files/ and two
new entries in +api/url.m (create_file_tier_job, get_file_tier_job).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XnjPsVgQfRTSJRu2AeABL
ndi.cloud.download.internal.batchSignedUrlLookup collapsed two distinct
causes of "this uid is not in the scope's map" into one silent per-uid
fallback:

1. Data drift: the file was never in this scope. The right answer is to
   fall back and warn.
2. A lagged index: the file IS in the scope on the server, but the batch
   endpoint's map has not caught up with a just-completed bulk upload.
   Falling back per uid here defeats the batch design -- a
   28,000-member series that took the fallback would issue 28,000
   getFileDetails calls where the batch would have taken one.

Waltham-Data-Science/NDI-python#309 (the counterpart on the Python side)
observed the second case on TEST_USER_1/prod after
Waltham-Data-Science/NDI-python#308 first let a series manifest reach
the server: the batch answered signerCalls=1 with a 2-entry map for a
4-member series. The two missing uids fell back per uid. Retrying the
same scope 37 minutes later on run_attempt=2 returned the full 4-entry
map -- so the endpoint does eventually catch up, just not by the time
waitForAllBulkUploads reported done.

Fix (#991): when a cached scope answers with a URL but
a subsequent uid in that scope misses, drop the entry, pause for
partialMapRetrySeconds (default 0.5), and re-fetch once. If the second
map covers the uid, take it and every subsequent uid in the scope
resolves from the refreshed cache; if not, warn and fall back as
before. Bounded to one retry per scope, so a genuine data-drift miss
still costs one extra signer call for the whole scope, not one per
uid. Tracked in a new STATS.partialMapRetries counter so tests can
distinguish the retry path from a plain data-drift miss.

Refactor: the fetch/nargout/try dance moves into a nested
localFetchScope so the retry can call it a second time without
duplicating it. Adds an injectable sleepFcn (defaults to @Pause) so
tests exercising the retry path do not pay real wall-clock latency,
and a partialMapRetrySeconds=0 mode that keeps the single retry but
skips the sleep.

The live-cloud FileSeriesRoundTripTest.signerCalls assertion is
relaxed to signerCalls == 1 + partialMapRetries with
partialMapRetries <= 1, keeping the "one batch call per series' worth
of members" contract while allowing the retry. uidMisses == 0 stays
firm.

Four new unit tests in BatchSignedUrlLookupTest mirror the Python
counterpart's TestBatchLookupPartialMapRetry: the retry succeeds; the
retry pauses before re-fetching; the retry is bounded to once per
scope; a retry that raises still falls back cleanly without
double-counting the miss. Existing miss-path tests are updated to
account for the added signer call and to skip the sleep via
partialMapRetrySeconds=0.

Closes #991.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016rEFEXaZ9t3BftShqc9Y4B
…etry

Retry once when the batch signer returns a partial map
readchannels_epochsamples_ingested addresses concrete _seg.nbf_N files.
In NDI the first sample of an epoch is 1, so s0 <= 0 must be clamped
rather than passed through: unclamped it computed SEG_start = 0 and
asked the document for _seg.nbf_0, a filename no writer has ever
produced. The isinf-only block already handled -Inf that way; a finite
value before the epoch (or past the end) fell through unclamped.

pyraview's filter warm-up sets t_read_start = current_t - excess on
the first chunk, which epochtimes2samples converts to s0 = 1 - sr, a
large negative sample number. Over local files the daq system dispatches
to a reader that hands the number to an external file library, which
quietly returns a short slice, so the same shape has always worked.
Over ingested files the reader tries to name a specific segment
document, DID rightly refuses "does not include a file named
ai_group1_seg.nbf_0", and pyraview's document-creation try/catch
swallows the error and never creates the pyraview doc.

The fix extends the reader's own convention: read what's in
[absolute_beginning, absolute_end]; anything outside is silently
omitted. -Inf/+Inf now flow through the same max/min the finite bounds
use (MATLAB clamps them for free). A window entirely outside the epoch
returns zeros(0, numel(channel)) rather than throwing -- read past the
edge, get nothing there. The caller-inversion check (s0 > s1 originally)
is kept, and precedes the clamp so an inverted request is not silently
absorbed by clipping one endpoint into the other.

The tests ingest the buildSession Intan example in place, then read the
resulting probe through readtimeseries -- pyraview's own call chain --
with the windows that used to fail: before t0, after t1, entirely
before t0, and [-Inf, Inf]. Each asserts on the reader's advertised
contract; before this change the first two error inside DID.

Fixes #993

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011auYUP2oxabnq8Z2q7rv6F
The four new tests errored on CI with MATLAB:cellRefFromNonCell inside
isingested. Root cause: probe.getchanneldevinfo returns devepoch as a
cell of epoch IDs (strings) -- see src/ndi/+ndi/probe.m:212-214 --
not the epochfiles cell array that isingested wants.

isingested does epochfiles{1} on its input, which is fine for a cell of
paths and MATLAB:cellRefFromNonCell for a plain string.

Do what the reader itself does at src/ndi/+ndi/+daq/+system/mfdaq.m:178:
load the daq system by name, ask its filenavigator for the epoch's
files, then test isingested on those. That is the exact cell array the
dispatch decision uses, so the guard proves what it claims.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011auYUP2oxabnq8Z2q7rv6F
waitForFileTierJob was a monolithic function -- no seam to override --
so the terminal-state, timeout and backoff paths could only be exercised
against a live server (which for tier control is not deployed to dev
yet, per PR #990's test plan). This mirrors the shape of the sibling
waitForSignedURLSetJob (thin wrapper over an implementation class with
an overridable pollStatus seam, plus a Scripted subclass and a mock
test) so those paths run headless.

- Factor the wait loop into a new WaitForFileTierJob implementation
  class with a protected pollStatus() seam.
- waitForFileTierJob.m becomes a thin shim over the class, unchanged
  API. Small behaviour fix along the way: an ok=false poll no longer
  ends the loop -- a gateway blip is not a dead job, and the caller
  keeps polling until the deadline. The old function returned false
  on the first API error, which a live test would only hit on a bad
  day.
- ScriptedFileTierJob under tests/+ndi/+test/+helper/ subclasses the
  new impl class and scripts pollStatus from a states cell array, same
  shape as ScriptedSignedURLSetJob.
- FileTierMockTest under tests/+ndi/+unittest/ exercises six cases
  against the helper: completed => success, failed and superseded are
  terminal (no re-poll), queued -> running -> completed is polled
  through, running-forever times out, and a transient ok=false is
  retried rather than treated as terminal.

A live cloud test belongs under tests/+ndi/+unittest/+cloud/ (matching
SignedURLSetTest) and lands once the tier endpoint is deployed to dev.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XnjPsVgQfRTSJRu2AeABL
Clamp the ingested mfdaq reader's window to what the epoch has
Mirrors SignedURLSetTest under tests/+ndi/+unittest/+cloud/. Two tests:

- testCreateAndWaitForFileTierJob: builds a small local dataset with
  two generic_file docs, uploads to a fresh cloud dataset, waits for
  bulk extraction to settle, then submits setFileTier against one doc
  with targetTier='PSEUDO_COLD'. PSEUDO_COLD skips S3 entirely and is
  server-side-accepted in non-production stages, so the round-trip is
  fast and pays no S3 minimum-storage charges. Verifies the accepted
  payload (jobId, fileCount, resolvedDocumentCount, collateralDocumentIds),
  polls once (accepting completed as a legal first read because v1 runs
  the worker inline), waits for terminal state, and checks
  state=='completed' with filesDone==fileCount and filesFailed==0. On a
  partial deployment (CONFIGURATION_ERROR / ENQUEUE_ERROR /
  JOB_CREATION_ERROR / INVALID_TARGET_TIER) the test skips rather than
  fails, matching the sibling.

- testGetFileTierJobUnknownId: bogus jobId returns 404.

Follows the narrative + APIMessage pattern so a failure carries every
step and the URL of the last API call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XnjPsVgQfRTSJRu2AeABL
The fourth MATLAB wrapper called for by manuals/file-tier-design.md
(ndi-cloud-node PR #138). Reads the doc's cached filesTier summary --
the same field the tier worker rewrites on completion -- and normalizes
it into a stable shape the caller can rely on:

  answer.counts     per-tier file-count map
  answer.dominant   coldest tier with a non-zero count (the design's
                    'dominantFileTier' shorthand); "" for a doc that
                    has never had a tier op
  answer.notes      cell array of divergence notes ('warmest-wins
                    overruled a freeze because sibling doc needs file
                    warm'); empty when clean
  answer.updatedAt  timestamp of the last summary write
  answer.raw        the full get-document response, for callers that
                    want the whole doc

No new server endpoint -- the design deliberately keeps the summary as
a cached field on the document, so this composes over the existing
ndi.cloud.api.documents.getDocument and projects out filesTier. Callers
that already hold the doc can just read `.filesTier` themselves; this
wrapper is for the common 'give me the tier state for this doc, no
setup' path.

Two new cases in FileTierTest under +cloud/:

- testGetFileTierOnUntieredDocumentReturnsEmpty -- read on a fresh doc
  round-trips cleanly and reports dominant "" (or STANDARD, depending
  on how the server surfaces the never-touched state) rather than
  throwing on the absent field.

- Extension to testCreateAndWaitForFileTierJob -- after the PSEUDO_COLD
  freeze completes, getFileTier.dominant reads "PSEUDO_COLD". This is
  the read path a caller uses to confirm a freeze happened, without
  polling the job.

Both live under +unittest/+cloud/ so they skip cleanly when
NDI_CLOUD_USERNAME / NDI_CLOUD_PASSWORD are absent, matching the
sibling SignedURLSetTest pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XnjPsVgQfRTSJRu2AeABL
…-o8enda

feat(cloud.api): file-tier control wrappers

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Analyzer found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@stevevanhooser
stevevanhooser merged commit 7ab1370 into Vnext_Update Sep 16, 2026
10 of 12 checks passed

Copy link
Copy Markdown
Contributor Author

Merge landed directly on Vnext_Update as 7ab13703b (parents 690d09b1 Vnext_Update + 6fdec7e7 main). All 5 conflicts resolved:

  • .github/badges/code_issues.svg, .github/badges/tests.svg — took main's version. CI regenerates these into the dedicated badges branch on the next run, so contents don't matter.
  • src/ndi/+ndi/session.m — took main's batch-removal shape (docs_to_remove = cat(2, docs_to_remove, dochere(:)'), single database_rm at the end — the comment above the conflict explains why removing per dep first cascaded and double-deleted the daqsystem doc), with Vnext_Update's V_delta-aware read via ndi.document.i_readDependencyTarget(depends_on(i)).
  • tests/+ndi/+unittest/+calc/+stimulus/testCalcTuningCurve.m — took main. The verifySelfTests helper exists on ndi.calculator.m:346 (added by "Split self-test verification into a validator and an orchestrator"), so the refactored one-liner is the right call.
  • src/ndi/+ndi/document.m — mixed per-hunk:
    • Constructor: main's madeFromDefinition flag, main's parent-constructor call ndi_document_obj@did.document(document_properties), main's reset_file_info on definition-built docs — plus Vnext_Update's ndi.compat.augmentRead (legacy-alias injection, NDI-specific) and ndi.compat.normalizeDependsOn (belt-and-suspenders alongside the parent's own canonicalization).
    • add_dependency_value_n: took main (removed override — the parent's version now handles it, virtually dispatching to ndi's set_dependency_value).
    • dependency_value, dependency_value_n: kept Vnext_Update's overrides. dependency_value_n has NDI-specific fallback (unnumbered lookup when no _i match, empty-value template-placeholder skip) that DID's parent version doesn't have.
    • plus, remove_dependency_value_n, set_dependency_value, remove_file, reset_file_info, setproperties: kept Vnext_Update's overrides. plus in particular has dedup-by-name dependency-merge semantics that the parent's concat-and-append doesn't. set_dependency_value already writes V_delta canonical document_id.

Closing this PR — it was preview-only. Vnext_Update now contains the merge; landing on Vnext and running the Vnext CI matrix is the next step.


Generated by Claude Code

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.

5 participants