Skip to content

feat(herd): add a user-facing API for external resource references - #877

Draft
ehennestad wants to merge 4 commits into
fix-empty-compound-dataset-writefrom
add-herd-user-api
Draft

feat(herd): add a user-facing API for external resource references#877
ehennestad wants to merge 4 commits into
fix-empty-compound-dataset-writefrom
add-herd-user-api

Conversation

@ehennestad

Copy link
Copy Markdown
Collaborator

Motivation

Problem — A value in an NWB file is often a term that means something specific: a species
name, a brain region, a unit. HERD (HDMF External Resources Data Structure) is the schema type
that records what those terms refer to in an external resource such as an ontology, so the
values are standardized rather than free text. MatNWB generated the HERD class but gave no
way to use it: adding one annotation meant assembling six compound tables by hand and wiring
them together with zero-based row indices, and getting an annotation back out meant reversing
that by hand.

Solution — Add the operations HERD is actually used for. One call records that an object
refers to an external entity, and a small set of lookups reads the annotations back as MATLAB
tables.

What changed

  • nwb.addRef(container, ...) records that an object in the file refers to an external entity.
  • nwb.getExternalResources() returns the file's HERD, creating and attaching one on first use.
    A file has at most one HERD, so an existing one is reused, including after nwbRead.
  • HERD.toTable() flattens the six tables into one table of references, with the internal row
    indices resolved into the values they point at.
  • HERD.getEntity, getKey, getObjectEntities and getObjectType look annotations back up.
  • Displaying a HERD shows how many keys, entities, objects and files it holds, followed by the
    flattened table, instead of the six Data properties.

Referencing an attribute of a container is supported when that attribute is itself a neurodata
type, such as a column of a DynamicTable. Attributes holding plain values need the relative
path support that HDMF has and MatNWB does not implement yet; those raise a clear error. The
sidecar zip form for sharing one HERD across files (to_zip/from_zip in HDMF) is also not
implemented.

Implementation notes

The handwritten behaviour lives in matnwb.neurodata.HERDBase, an abstract base class attached
to the generated HERD class through the customBaseClasses map in file.fillClass, following
the existing DynamicTableBase and AlignedDynamicTableBase pattern.

Row indices are stored zero-based on disk to match HDMF and PyNWB, and are converted at the API
boundary so no public method takes or returns one.

Semantics follow HDMF: keys are scoped to an object rather than shared across the file, so the
same term on two objects is stored as two key rows; an entity keeps the URI it was first stored
with and a differing URI warns; and one key may resolve to several entities. Adding a reference
that is already recorded changes nothing, where HDMF appends a second identical object_keys
row; both files read the same way.

HERDBase declares matlab.mixin.CustomDisplay directly. MATLAB treats a display hook defined
in a class outside that lineage as an ambiguous definition rather than an override, so the
mixin has to sit in it. getFooter is left to MetaClass so its missing-property warning
still fires.

Examples

Recording and reading back an annotation

nwb = NwbFile('session_description', 'demo', 'identifier', 'DEMO', ...
    'session_start_time', '2018-12-02T12:57:27.371444-08:00');
nwb.general_subject = types.core.Subject( ...
    'subject_id', '001', 'species', 'Mus musculus');

nwb.addRef(nwb.general_subject, ...
    Key=nwb.general_subject.species, ...
    EntityId="NCBITaxon:10090", ...
    EntityUri="http://purl.obolibrary.org/obo/NCBITaxon_10090");

disp(nwb.general_external_resources)

Before — neither entry point exists, so the six tables have to be built by hand:

ismethod(nwb, 'addRef')                        0
ismethod(nwb, 'getExternalResources')          0
ismethod(types.hdmf_common.HERD(), 'addRef')   0

and the result displays as its raw properties:

  HERD with properties:

       entities: [1×1 types.hdmf_common.Data]
    entity_keys: [1×1 types.hdmf_common.Data]
          files: [1×1 types.hdmf_common.Data]
           keys: [1×1 types.hdmf_common.Data]
    object_keys: [1×1 types.hdmf_common.Data]
        objects: [1×1 types.hdmf_common.Data]

After

  HERD with 1 key(s), 1 entity(ies), 1 object(s), 1 file(s)
                 file_object_id                                object_id                    object_type    relative_path      field             key                entity_id                             entity_uri
    ________________________________________    ________________________________________    ___________    _____________    __________    ________________    ___________________    __________________________________________________

    {'5c462de1-6b45-4868-9bac-126e232c14bb'}    {'8462f963-37c5-4550-8ca3-d9f5fe71faab'}    {'Subject'}     {0×0 char}      {0×0 char}    {'Mus musculus'}    {'NCBITaxon:10090'}    {'http://purl.obolibrary.org/obo/NCBITaxon_10090'}

Looking up what is annotated on one object

nwb.general_external_resources.getObjectEntities(nwb, nwb.general_subject)

Before — no such method; the objects, object_keys, entity_keys and entities tables
have to be joined by hand on their zero-based indices.

After

         entity_id                             entity_uri
    ___________________    __________________________________________________

    {'NCBITaxon:10090'}    {'http://purl.obolibrary.org/obo/NCBITaxon_10090'}

How to test

workingFolder = tempname; mkdir(workingFolder); cd(workingFolder);

nwb = NwbFile('session_description', 'demo', 'identifier', 'DEMO', ...
    'session_start_time', '2018-12-02T12:57:27.371444-08:00');
