Skip to content

Haley V2 import and the NDI V2 object layer (both V_eta shapes) - #1021

Draft
jesshaley wants to merge 354 commits into
V2from
claude/v2-object-layer
Draft

jesshaley wants to merge 354 commits into
V2from
claude/v2-object-layer

Conversation

@jesshaley

@jesshaley jesshaley commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Two things, now in one PR into V2 (this replaces #1011, whose branch claude/haley-import-v2 this one contains in full):

  1. The Haley V2 importer, ndi.setup.conv.haley.import_V2;
  2. the NDI V2 object layer, which reads what it writes.

Both work on did-schema main's shape and on one entity class with statements by composition (did-schema#88).

The Haley V2 importer

ndi.setup.conv.haley.import_V2 imports the Haley C. elegans / E. coli foraging dataset as V2 (V_eta). It was built stage by stage with the dataset's owner and is meant to be the canonical example of a V2 import. doImport.m (the v1 import) is unchanged.

  • The stages:
    • dataset metadata from import_V2_spec.json and the seeding suspensions;
    • per study and day: sessions, subjects (plates, patches, cohorts, worms), acquisition, relations, assertions, manipulations, ambient readings, calculations (video geometry and patch geometry, tracks, E. coli profiles) and encounters;
    • dataset-wide: the border amplitude fits, then one check-and-write of the whole dataset.
  • Every choice is in +haley/import_V2_decisions.md, with its reason: decisions Week of Jan 25 #1-fixes addToStructArray method in nsd_variable_struct #70, including today's comparison with doImport, column by column.
  • Source problems are reported, not changed silently. Each run prints its checks; named corrections live in the spec.
  • To run it: ndi.setup.conv.haley.import_V2(dataParentDir, 'Write', true, 'ReadVideos', true). Nothing is written without 'Write'.

The object layer

It began as a design document (src/ndi/docs/NDI-matlab/manual/developer/V2_Object_Layer.md) and now carries the build.

Decisions recorded (agreed with Jess Haley, 2026-10-06):

  • reading comes first; writing stays with did2.build for now;
  • value(), not data();
  • the subject is an entity;
  • back compatibility with v1: existing behaviour unchanged, and new methods return empty where v1 has no equivalent;
  • the @ in local_identifier stays a v1 rule and is not required for V2.

What it does:

  • Objects: ndi.entity, ndi.subject under it, and one statement tree that mirrors the schema: ndi.statement, ndi.assertion, ndi.interaction, ndi.observation, ndi.manipulation, ndi.calculation. A statement's value() is decoded from its data body.
  • Search: ndi.subject.search and ndi.statement.search, by what is true of a subject: asserted properties, the six statement kinds, relations, time filters (at, during, before, after, duration), wildcards, and explain.
  • Graph: walking member_of (distributive statements) and part_of/sample_of (assertions); contained_in as context (tenet T17); parents, children, ancestors, descendants.
  • Summaries of subjects and statements as tables.
  • Speed: each document is decoded once, the report-only census is skipped on reads, and a document already at the read target skips the conversion pass.

Both schemas (did-schema#88, signed 2026-10-08)

The import and the readers work on main's schema and on one entity class with statements by composition.

  • Writers:
    • ndi.setup.V2.entityDocuments writes an entity in either shape.
    • ndi.setup.V2.curie writes CURIE identifiers.
    • A cohort is instance_of its strain.
  • Readers: ndi.v2.isaQuery, blockOf, blockQuery, leafName and kindOf find a document under either shape.
  • Composed statements: ndi.v2.classChain includes the value kind.
  • Strain search: a session opened from its dataset resolves a strain through the dataset's documents (ndi.session/database_search_with_dataset). A session folder opened on its own holds only the relation and cannot name the strain; the test pins that.
  • CI: test-import-v2 runs two legs:

Linked and ingested sessions (did-schema#88, signed 2026-10-09)

V_eta_linked_session_plan.md: a session is in a dataset when its session entity is part_of the dataset, or part_of a study that is. An ingested session's documents are in the dataset's database. A linked one's are in its own folder, which a linked_session document (path, entity_id) names. On a V2 dataset (a did2 database with no session_in_a_dataset):

  • Listing: ndi.v2.datasetSessions lists the members, denominator first, and says what it left out and why (session_notes(), warning ndi:dataset:sessionsLeftOut). A linked folder is opened and checked to hold its session. session_list, open_session and database_search work for both kinds.
  • Adding: add_linked_session and add_ingested_session take 'PartOf' (a study; default the dataset).
  • Converting: convertLinkedSessionToIngested, and the new convertIngestedSessionToLinked(id, folder).
  • Unlinking: unlink_session removes the link and the part_of relations; the folder stays.
  • Self-contained: makeSelfContained('DryRun', tf) copies every by-location file into the dataset's store.
  • Copying: documents move as stored, with the files they ingested (ndi.v2.copyDocuments, ndi.v2.sessionDocuments).
  • Constructor: ndi.dataset.dir's constructor reads its own database for its lookups.

v1 datasets are unchanged.

Checks

test-import-v2, run 37873856395 (179fd779): both legs pass, 111 tests each, 0 failed, 0 filtered. All 7 new TestLinkedSessions tests pass on next. On current, whose schema has no linked_session class, the 4 that link return early.

The full suite (run-tests.yml, dispatched on this branch, run 37874409826) reports 1805 passed, 90 failed. None of the 90 is an ndi.unittest.dataset test.

  • V2 tests in that workflow: they fail at setup, because it carries no V2 schema (DID_SCHEMA_PATH does not hold a V2 schema with study).
  • The rest: in code this PR does not touch, for example OneEpochTest's fixture (Unrecognized function 'probemap') and diffTest (a demoNDI file-validation error in DID).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf


Generated by Claude Code

…greement

Confirmed 2026-09-27: the async signed-URL-set-job failure is
GLOBAL, not dataset-specific. The Python integration test raises
the same BatchScopeUnreachable against the 15-file tiny lightsheet
fixture (6ab84b549852a120dbcb22bc, User 1 prod) that it raises
against the 121k-file production dataset, while
getFileDetails(datasetId, manifestUid) returns 200 OK for the
fixture's manifest uid in the same instant. Two server code paths
disagree.

This is the MATLAB-side reproducer. Read-only against the fixture
(no uploads, no cleanup). For each lightsheetZarrLevel document in
the fixture:

  * listDatasetDocumentsAll picks the level docs out by className.
  * getDocument fetches full properties, from which
    files.file_info yields the chunk.bin series' manifest uid
    (same shape ndi.cloud.sync.internal.updateFileInfoForRemote
    Files reads on the download side).
  * createSignedURLSetJob(idNamespace='ndi', fileSeries='chunk.bin')
    + waitForSignedURLSetJob to a terminal state.
  * INDEPENDENTLY getFileDetails(datasetId, manifestUid).

Consistency assertion:
  * both ok -> healthy (mapSize > 0 sanity check).
  * both fail -> consistent, not the bug.
  * async job fails but getFileDetails returns 200 -> THE BUG.
    Accumulate the disagreement across every level doc so the
    final message names each cloudDocId / ndiDocId / manifestUid
    the API team can cite.
  * async job succeeds but getFileDetails fails -> also fail
    (the opposite mismatch is diagnostic on its own).

Rows print as [fixture-probe] lines while the test runs so a CI
log captures the sequence even if a later doc times out.

Lands under ndi.unittest.cloud so testCloudApi() picks it up under
the existing test-cloud-api.yml matrix without a workflow edit.
Skips cleanly without NDI_CLOUD_USERNAME/PASSWORD via the same
assumeNotEmpty idiom the rest of ndi.unittest.cloud uses.

See #1010. Python analog test lives under
NDI-python on the same branch (test_cloud_signed_url_set_job.py).

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

The old version hardcoded FixtureDatasetId = "6ab84b549852a120dbcb22bc"
(User 1 prod), so the test couldn't run against dev -- that dataset
id doesn't exist there and listDatasetDocumentsAll would fail with
"dataset not found" instead of exercising the actual bug. Since the
disagreement is GLOBAL (confirmed 2026-09-27 against both the tiny
prod fixture AND the 121k-member production dataset), a probe that
builds its own fixture reproduces it wherever the credentials point.

buildAndUploadTinyFixture now:
  * builds an ndi.test.lightsheet.makeBlobFixture (shape [32 32 32],
    1 channel, 3 levels, chunk [16 16 16]) -> tiny OME-Zarr with
    ~5-ish spatial chunks per level;
  * ingests into a fresh ndi.session.dir + subject, runs
    ndi.fun.doc.lightsheet.fromOMEZarr with materializeChunks=true,
    producing several lightsheetZarrLevel documents;
  * uploads uploadAsNew + skipMetadataEditorMetadata into whichever
    environment CLOUD_API_ENVIRONMENT points at;
  * waitForAllBulkUploads before probing;
  * registers per-test + class-level cleanup so every uploaded
    dataset is deleted, even on abort.

The probe body is unchanged: enumerate lightsheetZarrLevel docs from
the CLOUD side via listDatasetDocumentsAll, extract each level's
manifest UID from files.file_info, call createSignedURLSetJob +
waitForSignedURLSetJob next to an independent getFileDetails, and
fail on any (asyncFailed, getFileDetails=200) row with each offending
(docId, ndiDocId, manifestUid) triplet named.

Test now runs identically on dev and prod, and stops depending on
User 1 prod fixture state that a later cleanup could remove.

See #1010. Python analog:
Waltham-Data-Science/NDI-python test_cloud_signed_url_set_job.py
on the same branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1i76ogUkH8Aa8Y69tiPdz
exist('ndi.test.lightsheet.makeBlobFixture', 'file') returned 0 on
Steve's MATLAB even with src/ndi on the path, so both
TestSignedUrlSetJobFixture and TestSignedUrlSetJobScaling filtered
themselves out at TestClassSetup with a bogus "not on the path"
diagnostic and never ran the actual probe.

exist(NAME, 'file') is unreliable for dotted package names -- the
'file' second argument was designed for plain file paths, and its
handling of package resolution differs across MATLAB releases.
which(NAME) is the idiomatic probe: it returns the resolved file
path (or empty) after consulting the same package lookup MATLAB
would use to invoke the function.

Swap both TestClassSetup checks to assumeNotEmpty(which(...)) so
the tests skip only when the fixture builder is genuinely absent.

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

Two fixes to what Steve saw on a live dev run (fresh upload, five
lightsheetZarrLevel docs, every one jobState=ready + manifestFetchable=1):

1. The mapSize>0 sanity check was misfiring on ready jobs because
   waitForSignedURLSetJob's poll answer carries `resultUrl`, not
   `fileCount` -- the file count lives inside the gzipped JSON blob
   the resultUrl points at. probeOneLevelDoc now fetches that blob
   via ndi.cloud.api.files.getSignedURLSetResult when the job is
   ready, and reads fileCount off the parsed struct. A ready job
   with a zero-file blob still fails the sanity check with an
   honest diagnostic, but the healthy path passes.

2. Diagnostics were bare strings; a failed cloud test message needs
   to be handable to whoever owns ndi-cloud-node without a MATLAB
   session. Now every step appends to Narrative and every assertion
   that touches the cloud wraps its message with
   ndi.unittest.cloud.APIMessage, matching FileSeriesRoundTripTest's
   established shape (#945). The Narrative property
   is the sequence of steps a reader follows; APIMessage bakes it
   into a pretty-printed JSON report along with the API call's
   status line, URL, and body.

Refactored the probe into two private methods
(resolveManifestUid + probeOneLevelDoc) so the top-level test reads
as a narrative and each per-doc failure carries the specific step
that produced it. Per-doc [fixture-probe] rows still print (they
are the CI-log grep target).

Row struct now carries jobResultBlob / jobResultResponse /
jobResultUrl so the final "createSignedURLSetJob and getFileDetails
disagree" message serializes the server's own words (result blob,
poll response, whichever is interesting) into the JSON report.

See #1010. Python analog:
Waltham-Data-Science/NDI-python test_cloud_signed_url_set_job.py on
the same branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1i76ogUkH8Aa8Y69tiPdz
A working scientist reopening the same lightsheet-scale dataset over a
day should pay the ~85 min signed-URL-set job cost once, not every
open. New signedUrlDiskCache module writes each scope
(datasetId, documentId, seriesName) to
~/.ndi/signed-url-cache/<ds>/<doc>[_<escapedSeries>].json.gz as
gzipped JSON, TTL-checked against the server's own filesExpireAt
with a 30 min safety buffer, and atomically renamed on write.

batchSignedUrlLookup gains a diskCache option (off by default; the
DID chunk-download path in didsqlite turns it on). On S3 403 the
DID handler forgets the scope, so a rotated token does not cascade
to N per-uid 403s.

Env-var override NDI_SIGNED_URL_CACHE_DIR points the cache anywhere
(tests use it for per-run isolation). On-disk contract mirrors
byte-for-byte in NDI-python (VH-Lab/NDI-python#322).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhfC1rGbqm3nFBwxN3iRoV
Round-trip tests failed because save() was throwing silently inside its
own try/catch — the Java bridge path for GZIPOutputStream.write and
Files.move(ATOMIC_MOVE) with a javaArray of an interface type is not
portable across MATLAB releases / JVMs, so no file ever landed on disk
and every subsequent load() returned []. The five failing tests all
depend on save->load; the four tests that only assert misses passed
incidentally because "no file" is also a miss.

- gzipToFile / gunzipToString: use MATLAB's builtin gzip / gunzip via a
  scratch plain file (unicode2native + fwrite). Same on-disk format as
  before (single-member gzip of UTF-8 JSON), no Java overloads.
- atomicRename: use movefile('f'), which delegates to rename(2) on POSIX
  and MoveFileEx on Windows. Same "no half-written file visible"
  invariant, no javaArray-of-interface pitfall.
- chmodUserOnly: prefer filePermissions (R2024a+); keep fileattrib as a
  fallback so older MATLABs still work.
- localProcessId helper: prefer matlabProcessID; keep feature('getpid')
  as a fallback for the same reason.

Public contract (cachePath, payload struct, TTL/safety-buffer semantics,
forget, cacheDir + env override) is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hGmZKPmMGAt4KN7wmReRS
testLoginLogout asserted verifyClass on
answer_login.user.organizations.id, which expands to a
comma-separated list when the login response carries a struct
array of organizations. verifyClass then reads the extra values
as positional args and MATLAB:narginchk:tooManyInputs fires. This
started tripping when the test account was added to a second/third
organization; the underlying code (ndi.cloud.authenticate ->
extractFirstOrganizationId) already picks the first, so the test
mirrors that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhfC1rGbqm3nFBwxN3iRoV
Was: one 0.5 s retry per scope when the first signed-URL-set response
did not name a requested uid.

Now: a bounded schedule of 1 s / 3 s / 9 s. Waltham-Data-Science/
NDI-python#320 shows User 1 prod's signed-URL-set index lagging further
than 0.5 s after ``waitForAllBulkUploads`` returns -- one retry is not
enough to bridge the settle between the bulk-upload extraction subsystem
reporting done and the signed-URL-set subsystem catching up. A missed
uid then either flags as uid_misses > 0 (main) or, on the Python side's
strict-mode reconstruction path, surfaces earlier as a document rejected
at add time. Three waves at 1 s / 3 s / 9 s cover the observed tail,
still bounded (each wave costs one scope refetch, not one per uid), and
never fire in the common case where the first map is already complete.

Schema:
- New name-value ``partialMapRetryDelays`` (row vector of waits).
  Defaults to [1 3 9].
- ``partialMapRetrySeconds`` kept as a deprecated scalar knob (NaN
  means "use partialMapRetryDelays"; any real number replaces the
  schedule with a one-element vector containing that wait, so
  ``partialMapRetrySeconds=0`` still fires ONE retry with no wait,
  matching the pre-schedule contract every existing test pins).
- Persistent RETRIEDSCOPES map now records attempts spent per scope
  (double) instead of a "has been retried" flag (logical); walks the
  schedule until a fresh map names the uid or the schedule is
  exhausted.

Round-trip test's cap on partialMapRetries relaxed from 1 to
numel(defaultSchedule); every other assertion (uid_misses == 0,
signer_calls == 1 + partial_map_retries) is unchanged.

Python mirror lands in Waltham-Data-Science/NDI-python on the same
branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
When DID reads a series member, it fetches the manifest first via the
same download handler with seriesName="". That call previously went
through ``batchSignedUrlLookup`` under the whole-document scope, and
for a lightsheet-scale pyramid document (15k+ files) the batch endpoint
spent 60-90 s signing URLs the caller had no use for -- just to answer
the ONE manifest URL. The subsequent seriesName="chunk.bin" call then
fired its own scope, and the two answers overlapped almost completely.

Now: when seriesName is empty, the batch is skipped and the fetch
takes the direct ``getFileDetails`` path (O(1)), matching what
``ndi.cloud.sync.internal.fetchManifest`` already does for the
internal manifest fetch. When DID follows up with a member fetch
(seriesName="chunk.bin"), the batch fires there and amortizes across
every member exactly as it did before.

Python mirror lands in Waltham-Data-Science/NDI-python on the same
branch (filehandler.download_file_from_cloud gained the same guard).

See #1010 and Waltham-Data-Science/NDI-python#320.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
Real lightsheet-scale documents (e.g. the 121,441-URL pyramid level
that motivated Waltham-Data-Science/NDI-python#320 and
ndi-cloud-node#151) sit an order of magnitude past the largest row
this probe used to run (N=10000). That regime is where per-URL
signing rate goes sub-linear AND the signed-count plateaus for
minutes; neither is visible below ~50k. Adding N=50000 and N=100000
gives us the rows that will prove ndi-cloud-node#151's fix has
landed and guard against future regressions.

Two guards keep the default CI run from swallowing tens of minutes:

- **Huge-N gate.** The 50k / 100k rows upload roughly 50k / 100k
  files each, which currently takes tens of minutes and burns cloud
  budget on every CI push while the perf issue is still live.
  ``assumeTrue(NDI_SCALINGPROBE_HUGE=='1')`` skips them by default;
  set the env var to run them locally when investigating the perf
  issue. Retire the gate once ndi-cloud-node#151 is fixed and the
  upload path is fast enough to afford these rows on every push.

- **Size-adaptive job timeout.** The old 300 s ceiling was fine for
  the small rows but would swallow every ready-at-60-90 s finish
  the big rows will produce once the fix lands: the wait would
  time out before reporting the actual wall time. Now
  ``max(300, ceil(N / 100)) s`` -- 300 s for the small rows,
  500 s at 50k, 1000 s at 100k -- a generous ceiling costs nothing
  on the happy path (the wait returns as soon as the job is ready)
  and lets the row report the real elapsed rather than "timeout".

Also expanded the doc comment on ``targetMemberCount`` to name the
three size regimes (plumbing / rate-matters / >100k) and the issues
each row is regression-guarding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
Was: the test built a spatial shape via ``pickShapeForTarget(N, [16 16 16])``
that would contain ~N chunks if the fixture wrote at [16 16 16], but
its call to ``ndi.fun.doc.lightsheet.fromOMEZarr`` did not forward a
``chunks`` argument. ``makePyramid`` therefore fell through to
``chooseTileShape``'s default ~8 MB per-tile byte budget, which for
uint16 lands at roughly [161 161 161] per chunk. Concrete outcome:

  requested N     actual members
  ------------     --------------
  5                1
  100              1
  500              1
  2000             8
  10000            27
  50000            64
  100000           125

Every row degenerated into a "~125-member series" test, and the
scaling axis this class exists to sweep was silently collapsed to
a constant. The rows still exercised the bug tracked in
#1010 (createSignedURLSetJob rejects a manifest
that getFileDetails accepts), but the N label carried no
information about scope size -- it only labelled cloud budget spent
on a fixture that reproduces the small-N case seven times over.

Fix: forward ``'chunks', chunkShape`` to fromOMEZarr so makePyramid
uses the chunk shape the fixture wrote (which is the same shape
pickShapeForTarget was already sizing the level to). Also add a
regression-guard ``warning()`` that fires when the actual member
count is <25% of the target for N>=100, so if this ever silently
regresses again the run log makes the failure loud rather than
legible only by squinting at the [scaleprobe] rows.

Companion to Waltham-Data-Science/NDI-python#320 and
ndi-cloud-node#151. Once the server-side signing behaviour stabilises,
the huge-N rows will actually produce huge-N scopes and the
performance-regime evidence they were added to capture becomes
meaningful.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
Uncovered by CI on head b0dcce1 (Cloud CI 2): the fixture-count fix
made N=10000 actually upload 10,165 files, and the default 300 s
ceiling on ``waitForAllBulkUploads`` was too tight for the server to
finish extracting them -- the assertion at line 275 fired with
``state=timeout, elapsed=300.3s`` and the row never reached the
createSignedURLSetJob step it was there to observe. That's a
false-negative on the bug this class exists to detect: nothing was
learned about the async job path at N=10000 that run.

Mirrors the size-adaptive timeout already applied to
waitForSignedURLSetJob: ``max(300, ceil(N / 20)) s`` -- 300 s for
N=500/2000, 500 s at N=10000, 2500 s at N=50000, 5000 s at N=100000.
Rate anchored to a rough "20 files/s extract" observation: at N=500,
513 files took ~9 s (well inside 300 s); at N=10000, 10165 files
were still going at 300 s, and 20 files/s would predict ~500 s to
finish (well inside 500 s).

Includes ``timeout=Ns`` in the assertion diagnostic so a future
timeout row prints the ceiling that was applied.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
Cloud CI 2 on head e3d5f51 failed with two waitForAllBulkUploads
timeouts:
  * N=2000  hit 300.3 s / 300 s ceiling
  * N=10000 hit 500.4 s / 500 s ceiling

The same N=2000 row finished in 22 s on b0dcce1 and again in earlier
runs at ~10 s, so the extract time swings by more than an order of
magnitude with server load. Chasing that with an ever-larger ceiling
would still red the PR whenever the server is warm.

The probe's own philosophy, stated at line 369 of the failed-job
branch, is that a job outcome IS the observation the run is here to
make -- the probe does not fail on things the server does; it records
them. Extend the same treatment to the bulk-extract wait: if
waitForAllBulkUploads times out, record a row with
state='bulkTimeout' and errorMessage naming the elapsed time and
ceiling, then return from the parametric case so the next N runs.
The row still surfaces the observation for the bug report; CI stays
green when the extract is slow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
Replace the fixed pause(20) + single-retry pause(10) with a proper
per-file poll of getFileDetails, gating each verification on the file
document actually flipping uploaded=true (which is when the server
returns a downloadUrl).

The old sequence -- upload files serially, pause 20 s, then start
verifying -- collapses when the backend is under load. The file goes
to S3 (client-side PUT succeeds) but the server hasn't committed the
file document yet, so getFileDetails returns HTTP 200 with
{uploaded: false, size: 0, no downloadUrl}. The wrapper treats that
as b=false. The old getFileDetailsWithRetry then paused 10 s and
tried once more; the total 30 s budget was still too short on a
thrashed dev-api, and the test failed at line 178 with the
"missing downloadUrl" diagnostic.

The new getFileDetailsWithRetry polls every 2 s up to 300 s until
b=true or timeout, matching the order of magnitude the sibling
waitForAllBulkUploads uses for the bulk-upload path. Since the
polling helper is called downstream in the per-file verification
loop, the upfront pause(20) is redundant and is removed from both
testSerialUploadPublishDownload and testMixedUploadPublishDownload;
a narrative line names what is now doing the waiting so a failing
run's log still explains the wait shape. Callers keep the same
getFileDetailsWithRetry signature.

testBulkUploadPublishDownload already used the proper
'waitForCompletion', true gate on the bulk-upload path and is
untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w
Records the decisions made so far for the V2 importer (plates and patches are
subjects; excluded data is tagged; the tableOfContents row 0014 typo), the
agreed principles and stage order, the open questions, and the source
inventory taken from the file list and column profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…g, videos as epochs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…proposal; mp4 via NDR imagestack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…et licence, instrument product edge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…tance_of built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…4.0; strain product relation deferred

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…, dataset-level entities

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
- import_V2.m: the stage orchestrator. It runs discover and metadata and
  returns what each built. Nothing is written yet; doImport.m is unchanged.
- import_V2_spec.json: the dataset-level metadata, taken from the eLife
  paper (key resources, methods, funding, author contributions, ORCIDs,
  data availability) and the NDI Cloud licence:
  - 23 organizations, 4 people, 5 awards, 1 publication;
  - 2 web resources, 5 software, 8 products, 13 strains, 4 instruments;
  - 7 studies and the dataset.
  Entries that need checking carry `_verify`.
- ndi.setup.V2.discover: stage 0. It lists source files, skips earlier
  import output (.ndi, .ndi_, archives, OS clutter) and prints its
  denominator.
- ndi.setup.V2.profileTable: describes a table's columns without
  interpreting them.
- ndi.setup.V2.datasetMetadata: turns a spec into V2 documents through
  did2.build and resolves keys to ids. It emits the relations
  (suborganization_of, affiliated_with, issued_by, awarded_to, hosted_by,
  instance_of, has_author with sequence and roles, funded_by, cites,
  stored_at, documented_by, part_of). It REPORTS spec content with no V2
  home instead of dropping it (today: software vendor).
- TestDatasetMetadataHaley + test-import-v2.yml: 133 documents (86
  entities, 47 relations), every edge resolvable, authors ordered with
  roles, OP50-GFP -> OP50 lineage. The job fails if tests were filtered.
  It runs against did-schema PR #78's branch until that merges.

UNVERIFIED: no MATLAB in the authoring environment; test-import-v2.yml is
the first execution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…e renames them x_)

CI run 36749331306: 5 of 6 tests passed. The sixth found that jsondecode
turns `_verify` into `x_verify`, so the maker reported the spec's own
reviewer notes as unrepresented content. Both spellings are now skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…instance_of/awarded_to/roles signed and merged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…r study per day / dataset-wide)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…tabase

ndi.session.dir can open a V2 session (V_eta.sqlite is database-hierarchy
entry 2) but can only create a legacy one: only entry 1 has creation code.
createSession fills that gap without changing ndi.session.dir:

1. it writes .ndi/V_eta.sqlite holding the V2 `session` document (built
   with did2.build) and any `part_of` study relations;
2. it then opens the directory the ordinary way, and checks that the id
   it reads back is the one it wrote.

It refuses to overwrite an existing database of either kind unless told
to.

TestCreateSession covers the round trip (create, reopen, same id and
reference, via the did2 backend), the study relation stored in the
session, and the overwrite guard.

UNVERIFIED: no MATLAB in the authoring environment; test-import-v2.yml is
the first execution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
ndi.session.dir scopes every search to its session id. On a one-argument
open it takes that id from .ndi/unique_reference.txt, or makes up a
provisional one, which found nothing (CI run 36752118828: 'Could not load
the REFERENCE field').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…sessions, naming, createSession

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
claude added 30 commits October 14, 2026 17:57
concentration_0015's 12 assay plates share one acclimation plate and
agree on everything but its seeding time: 12:39 on six, 12:41 on six.
When assay plates disagree only on the seeding time, the acclimation
plate is now seeded at the span's midpoint with its tolerance widened
by half the span (reported once as growthSeedSpread), instead of taking
the first plate's time and reporting a disagreement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
The OD600 cross-check compared the map's values in detection order with
the template list in template order, and flagged 14 Matching plates that
hold exactly the same densities (neighbouring patches swapped). It now
compares the sorted densities. The map stays the source, as in the
analysis (analyzeEncounters.m:254).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
The acclimation plates of foragingSensory experiment 2 left the cold
room on 5 Jan 2024 09:49 by the source, after their worms were picked
onto them on 4 Jan. A named correction sets 4 Jan 09:49, as #70e does
for Mini experiment 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
analyzeGFP_24-04-04.mat is 119 GB and lives on a Google shared drive.
ndi.setup.conv.haley.extractLawnProfiles reads it one image at a time
through the existing HDF5 reader (never touching the ~170 GB of images
in lawnProfiles) and writes only the curves, their distances and
backgroundInfo, which the import reads through 'EcoliProfilesFile'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
The 2,061 metaData images with no TIFF include none of the 1,575
analysed images or their 89 backgrounds: they are .czi acquisitions
never converted. They stay out, and recordingList's report now says
how many missing images were analysed or used as a background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
34 analysed images detected fewer than 12 patches, so their 267
per-patch values were left out. ndi.setup.conv.haley.matchGridPatches
fits the staggered 3 x 4 grid (measured on plate 124) to the detected
centres and keeps a placement only when it is the only consistent one;
ecoliDocuments uses it when the full-image numbering cannot apply.
showPatchMatch draws an image's mask with the numbers given, for
checking by eye.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
#80)

extractTemplates copies analyzeGFP's per-brightfield-image acetate
registration (centre [y x], angle, scale; not the masks) into the small
extract. patchLayout converts every full 12-patch image's patch centres
into the acetate's frame (mm) and reports the median position of each
patch, its spread, the outlier images and a spec line. The angle's sign
convention is chosen by the data. The import's full-image numbering
moves to private/matchFullImage so both use one rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…plate (#80)

The grid fit mislabelled an image with only two rows (Jess, image 13),
so it is replaced by the analysis's own registration: each fluorescence
image's brightfield image has an acetate template (centre, angle,
scale), and patchLayout learned the 12 patch positions in that frame
from every full image (median deviation 0.3 mm, closest positions
5.97 mm apart). The layout is the spec's ecoli_patch_layout;
matchTemplatePatches gives each detected patch the nearest position
within 2 mm, and refuses when two claim one. showPatchMatch draws the
template positions too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
On 2023-11-09 foragingMutants seeded acclimation plates only, so no
OD600Real or CFU was read and their seeding dose was left out. Such a
day now gets one formulation per nominal OD600 (the strain at that
nominal OD600 in the growth diluent, no stock, no CFU), and the dose's
notes say it was not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…ing (#83)

On concentration_0016, plates 161 and 162 are the only plates whose
lawn clip and video are on different cameras; the source is kept and
each of their recordings carries a note (recordingList reports such
plates as cameraSwap, and a recording row's note now reaches its
statement). An unnamed file on a day whose worms are all on listed
plates is reported as not a missing plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…open

- A term_assertion declares no `notes` (only an interaction does), so
  decision #73's exclusion reason and "not filmed" note are now text
  statements beside their tags (`reason for <variable>`).
- ndi.dataset/open_session ran mksqlite('close') after opening a
  session, closing every connection. The legacy backend reopens per
  call; a V2 database keeps its connection, so an ingested session
  opened through its dataset could not be searched ("database not
  open", testDataset since decision #71 made dataset runs ingest).
  It now closes only for legacy datasets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…73)

did-schema now carries `notes` on every statement (moved up from
interaction, 2026-10-14), so the exclusion reason and the "not filmed"
note go on their tags again. With a schema whose statements have no
notes (did-schema main today), they stay text statements beside the
tag, so both CI legs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
did-schema main has neither statement notes nor the `text` value kind,
so the exclusion reason's fallback (a text statement) failed there
("No schema file for class text", the current CI leg on 206a4a5).
With neither, the reason is now reported in `skipped` instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
On the 'current' leg (did-schema main) a statement block is still
subject_statement; the other tests already pass documents through newNames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
did-schema 877bbde makes data_body.format a bound ontology term (Jess,
2026-10-14, lightsheet point 4). Recordings MPEG-4 / TIFF, the E. coli
images TIFF, ingested masks and maps binary. fileFormat writes the media
type on a schema copy whose format is still a string (the CI current
leg); schemaHasField now also returns the field's declaration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
DID-matlab's lightsheet migration (claude/ecstatic-pascal-md91fw, 000aeb8)
turns a v1 lightsheetZarrPyramid into an intensity `observation` (id kept)
and each lightsheetZarrLevel that held bytes into a `sampled_body` (id
kept, owner_id = the observation), with the chunk series renamed
chunk.bin_k -> body_data_{k-1}.

ndi.vintage gains the lightsheet reads, by v1 name, for both vintages:
lightsheetPyramidDocs / lightsheetLevelDocs (find them), lightsheetLevel /
lightsheetLevels / lightsheetPyramid / lightsheetKeys (read them; a v1
document is returned as its own block, untouched), lightsheetChunkName and
seriesMemberBase (a series' first member is NAME_1 on v1, NAME_0 on V_eta;
did.document/seriesMemberBase when DID has it, else the schema vintage).

Routed through it: levelTable, makePyramid's reduce-level chunk reads,
LightsheetZarrManager (rows, reductions, deletion plan, subject names),
and the series member walks in extract_docs_files and list_binary_files.
No ndi.vintage.map row: observation/sampled_body are shared classes.

Tests: TestLightsheetVintage (hand-built, no database),
TestLightsheetMigratedSession (a V2 session holding a migrated pyramid),
TestLightsheetMigrated (the real migrator's output; next leg only), and
the existing v1 lightsheet tests now also run in test-import-v2.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
did-schema 9bedceb adds developed_by (software -> organization) and hosted_by
(dataset -> organization), Jess 2026-10-15. Stage 2 writes them on a schema
that has them (new ndi.setup.V2.schemaHasRelation) and reports the spec facts
as before on one that does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…n superclasses; which() for package functions

CI run 38674988309: did.document refuses a series not in file_list, the
did2 database refuses a class repeated among its own superclasses, and
exist(..., 'file') does not find a package function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…value, source_value}

CI run 38676292511 (next leg): the real migrator's keys carry spacing and
origin as pairs, not as flat source_spacing/source_origin fields. Read
both; the hand-built fixtures now use the real shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
did-schema 033a22d adds distributed_as (strain -> product), Jess 2026-10-15.
entityDocuments wrote a merged-schema strain's product_id as derived_from,
saying the strain was made from its listing; it now writes distributed_as,
and derived_from only on a schema without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
On a schema without hosted_by (the CI current leg) storedHosts is empty, and
for-over-unique({}) still ran once on its empty column, indexing past it
(TestDatasetMetadataHaley setup, MATLAB:badsubscript at the relate call).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
Keep the original subject-block read for v1; ndi.v2.blockOf only for a
subject with no subject block (the one-entity-class schema).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
ndi.query('', 'isa', 'subject') drops subjects of non-biological types
with '~exact_string' terms on subject.type.name. The v1 database
searches in SQL, where a negated term still needs the field to exist
(did.database: field_check AND NOT value = ...), so every v1 subject,
none of which has a subject.type, was dropped too. A v1 subject always
was a biological subject, so didsqlite puts back the plain search,
wherever the rewritten term sits in the query. V2 sessions are
did2sqlite and keep the rewrite.

This is what failed TestLightsheetZarrManager's two subject-name tests.
TestV1SubjectSearch covers the bare search, an AND and an OR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
Two decisions of Jess, 2026-10-15 (DID-matlab f363d98 builds the
migrator side):

1. A migrated lightsheet pyramid's observation carries method
   FBbi:00000369 'selective plane illumination fluorescence microscopy'.
   ndi.vintage.lightsheetPyramidDocs now finds migrated pyramids as
   observations with interaction.method.node == that CURIE, one query
   (ndi.v2.fieldQuery, so subject_interaction is asked too); v1 pyramids
   are found as before (isa lightsheetZarrPyramid). The term lives in the
   new ndi.vintage.lightsheetMethod, which replaces
   ndi.vintage.lightsheetBuilderName (deleted: nothing else used it; the
   fixture names the software entity literally). The builder software
   entity is still written; it no longer marks the pyramid.

2. ndi.vintage.lightsheetPyramid reads the label from the observation's
   statement.notes (the part before the migrator's separator, when the
   batch pass did not run), pyramid_name from the store body description
   as before, and pyramid_type is always '' (dropped).

Tests: TestLightsheetMigrated pins the method against the real
migrator's output (replacing the builder-name check), the label after
the pass, and the reader's separator against a no-pass migration;
TestLightsheetMigratedSession adds a decoy observation of the builder
software with no method, which must not be listed; TestLightsheetVintage
reads the label from notes, with and without the separator. Fixtures
carry the method and the label on notes, and a description without
label or type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…lation 0

Jess, 2026-10-15 (DID-matlab jLightsheetKeys): a space or time axis the
source gave no scale is now migrated as regular, origin 0, spacing 1, no
unit, instead of `values` 0..n-1. ndi.vintage.lightsheetKeys already reads
it as voxel size 1, translation 0, unit ''; its header now says so, and
TestLightsheetVintage checks both forms read the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
…rded video

A plate video that is not a recording (mutants_assayPlate0001's video 1,
a failed start with no file, reported at stage 5 as rowWithoutFile) made
every worm on the plate lose all its encounters: the check asked whether
ALL of the plate's videos were recordings. It now asks whether the
worm's TRACK rows are all on recorded videos, and uses only those
videos for the plate's start, frame rate and window. A plate with no
recorded video at all is still skipped. In the 2026-10-14 run this
skipped mutants_worm0001-0004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
reconstructSeriesIngestLocations rebuilt ingest_locations with index =
manifest slot (1-based), i.e. the v1 numbering. DID names an ingested
member sprintf('%s_%d', name, index) (sqlitedb/do_add_doc) and
addFileSeries writes index as the member NUMBER in the document's base,
so for a V_eta document (base 0, DID-schema #73 item 17, DID-matlab
1a6bd9f) every rebuilt entry named the next member up.

The helper takes 'MemberBase' (default 1, v1 unchanged); both callers
pass ndi.vintage.seriesMemberBase(document). Tests add the V_eta case
(members 0,2,4 for slots 1,3,5, same uids) beside an explicit v1 case,
and test-import-v2.yml now runs ReconstructSeriesIngestLocationsTest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf

This branch has not been deployed

No deployments
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.

3 participants