nwb.general_subject = types.core.Subject('subject_id', '001', 'species', 'Mus musculus');

% Annotate the subject, and a column of a table
nwb.addRef(nwb.general_subject, Key=nwb.general_subject.species, ...
    EntityId="NCBITaxon:10090", ...
    EntityUri="http://purl.obolibrary.org/obo/NCBITaxon_10090");

electrodes = types.hdmf_common.DynamicTable( ...
    'description', 'electrodes', 'colnames', {'location'}, ...
    'id', types.hdmf_common.ElementIdentifiers('data', int64([0; 1])), ...
    'location', types.hdmf_common.VectorData('description', 'region', 'data', {'VISp'; 'VISp'}));
nwb.scratch.set('electrodes', electrodes);
nwb.addRef(electrodes, Attribute="location", Key="VISp", ...
    EntityId="MBA:385", EntityUri="https://purl.brain-bican.org/ontology/mbao/MBA_385");

disp(nwb.general_external_resources)

% Round trip
nwbExport(nwb, 'herd.nwb');
readFile = nwbRead('herd.nwb', 'ignorecache');
disp(readFile.general_external_resources.toTable())
disp(readFile.general_external_resources.getObjectEntities(readFile, readFile.general_subject))

Unit tests:

nwbtest('Name', 'tests.unit.HERDTest')

Checklist

  • Have you ensured the PR description clearly describes the problem and solutions?
  • Have you checked to ensure that there aren't other open or previously closed Pull Requests for the same change?
  • If this PR fixes an issue, is the first line of the PR description fix #XX where XX is the issue number?

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.36364% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.30%. Comparing base (1319093) to head (834a139).

Files with missing lines Patch % Lines
+matnwb/+neurodata/HERDBase.m 97.31% 7 Missing ⚠️
NwbFile.m 75.00% 3 Missing ⚠️
Additional details and impacted files
@@                         Coverage Diff                          @@
##           fix-empty-compound-dataset-write     #877      +/-   ##
====================================================================
+ Coverage                             95.27%   95.30%   +0.03%     
====================================================================
  Files                                   234      235       +1     
  Lines                                  8318     8592     +274     
====================================================================
+ Hits                                   7925     8189     +264     
- Misses                                  393      403      +10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ehennestad
ehennestad marked this pull request as draft August 27, 2026 07:46
@ehennestad
ehennestad force-pushed the add-herd-user-api branch 2 times, most recently from 1be5f31 to b0d04c6 Compare August 27, 2026 08:53
ehennestad and others added 4 commits August 27, 2026 11:02
Add matnwb.neurodata.HERDBase, a non-generated base class for the
generated HERD type, and register it with the code generator. HERD
holds its associations in six tables that reference each other by
zero-based row index; the new methods hide that layout behind terms,
entities and objects:

  herd.addRef(nwb, nwb.general_subject, Key="Mus musculus", ...
      EntityId="NCBITaxon:10090", ...
      EntityUri="http://purl.obolibrary.org/obo/NCBITaxon_10090")

Lookups are getEntity, getKey, getObjectEntities and getObjectType.
toTable flattens the six tables into one table of references.

Semantics follow HDMF: keys are scoped to an object rather than shared
across the file, an entity keeps the URI it was first stored with, and
one key may resolve to several entities. Unlike HDMF, adding a
reference that is already recorded changes nothing instead of
appending a duplicate object_keys row; both files read the same way.

Referencing an attribute is limited to properties that are themselves
neurodata types, such as a table column. Attributes holding plain
values need relative path support, which is not implemented yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add nwb.addRef(container, ...) and nwb.getExternalResources() as the
primary entry points for HERD: addRef lazily creates and attaches the
file's HERD on first use, matching PyNWB's get_external_resources
semantics (a file has at most one HERD). HERD.addRef remains available
directly for building a HERD before it is attached to a file.

Give HERD a custom display showing the reference-table summary counts
and the flattened table instead of the six internal Data properties,
which are not informative on their own. This requires HERDBase to
declare matlab.mixin.CustomDisplay directly: MATLAB treats a method
defined in an unrelated class as ambiguous rather than as an override
unless the class sits in the same CustomDisplay lineage. getFooter is
left untouched so the existing missing-required-property warning from
MetaClass still fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getObjectTypeName used class(container), which returns the bare
'NwbFile' for the MatNWB-only wrapper class rather than the 'NWBFile'
schema name MetaClass.export actually writes to neurodata_type. This
mismatch was invisible for every other type (their class name already
equals the schema name) but meant an external reference added directly
to the file, via addRef(nwb, nwb, ...), recorded an object_type that
getObjectType("NWBFile") and any PyNWB/HDMF consumer filtering by
neurodata_type would never match, even though the same row's object_id
still resolved correctly.

Also drop a dead isempty/else branch in findKeyForObject: intersect
already returns empty when there is no match, so the branch reassigned
empty to empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asking a file for its external resources attached a HERD whose six tables
were unset, and every one of them is required by the schema. Exporting
that file stopped with a required-property error listing all six, so
nwb.getExternalResources() followed by nwbExport could not be run. PyNWB
writes an empty HERD for the same sequence.

The tables were only filled in by addRef, which left the HERD unwritable
until a reference was added to it. getExternalResources now fills them in
too, so the HERD it hands back can be written as it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant