diff --git a/src/did/+did2/+convert/+migrators_j/Contents.m b/src/did/+did2/+convert/+migrators_j/Contents.m index 77e7672f..a247544e 100644 --- a/src/did/+did2/+convert/+migrators_j/Contents.m +++ b/src/did/+did2/+convert/+migrators_j/Contents.m @@ -965,8 +965,12 @@ % Deferred: stimulus_bath migrates to a formulation-valued manipulation in the % NDI layer / the coarse resolveDeferredBaths pass. % stimulus_parameter -% Brainstorm-J migrator: did_v1 stimulus_parameter -- a PASSTHROUGH that -% normalises the template's own untypeable placeholder. +% Brainstorm-J migrator: did_v1 stimulus_parameter -- carried whole as v1 +% (since 2026-10-29, [one folder, final classes only]; see the end of the +% function). Until then it was a PASSTHROUGH that normalised the template's +% own untypeable placeholder; the history below explains that repair, which a +% carried document no longer needs (it is not read against V2). A non-empty +% char or a non-numeric `value` still errors. % stimulus_presentation % Brainstorm-J migrator: did_v1 stimulus_presentation -- the decomposition is % DONE BY THE NDI SECOND PASS; pass 1 is a GUARDED PASSTHROUGH that carries diff --git a/src/did/+did2/+convert/+migrators_j/daqmetadatareader.m b/src/did/+did2/+convert/+migrators_j/daqmetadatareader.m index 21daa370..6069bd36 100644 --- a/src/did/+did2/+convert/+migrators_j/daqmetadatareader.m +++ b/src/did/+did2/+convert/+migrators_j/daqmetadatareader.m @@ -134,6 +134,14 @@ filePattern = jGetChar(blk, 'tab_separated_file_parameter'); if isempty(implClass) && isempty(filePattern) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-129 covers it. + % carry-register: carry-129 + did2.convert.reportNote('carried_as_v1', 'daqmetadatareader', jCarryId(src), ... + '[carry-129] malformed: no reader class and no file parameter, so nothing to declare ([passthrough stays v1])'); bodies = {src}; % see THE GUARD above return; end diff --git a/src/did/+did2/+convert/+migrators_j/daqreader.m b/src/did/+did2/+convert/+migrators_j/daqreader.m index 72e50a28..23e9fdfc 100644 --- a/src/did/+did2/+convert/+migrators_j/daqreader.m +++ b/src/did/+did2/+convert/+migrators_j/daqreader.m @@ -151,6 +151,14 @@ % a body with NEITHER a class name NOR a reader string says nothing at all, % and minting entities from it would be the hollow-document defect. if isempty(implClass) && isempty(readerString) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-130 covers it. + % carry-register: carry-130 + did2.convert.reportNote('carried_as_v1', 'daqreader', jCarryId(src), ... + '[carry-130] malformed: no reader class and no reader string, so nothing to declare ([passthrough stays v1])'); bodies = {src}; return; end diff --git a/src/did/+did2/+convert/+migrators_j/daqsystem.m b/src/did/+did2/+convert/+migrators_j/daqsystem.m index 57689b4c..8ab8a93c 100644 --- a/src/did/+did2/+convert/+migrators_j/daqsystem.m +++ b/src/did/+did2/+convert/+migrators_j/daqsystem.m @@ -313,7 +313,8 @@ % preserved. The passthrough rescues nothing. % % IT IS LEFT AS IT IS, DELIBERATELY, and pinned by - % testTheResidualPassthroughIsAQuarantinePathByConstruction. NDI sets + % testTheResidualPassthroughIsAQuarantinePathByConstruction (since 2026-10-29 + % testTheResidualDaqsystemIsCarriedWholeAsV1). NDI sets % `ndi_daqsystem_class` on every document it writes and gives every real rig % a filenavigator and a daqreader, so this branch is unreachable for real % data; relaxing the tombstone to admit a hollow document would trade a live @@ -325,6 +326,18 @@ % that can carry a document on its own, via the software_id edge, so it has % to be counted as something-to-declare or a class-name-only daqsystem would % fall into a passthrough that is no longer warranted. + % + % CARRIED, NOT PASSED THROUGH, since 2026-10-29: the quarantine described + % above came from validating the passthrough against the `daqsystem` + % tombstone; a carried document is v1 and is not read against V2. + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-131 covers it. + % carry-register: carry-131 + did2.convert.reportNote('carried_as_v1', 'daqsystem', jCarryId(src), ... + '[carry-131] malformed: no daqsystem class, navigator, reader or metadata reader, so nothing to declare ([passthrough stays v1])'); bodies = {src}; return; end diff --git a/src/did/+did2/+convert/+migrators_j/electrode_offset_voltage.m b/src/did/+did2/+convert/+migrators_j/electrode_offset_voltage.m index d9916541..daa0c9fd 100644 --- a/src/did/+did2/+convert/+migrators_j/electrode_offset_voltage.m +++ b/src/did/+did2/+convert/+migrators_j/electrode_offset_voltage.m @@ -121,6 +121,14 @@ offset = finiteScalar(blk, 'offset'); if isempty(offset) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-133 covers it. + % carry-register: carry-133 + did2.convert.reportNote('carried_as_v1', 'electrode_offset_voltage', jCarryId(src), ... + '[carry-133] malformed: electrode_offset_voltage.offset is not one finite number, so nothing to fold ([passthrough stays v1])'); bodies = {src}; % nothing to fold -> carry unchanged (discovery fallback) return; end diff --git a/src/did/+did2/+convert/+migrators_j/filenavigator.m b/src/did/+did2/+convert/+migrators_j/filenavigator.m index f478a5e9..ae26bcab 100644 --- a/src/did/+did2/+convert/+migrators_j/filenavigator.m +++ b/src/did/+did2/+convert/+migrators_j/filenavigator.m @@ -162,6 +162,14 @@ mapList = jFileMatchList(rawField(blk, 'epochprobemap_fileparameters')); if isempty(implClass) && isempty(mapFormat) && isempty(dataList) && isempty(mapList) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-132 covers it. + % carry-register: carry-132 + did2.convert.reportNote('carried_as_v1', 'filenavigator', jCarryId(src), ... + '[carry-132] malformed: no navigator class, file patterns or epoch map, so nothing to declare ([passthrough stays v1])'); bodies = {src}; % nothing to declare -- see THE GUARD above return; end diff --git a/src/did/+did2/+convert/+migrators_j/hartley_calc.m b/src/did/+did2/+convert/+migrators_j/hartley_calc.m index 05f5fadb..cb6c9274 100644 --- a/src/did/+did2/+convert/+migrators_j/hartley_calc.m +++ b/src/did/+did2/+convert/+migrators_j/hartley_calc.m @@ -397,6 +397,19 @@ % document whole as v1 (carry-100 outside the template, carry-056 inside it), % as it does for an unread calculator field dropped = [dropped, unreadFields(preBody)]; +% NO SUBJECT: jCalculation hands such a document back unchanged (a calculation +% requires its subject). [one folder, final classes only] (Jess Haley, +% 2026-10-29, did-schema V_eta_go_forward_class_audit.md): a retire class's V2 +% file is deleted once no v1 document passes through under it, so it is carried +% whole as v1 with a note here ([passthrough stays v1], Jess Haley, 2026-10-24) +% instead of passing through silently. Register row carry-138 covers it. +if isempty(jSubjectOf(preBody, {'element_id', 'subject_id'})) + % carry-register: carry-138 + did2.convert.reportNote('carried_as_v1', 'hartley_calc', sourceId(preBody), ... + '[carry-138] awaiting_home: no element_id or subject_id, so no calculation subject ([passthrough stays v1])'); + bodies = {preBody}; + return; +end bodies = jCalculation(preBody, 'receptive_field_calculation', 'rf', ... 'hartley_calc', 'receptive field', 'ndi.calc.vis.hartley', ... 'ExtraParameters', extra, 'ExtraInputs', inputs, 'DataBody', true, ... diff --git a/src/did/+did2/+convert/+migrators_j/openminds_element.m b/src/did/+did2/+convert/+migrators_j/openminds_element.m index 2fa67d11..8096f9e1 100644 --- a/src/did/+did2/+convert/+migrators_j/openminds_element.m +++ b/src/did/+did2/+convert/+migrators_j/openminds_element.m @@ -20,7 +20,9 @@ % the asserted `variable`; the ontology id + label are the term `value`. It is % a timeless subject_assertion (no interaction, no session anchor). 1 -> 1. % -% If no openMINDS block is present the document is carried unchanged. +% If no openMINDS block is present, or the block states an `openminds_id` or +% a `fields.description` (carry-128), the document is carried whole as v1 +% with a `carried_as_v1` note. arguments preBody (1,1) struct @@ -33,7 +35,17 @@ block = preBody.openminds_element; end if isempty(block) - v2Body = {preBody}; % no openMINDS block: carry unchanged + % No openMINDS block: nothing to assert, so the document is carried whole + % as v1 ([passthrough stays v1], Jess Haley, 2026-10-24) rather than + % passed through silently under the retire class `openminds_element` + % ([one folder, final classes only], Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md: a retire class's V2 file is deleted + % once no v1 document passes through under it). No register row covers a + % missing block yet (a question for the team). + % carry-register: carry-146 + did2.convert.reportNote('carried_as_v1', 'openminds_element', sourceIdOf(preBody), ... + '[carry-146] malformed: no openminds block, so no term to assert ([passthrough stays v1])'); + v2Body = {preBody}; return; end @@ -41,6 +53,26 @@ flds = struct(); if isfield(block, 'fields') && isstruct(block.fields); flds = block.fields; end + +% [openMINDS element leftovers carried] (Decided by Jess Haley, 2026-10-29, +% did-schema V_eta_go_forward_class_audit.md): "a v1 `openminds_element` +% holding an `openminds_id` or a `fields.description` is carried whole as v1, +% as for openMINDS subjects ([openMINDS subject leftovers])". The term +% assertion keeps neither, so a document stating either is carried +% (register row carry-128). +omId = strtrim(jGetChar(block, 'openminds_id')); +omDescription = strtrim(jGetChar(flds, 'description')); +if ~isempty(omId) || ~isempty(omDescription) + held = {}; + if ~isempty(omId); held{end+1} = sprintf('openminds_id "%s"', omId); end + if ~isempty(omDescription); held{end+1} = 'fields.description'; end + % carry-register: carry-128 + did2.convert.reportNote('carried_as_v1', 'openminds_element', sourceIdOf(preBody), ... + sprintf(['[carry-128] awaiting_home: %s (the term assertion keeps neither) ' ... + '([openMINDS element leftovers carried])'], strjoin(held, ' and '))); + v2Body = {preBody}; + return; +end node = firstNonEmpty( ... jGetChar(flds, 'preferredOntologyIdentifier'), ... jGetChar(flds, 'ontologyIdentifier'), ... @@ -90,6 +122,13 @@ if isempty(out); out = 'openminds entity'; end end +function id = sourceIdOf(preBody) +%SOURCEIDOF The v1 document's base.id, or '' (did2.convert.v1read.readBase). +b = did2.convert.v1read.readBase(preBody); +id = b.id; +if ~ischar(id); id = ''; end +end + function s = firstNonEmpty(varargin) s = ''; for k = 1:numel(varargin) diff --git a/src/did/+did2/+convert/+migrators_j/private/jCarryId.m b/src/did/+did2/+convert/+migrators_j/private/jCarryId.m new file mode 100644 index 00000000..d34976c4 --- /dev/null +++ b/src/did/+did2/+convert/+migrators_j/private/jCarryId.m @@ -0,0 +1,15 @@ +function id = jCarryId(body) +%JCARRYID The v1 document's base.id for a `carried_as_v1` note ('' when it +% states none), read with did2.convert.v1read.readBase (a legacy +% `ndi_document` identity too). +% +% ID = jCarryId(BODY). +% +% See also did2.convert.reportNote, did2.convert.v1read.readBase. + +b = did2.convert.v1read.readBase(body); +id = b.id; +if ~ischar(id) + id = ''; +end +end diff --git a/src/did/+did2/+convert/+migrators_j/private/jModelFit.m b/src/did/+did2/+convert/+migrators_j/private/jModelFit.m index 0ef81664..51ec07b1 100644 --- a/src/did/+did2/+convert/+migrators_j/private/jModelFit.m +++ b/src/did/+did2/+convert/+migrators_j/private/jModelFit.m @@ -16,8 +16,10 @@ % independent_variables[] / dependent_variable (the names), goodness {sse, % sse_per_point}, input_field / output_field (fit_data). % -% PASSES THROUGH (returns {PREBODY}, the v1 tombstone validates it) when -% the document has no subject (decided: "no subject -> refused"). A missing +% CARRIES the document whole as v1 (returns {PREBODY} with a +% `carried_as_v1` note; until 2026-10-29 a silent passthrough the v1 +% tombstone validated) when the document has no subject (decided: "no +% subject -> refused"). A missing % operating system no longer passes it through: `operating_system_id` is % OPTIONAL (Jess Haley, 2026-10-21 [calculation-os-optional]), so the edge is % omitted, never invented. @@ -25,6 +27,18 @@ bodies = {preBody}; subjectId = jSubjectOf(preBody, {'element_id', 'subject_id'}); if isempty(subjectId) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once + % no v1 document passes through under it, so this document, refused on + % purpose, is carried whole as v1 with a note ([passthrough stays v1], Jess + % Haley, 2026-10-24) instead of passing through silently. Register rows + % carry-136 (fitcurve) and carry-137 (vmspikefit) cover it. + stamp = did2.convert.v1read.readStamp(preBody); + tag = '[carry-136]'; + if strcmp(stamp.class_name, 'vmspikefit'); tag = '[carry-137]'; end + % carry-register: carry-136 carry-137 + did2.convert.reportNote('carried_as_v1', stamp.class_name, jCarryId(preBody), ... + [tag ' awaiting_home: no element_id or subject_id, so no calculation subject ([passthrough stays v1])']); return; end [software, swId, execEnv] = jSoftwareFromApp(preBody, 'RequireSession', true); diff --git a/src/did/+did2/+convert/+migrators_j/probe_geometry.m b/src/did/+did2/+convert/+migrators_j/probe_geometry.m index 353c2e90..526ef8cc 100644 --- a/src/did/+did2/+convert/+migrators_j/probe_geometry.m +++ b/src/did/+did2/+convert/+migrators_j/probe_geometry.m @@ -201,6 +201,14 @@ end if isempty(obsBodies) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-134 covers it. + % carry-register: carry-134 + did2.convert.reportNote('carried_as_v1', 'probe_geometry', jCarryId(src), ... + '[carry-134] malformed: no per-site axis values, so nothing to fold ([passthrough stays v1])'); bodies = {src}; % nothing to fold -> carry unchanged (discovery fallback) return; end diff --git a/src/did/+did2/+convert/+migrators_j/site2channelmap.m b/src/did/+did2/+convert/+migrators_j/site2channelmap.m index 4e01f69c..b7fe228c 100644 --- a/src/did/+did2/+convert/+migrators_j/site2channelmap.m +++ b/src/did/+did2/+convert/+migrators_j/site2channelmap.m @@ -75,8 +75,17 @@ end [~, collision, unplaced] = jRawAccount(preBody, struct('site2channelmap', {{'map'}}), ... {'^probe_id$', '^probe_geometry_id$'}); -jRawCarry('site2channelmap', preBody, collision, unplaced, 'carry-100'); -bodies = {preBody}; % a passthrough, or the carry of the untouched v1 source +if ~jRawCarry('site2channelmap', preBody, collision, unplaced, 'carry-100') + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-139 covers it. + % carry-register: carry-139 + did2.convert.reportNote('carried_as_v1', 'site2channelmap', jCarryId(preBody), ... + '[carry-139] awaiting_home: site2channelmap has no V2 home (its map needs the probe_geometry join) ([passthrough stays v1])'); +end +bodies = {preBody}; % the carry of the untouched v1 source end % ===================== small helpers ======================================= diff --git a/src/did/+did2/+convert/+migrators_j/stimulus_parameter.m b/src/did/+did2/+convert/+migrators_j/stimulus_parameter.m index b38acb60..b4a221e6 100644 --- a/src/did/+did2/+convert/+migrators_j/stimulus_parameter.m +++ b/src/did/+did2/+convert/+migrators_j/stimulus_parameter.m @@ -1,6 +1,10 @@ function bodies = stimulus_parameter(preBody) -%STIMULUS_PARAMETER Brainstorm-J migrator: did_v1 stimulus_parameter -- a -% PASSTHROUGH that normalises the template's own untypeable placeholder. +%STIMULUS_PARAMETER Brainstorm-J migrator: did_v1 stimulus_parameter -- carried +% whole as v1 (since 2026-10-29, [one folder, final classes only]; see the +% end of the function). Until then it was a PASSTHROUGH that normalised the +% template's own untypeable placeholder; the history below explains that +% repair, which a carried document no longer needs (it is not read against +% V2). A non-empty char or a non-numeric `value` still errors. % % Routed from did2.convert.v1_to_v2 only when TargetVersion == 'V_eta'. % @@ -137,9 +141,10 @@ % empty DOUBLE and validates: `value` is not scalar-constrained). elseif ischar(v) || isstring(v) if isempty(strtrim(char(v))) - % The template's own "unset" placeholder. Absence is the only - % spelling of unset that validates. - preBody.(BLOCK) = rmfield(preBody.(BLOCK), 'value'); + % The template's own "unset" placeholder. It used to be removed + % here so the passthrough validated against the V_eta tombstone; + % the document is now carried whole as v1 (below), which is not + % read against V2, so it is kept as v1 wrote it. else error('did2:convert:stimulusParameterCharValue', ... ['stimulus_parameter.value carries the non-empty char ''%s'' ' ... @@ -155,5 +160,14 @@ end end +% [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema +% V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no +% v1 document passes through under it, so this document, held for the stimulus +% model (#31) and left as v1 on purpose, is carried whole as v1 with a note +% ([passthrough stays v1], Jess Haley, 2026-10-24) instead of passing through +% silently. Register row carry-140 covers it. +% carry-register: carry-140 +did2.convert.reportNote('carried_as_v1', 'stimulus_parameter', jCarryId(preBody), ... + '[carry-140] awaiting_home: stimulus_parameter has no V2 home until the stimulus model ([passthrough stays v1])'); bodies = {preBody}; end diff --git a/src/did/+did2/+convert/+migrators_j/subjectmeasurement.m b/src/did/+did2/+convert/+migrators_j/subjectmeasurement.m index e166aa8b..71d44678 100644 --- a/src/did/+did2/+convert/+migrators_j/subjectmeasurement.m +++ b/src/did/+did2/+convert/+migrators_j/subjectmeasurement.m @@ -150,6 +150,14 @@ % ---- guard 1: no subject => no observation --------------------------------- subjectId = dependencyValue(preBody, 'subject_id'); if isempty(subjectId) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-144 covers it. + % carry-register: carry-144 + did2.convert.reportNote('carried_as_v1', 'subjectmeasurement', jCarryId(preBody), ... + '[carry-144] malformed: no subject_id, so no observation ([passthrough stays v1])'); return; end @@ -157,6 +165,14 @@ % `measurement` is free text and is the whole of what the statement is ABOUT. name = getCharField(blk, 'measurement'); if isempty(strtrim(name)) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-144 covers it. + % carry-register: carry-144 + did2.convert.reportNote('carried_as_v1', 'subjectmeasurement', jCarryId(preBody), ... + '[carry-144] malformed: subjectmeasurement.measurement is empty, so no variable ([passthrough stays v1])'); return; end @@ -164,6 +180,14 @@ value = []; if isfield(blk, 'value'); value = blk.value; end if ~(isnumeric(value) && isscalar(value) && isfinite(value)) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-144 covers it. + % carry-register: carry-144 + did2.convert.reportNote('carried_as_v1', 'subjectmeasurement', jCarryId(preBody), ... + '[carry-144] malformed: subjectmeasurement.value is not one finite number ([passthrough stays v1])'); return; end @@ -190,6 +214,18 @@ % ---- guard 4: no honest leaf => carry the document through ------------------ if isempty(folded) + if did2.convert.reportNote('pending', 'carried_as_v1') == 0 + % jMeasurementFold left no note of its own (it writes one, carry-059, + % for a second value): it found no quantity leaf for the name + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-145 covers it. + % carry-register: carry-145 + did2.convert.reportNote('carried_as_v1', 'subjectmeasurement', jCarryId(preBody), ... + '[carry-145] awaiting_home: no quantity kind is named by subjectmeasurement.measurement ([passthrough stays v1])'); + end return; end bodies = folded; diff --git a/src/did/+did2/+convert/+migrators_j/syncgraph.m b/src/did/+did2/+convert/+migrators_j/syncgraph.m index 6c941c23..b43642c5 100644 --- a/src/did/+did2/+convert/+migrators_j/syncgraph.m +++ b/src/did/+did2/+convert/+migrators_j/syncgraph.m @@ -167,6 +167,14 @@ sessionId = char(preBody.base.session_id); end if isempty(sessionId) || isEmptySessionSentinel(sessionId) + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-135 covers it. + % carry-register: carry-135 + did2.convert.reportNote('carried_as_v1', 'syncgraph', jCarryId(src), ... + '[carry-135] malformed: no session id, so no policy can be anchored ([passthrough stays v1])'); bodies = {src}; % THE GUARD -- see the header return; end diff --git a/src/did/+did2/+convert/+migrators_j/vmneuralresponseresiduals.m b/src/did/+did2/+convert/+migrators_j/vmneuralresponseresiduals.m index 45b4d32b..1c46cdf1 100644 --- a/src/did/+did2/+convert/+migrators_j/vmneuralresponseresiduals.m +++ b/src/did/+did2/+convert/+migrators_j/vmneuralresponseresiduals.m @@ -92,8 +92,17 @@ 'vmneuralresponseresiduals', {{'element_epochid', 'parameters', 'column_labels', ... 'goodness_of_fit', 'total_power', 'residual_power'}}), ... {'^element_id$'}); -jRawCarry('vmneuralresponseresiduals', preBody, collision, unplaced, 'carry-100'); -bodies = {preBody}; % a passthrough, or the carry of the untouched v1 source +if ~jRawCarry('vmneuralresponseresiduals', preBody, collision, unplaced, 'carry-100') + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-141 covers it. + % carry-register: carry-141 + did2.convert.reportNote('carried_as_v1', 'vmneuralresponseresiduals', jCarryId(preBody), ... + '[carry-141] awaiting_home: vmneuralresponseresiduals has no V2 home ([passthrough stays v1])'); +end +bodies = {preBody}; % the carry of the untouched v1 source end % ===================== small helpers ======================================= diff --git a/src/did/+did2/+convert/+migrators_j/vmspikefilteringparameters.m b/src/did/+did2/+convert/+migrators_j/vmspikefilteringparameters.m index c0eb18a5..eb9924ea 100644 --- a/src/did/+did2/+convert/+migrators_j/vmspikefilteringparameters.m +++ b/src/did/+did2/+convert/+migrators_j/vmspikefilteringparameters.m @@ -161,6 +161,14 @@ % ---------------------------------------------------------------- THE GUARD if hasPayload(blk, 'spiketimes') + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-143 covers it. + % carry-register: carry-143 + did2.convert.reportNote('carried_as_v1', 'vmspikefilteringparameters', jCarryId(preBody), ... + '[carry-143] awaiting_home: vmspikefilteringparameters.spiketimes holds a result, which the method parameters do not keep ([passthrough stays v1])'); bodies = {preBody}; return; end diff --git a/src/did/+did2/+convert/+migrators_j/vmspikesummary.m b/src/did/+did2/+convert/+migrators_j/vmspikesummary.m index ba8637d9..d490e66f 100644 --- a/src/did/+did2/+convert/+migrators_j/vmspikesummary.m +++ b/src/did/+did2/+convert/+migrators_j/vmspikesummary.m @@ -103,8 +103,17 @@ 'median_presk_halfwidthmaximum', 'median_postsk_halfwidthmaximum', 'median_max_dvdt', ... 'median_kink_index', 'slope_criterion'}}, 'epochid', {{'epochid'}}), ... {'^element_id$', '^spike_extraction_id$'}); -jRawCarry('vmspikesummary', preBody, collision, unplaced, 'carry-100'); -bodies = {preBody}; % a passthrough, or the carry of the untouched v1 source +if ~jRawCarry('vmspikesummary', preBody, collision, unplaced, 'carry-100') + % [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema + % V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no + % v1 document passes through under it, so this document, left as v1 on purpose, + % is carried whole as v1 with a note ([passthrough stays v1], Jess Haley, + % 2026-10-24) instead of passing through silently. Register row carry-142 covers it. + % carry-register: carry-142 + did2.convert.reportNote('carried_as_v1', 'vmspikesummary', jCarryId(preBody), ... + '[carry-142] awaiting_home: vmspikesummary has no V2 home ([passthrough stays v1])'); +end +bodies = {preBody}; % the carry of the untouched v1 source end % ===================== small helpers ======================================= diff --git a/src/did/+did2/+convert/+roundtrip/openminds_element.m b/src/did/+did2/+convert/+roundtrip/openminds_element.m index 5cdb375a..6450a716 100644 --- a/src/did/+did2/+convert/+roundtrip/openminds_element.m +++ b/src/did/+did2/+convert/+roundtrip/openminds_element.m @@ -5,10 +5,12 @@ % [V1, SPEC] = did2.convert.roundtrip.openminds_element(BODIES). BODIES is % the one `assertion` about the element (its `element_id` edge becomes the % assertion's entity_id); private/openmindsInverse says what is rebuilt and -% what is not. Unlike openminds_subject, the migrator converts EVERY -% openminds_element, whether or not it states an `openminds_id`, and writes -% the id nowhere; no decision covers that id or the other openMINDS fields -% for an element, so the round trip reports each one v1 states. +% what is not. A document stating an `openminds_id` or a +% `fields.description` is carried whole as v1 by the migrator (carry-128, +% [openMINDS element leftovers carried], Jess Haley, 2026-10-29), stamped +% did_v1, and never reaches this inverse (did2.convert.roundtrip.check +% compares the carry itself). The other openMINDS fields v1 states (a +% definition, a synonym, ...) are reported. % % See also did2.convert.roundtrip.check, % did2.convert.migrators_j.openminds_element. diff --git a/src/did/+did2/+convert/+roundtrip/openminds_subject.m b/src/did/+did2/+convert/+roundtrip/openminds_subject.m index d78c3d34..572a1080 100644 --- a/src/did/+did2/+convert/+roundtrip/openminds_subject.m +++ b/src/did/+did2/+convert/+roundtrip/openminds_subject.m @@ -8,7 +8,10 @@ % what is not. `matlab_type` is compared as notation ([openMINDS subject % leftovers], Jess Haley, 2026-10-27: "`matlab_type` is notation"): any % openMINDS MATLAB class name ending in the rebuilt type's name states the -% same type. A document the migrator carries (an `openminds_id` that does +% same type. A Species' `definition` and `synonym` are accepted +% differences ([species text is the openMINDS equivalent], Jess Haley, +% 2026-10-29): compared by spec.same, they are reported as `equivalent`, +% not lost. A document the migrator carries (an `openminds_id` that does % not rebuild, a lab-made strain) is stamped did_v1 and never reaches this % inverse (did2.convert.roundtrip.check compares the carry itself). @@ -43,7 +46,28 @@ 'fn', @(a, b) idRebuilds(node, a), 'why', ['the id is rebuilt from the ' ... 'term through the registry''s `equivalent` list ([openMINDS subject ' ... 'leftovers], [term equivalents recorded], Jess Haley, 2026-10-27)']); + if endsWith(v1.openminds.openminds_type, '/Species') + % [species text is the openMINDS equivalent] (Decided by Jess Haley, + % 2026-10-29, did-schema V_eta_go_forward_class_audit.md): "a v1 + % openMINDS subject's species `definition` and `synonym` are not stored + % in V2: the term registry records the openMINDS species instance as + % the term's equivalent ([term equivalents recorded]), and the round + % trip reports the v1 text as an accepted difference". So whatever text + % v1 states there is accepted (reported under `equivalent`, never lost). + why = ['a species'' definition and synonym are openMINDS''s text for the ' ... + 'species instance the term registry records as the term''s equivalent ' ... + '([species text is the openMINDS equivalent], Jess Haley, 2026-10-29)']; + for f = {'definition', 'synonym'} + spec.same(end+1) = struct('path', ['openminds.fields.' f{1}], ... + 'fn', @(a, ~) isText(a), 'why', why); + end + end +end end + +function tf = isText(v) +% the v1 value is text (char or one string): the openMINDS text accepted +tf = ischar(v) || (isstring(v) && isscalar(v)); end function tf = idRebuilds(node, v1Id) diff --git a/tests/+did2/+unittest/testMigratorsJ.m b/tests/+did2/+unittest/testMigratorsJ.m index 3b85f8f1..3c4f7ad2 100644 --- a/tests/+did2/+unittest/testMigratorsJ.m +++ b/tests/+did2/+unittest/testMigratorsJ.m @@ -4996,6 +4996,36 @@ function testOpenmindsElementBecomesTermAssertion(testCase) verifyEqual(testCase, a.document_class.class_name, 'assertion'); % #88 end +function testAnOpenmindsElementWithAnIdOrADescriptionIsCarried(testCase) +% [openMINDS element leftovers carried] (Decided by Jess Haley, 2026-10-29, +% did-schema V_eta_go_forward_class_audit.md): "a v1 `openminds_element` +% holding an `openminds_id` or a `fields.description` is carried whole as v1, +% as for openMINDS subjects"; the register row is carry-128. Each alone carries. +body = struct(); +body.document_class = struct('class_name', 'openminds_element', 'class_version', '1.0.0', ... + 'superclasses', struct('class_name', 'base', 'class_version', '1.0.0')); +body.depends_on = struct('name', {'element_id', 'openminds'}, 'value', {'elem_9', ''}); +body.base = struct('id', 'ome_2', 'session_id', 'sess_09', ... + 'name', '', 'datestamp', '2024-06-01T12:00:00.000Z'); +body.openminds = struct('openminds_type', 'https://openminds.om-i.org/types/CellType', ... + 'matlab_type', 'openminds.controlledterms.CellType', ... + 'openminds_id', 'CellType/dc2f5dd6-5cea-477c-a035-ca2dd3e6649d', ... + 'fields', struct('name', 'type III BNST neuron', ... + 'preferredOntologyIdentifier', 'EMPTY:0000073', 'description', '')); +out = runJ(body); +verifyCarriedWholeAsV1(testCase, body, out); +d = out.summary.migration_notes.carried_as_v1.distinct_details{1}; +verifyTrue(testCase, startsWith(d, '[carry-128] '), d); +desc = body; +desc.base.id = 'ome_3'; +desc.openminds.openminds_id = ''; +desc.openminds.fields.description = 'A type of neuron.'; +out = runJ(desc); +verifyCarriedWholeAsV1(testCase, desc, out); +d = out.summary.migration_notes.carried_as_v1.distinct_details{1}; +verifyTrue(testCase, startsWith(d, '[carry-128] '), d); +end + function testOpenmindsStimulusPassesThroughForSecondPass(testCase) % INVERTED, not updated. This test used to assert the document became a % `term_assertion` whose subject_id came from a `stimulus_id` dependency -- and @@ -5925,6 +5955,25 @@ function testFitcurveWithNoSubjectPassesThroughInsteadOfObservingNobody(testCase verifyEqual(testCase, out{1}.fitcurve.fit_equation, 'gaussian'); end +function testASubjectlessFitIsCarriedWholeAsV1WithANote(testCase) +% [one folder, final classes only] (Decided by Jess Haley, 2026-10-29, did-schema +% V_eta_go_forward_class_audit.md): the guard above no longer passes the fit +% through silently under its retire class; through the pipeline it is carried +% whole as v1 with a `carried_as_v1` note ([passthrough stays v1]). No register +% row covers it yet, so the note cites none. +v1 = struct(); +v1.document_class = struct('class_name', 'fitcurve', 'class_version', '1.0.0', ... + 'superclasses', struct('class_name', {'base'}, 'class_version', {'1.0.0'})); +v1.depends_on = struct('name', {'fit_example_data_id'}, 'value', {''}); +v1.base = struct('id', 'fc_4', 'session_id', 'sess_09', 'name', '', ... + 'datestamp', '2024-06-01T12:00:00.000Z'); +v1.fitcurve = struct('fit_equation', 'gaussian', 'fit_sse', 12.5); +out = runJ(v1); +verifyCarriedWholeAsV1(testCase, v1, out); +d = out.summary.migration_notes.carried_as_v1.distinct_details{1}; +verifyTrue(testCase, contains(d, 'no calculation subject'), d); +end + function testFitcurveWithAnEmptyElementIdIsAlsoGuarded(testCase) % The edge being PRESENT but blank is the same defect as the edge being absent % -- and it is the shape the invented-empty-edge pattern produces, so it is diff --git a/tests/+did2/+unittest/testMigratorsJClockAlignment.m b/tests/+did2/+unittest/testMigratorsJClockAlignment.m index f146bb4d..e8947d86 100644 --- a/tests/+did2/+unittest/testMigratorsJClockAlignment.m +++ b/tests/+did2/+unittest/testMigratorsJClockAlignment.m @@ -371,14 +371,21 @@ function testRuleLessGraphEmitsNoConfigurationEdgeAtAll(testCase) did2.unittest.helpers.sessionDocumentId('sessdoc_1', 'sess_0001')); end -function testGraphWithTheEmptySessionSentinelPassesThrough(testCase) +function testGraphWithTheEmptySessionSentinelIsCarried(testCase) +% INVERTED 2026-10-29 from testGraphWithTheEmptySessionSentinelPassesThrough. +% Decided by Jess Haley, 2026-10-29 [one folder, final classes only] (did-schema +% V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no +% v1 document passes through under it, so the guard now carries the document +% whole as v1 with a `carried_as_v1` note ([passthrough stays v1]). % ndi.session.empty_id() is '0000000000000000_0000000000000000' -- "no specific % session". Pointing a REQUIRED edge at a document that by construction does not -% exist is an orphan, not a fact, so the document passes through instead. +% exist is an orphan, not a fact, so the document is kept as v1 instead. out = runJ(syncgraphFixture({'rule_a'}, '0000000000000000_0000000000000000')); verifyEqual(testCase, numel(out.migrated), 1); verifyEqual(testCase, did2.unittest.helpers.classKey(out.migrated{1}), 'syncgraph'); +verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), 'did_v1'); verifyEqual(testCase, out.summary.unconverted_count, 1); +verifyTrue(testCase, isfield(out.summary.migration_notes, 'carried_as_v1'), 'the carry is noted'); end function testPolicyNeverEmitsABlankRequiredSessionEdge(testCase) diff --git a/tests/+did2/+unittest/testMigratorsJDaqConfiguration.m b/tests/+did2/+unittest/testMigratorsJDaqConfiguration.m index 9d504620..199b7c9e 100644 --- a/tests/+did2/+unittest/testMigratorsJDaqConfiguration.m +++ b/tests/+did2/+unittest/testMigratorsJDaqConfiguration.m @@ -87,7 +87,9 @@ % passthrough in the first place. The branch is therefore a quarantine path by % construction, unreachable for real data (NDI writes the class name on every % document), and pinned as such by -% testTheResidualPassthroughIsAQuarantinePathByConstruction. +% testTheResidualPassthroughIsAQuarantinePathByConstruction -- INVERTED +% 2026-10-29 to testTheResidualDaqsystemIsCarriedWholeAsV1: the branch now +% carries the body whole as v1 ([one folder, final classes only]). % % THAT CORRECTION IS WHY THE LINE BELOW MATTERS, and it stayed true for % exactly one commit: nothing here had been executed when it was written, and @@ -437,6 +439,31 @@ function testMetadataReaderWithNothingToDeclarePassesThrough(testCase) verifyEqual(testCase, out.summary.unconverted_count, 1); end +function testHollowReadersAndSystemsAreCarriedWithANote(testCase) +% [one folder, final classes only] (Decided by Jess Haley, 2026-10-29, did-schema +% V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no +% v1 document passes through under it. The nothing-to-declare branches above +% (daqreader, daqmetadatareader, daqsystem) still hand the document back +% unchanged, but now as a carry: stamped did_v1, with a `carried_as_v1` note +% ([passthrough stays v1]), not a silent did_v2 passthrough. +v0 = daqsystemV1('', {}); +v0.depends_on = struct('name', {}, 'id', {}); +bodies = {daqreaderV1(''), daqmetadatareaderV1('', ''), v0}; +for k = 1:numel(bodies) + out = runJ(bodies{k}); + verifyEqual(testCase, numel(out.migrated), 1); + label = char(out.migrated{1}.get('document_class.class_name')); + verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), ... + 'did_v1', [label ': carried']); + verifyTrue(testCase, isfield(out.summary.migration_notes, 'carried_as_v1'), ... + [label ': the carry is noted']); + if isfield(out.summary.migration_notes, 'carried_as_v1') + verifyTrue(testCase, contains(out.summary.migration_notes.carried_as_v1.distinct_details{1}, ... + 'nothing to declare'), label); + end +end +end + % ===================== daqsystem -> acquisition_system ===================== function testDaqsystemClassNameNowFoldsToASoftwareEntity(testCase) @@ -635,8 +662,20 @@ function testDaqsystemClassNameFoldValidatesUnderVEta(testCase) verifyEqual(testCase, classNames(out), {'acquisition_system', 'software'}); end -function testTheResidualPassthroughIsAQuarantinePathByConstruction(testCase) -% INVERTED 2026-08-12, ON ITS FIRST EXECUTION. It was +function testTheResidualDaqsystemIsCarriedWholeAsV1(testCase) +% INVERTED 2026-10-29 from testTheResidualPassthroughIsAQuarantinePathByConstruction, +% which pinned the all-empty daqsystem as a quarantine (1 quarantined, nothing +% surviving). Decided by Jess Haley, 2026-10-29 [one folder, final classes +% only] (did-schema V_eta_go_forward_class_audit.md): a retire class's V2 file +% is deleted once no v1 document passes through under it, so the residual +% branch no longer passes the body through to be validated against the +% `daqsystem` tombstone: it carries it whole as v1 with a `carried_as_v1` +% note ([passthrough stays v1], [generic stage is lossless]). A carried +% document is v1 and is not read against V2, so nothing is quarantined and the +% body survives untouched. That answers the open question the history below +% leaves to the team ("Either it should go, or it should refuse loudly"). +% +% THE EARLIER HISTORY, kept: INVERTED 2026-08-12, ON ITS FIRST EXECUTION. It was % `testDaqsystemTombstoneStillExistsForTheEmptyBody` and asserted % quarantine_count 0 with the body surviving as `daqsystem`. CI says 1 and % nothing survives, and CI is right -- the assertion was written from intent @@ -672,12 +711,18 @@ function testTheResidualPassthroughIsAQuarantinePathByConstruction(testCase) v1 = daqsystemV1('', {}); v1.depends_on = struct('name', {}, 'id', {}); out = runJValidated(v1); -verifyEqual(testCase, out.summary.quarantine_count, 1, ... - ['an all-empty daqsystem is a HOLLOW document: the tombstone requires ' ... - 'two edges and a field it does not have. Quarantine is #37/#38 working, ' ... - 'not a defect -- see this test''s header before changing either side.']); -verifyEmpty(testCase, classNames(out), ... - 'nothing survives, so the passthrough preserves nothing'); +verifyEqual(testCase, out.summary.quarantine_count, 0, ... + 'an all-empty daqsystem is carried whole as v1, never quarantined'); +verifyEqual(testCase, numel(out.migrated), 1, 'one document: the v1 source'); +verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), ... + 'did_v1', 'carried, not passed through as a V2 document'); +verifyEqual(testCase, char(out.migrated{1}.get('document_class.class_name')), 'daqsystem'); +notes = out.summary.migration_notes; +verifyTrue(testCase, isfield(notes, 'carried_as_v1'), 'the carry is noted'); +if isfield(notes, 'carried_as_v1') + verifyTrue(testCase, contains(notes.carried_as_v1.distinct_details{1}, ... + 'nothing to declare'), notes.carried_as_v1.distinct_details{1}); +end end function testTheFoldsLeaveNoEmptyRequiredEdgeAndNoFragment(testCase) diff --git a/tests/+did2/+unittest/testMigratorsJFileNavSoftware.m b/tests/+did2/+unittest/testMigratorsJFileNavSoftware.m index 2b1df016..b1a40e62 100644 --- a/tests/+did2/+unittest/testMigratorsJFileNavSoftware.m +++ b/tests/+did2/+unittest/testMigratorsJFileNavSoftware.m @@ -151,19 +151,25 @@ function testFilenavigatorEmptyListTextDeclaresNoPatterns(testCase) verifyEqual(testCase, blk.epoch_map_format, 'ndi.epoch.epochprobemap_daqsystem'); end -function testFilenavigatorWithNothingToDeclarePassesThrough(testCase) -% THE GUARD (the fitcurve / openminds_stimulus / probe_geometry shape). All four -% source fields empty means an epoch_file_pattern with no patterns, no format and -% no software edge -- a hollow document that validates clean and says nothing. -% The source class keeps its V_eta tombstone, so passing through loses nothing -% and shows up in `unconverted_by_class` where it can be counted. +function testFilenavigatorWithNothingToDeclareIsCarried(testCase) +% INVERTED 2026-10-29 from testFilenavigatorWithNothingToDeclarePassesThrough, +% which relied on the source class keeping its V_eta tombstone. Decided by Jess +% Haley, 2026-10-29 [one folder, final classes only] (did-schema +% V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no +% v1 document passes through under it, so the guard now carries the document +% whole as v1 with a `carried_as_v1` note ([passthrough stays v1]). +% THE GUARD: all four source fields empty means an epoch_file_pattern with no +% patterns, no format and no software edge -- a hollow document. It is still +% counted in `unconverted_by_class` (handed back unchanged). v1 = navBody('', '', ''); v1.filenavigator.ndi_filenavigator_class = ''; out = runJ(v1); verifyEmpty(testCase, out.quarantine); verifyEqual(testCase, numel(out.migrated), 1); verifyEqual(testCase, did2.unittest.helpers.classKey(out.migrated{1}), 'filenavigator'); +verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), 'did_v1'); verifyEqual(testCase, out.summary.unconverted_count, 1); +verifyTrue(testCase, isfield(out.summary.migration_notes, 'carried_as_v1'), 'the carry is noted'); end function testFilenavigatorWithoutImplementationClassOmitsTheEdge(testCase) diff --git a/tests/+did2/+unittest/testRawV1RoundTripIngestedEpochs.m b/tests/+did2/+unittest/testRawV1RoundTripIngestedEpochs.m index 2b605f7e..4fd74cb9 100644 --- a/tests/+did2/+unittest/testRawV1RoundTripIngestedEpochs.m +++ b/tests/+did2/+unittest/testRawV1RoundTripIngestedEpochs.m @@ -25,8 +25,10 @@ % with edges set % No corpus of the four zips holds any of them. % -% All four pass through pass 1 unchanged, as before; each round trips as -% itself. No known findings. +% The two readers pass through pass 1 unchanged, as before (resolveEpochProbemap +% folds their files); vmneuralresponseresiduals and vmspikesummary, which no +% pass reads, are carried whole as v1 since 2026-10-29 ([one folder, final +% classes only], Jess Haley). Each round trips as itself. No known findings. tests = functiontests(localfunctions); end @@ -40,17 +42,35 @@ function testTheSwitchListsTheFamily(testCase) verifyEqual(testCase, r.summary.legacy_ndi_document.bodies_read_raw_v1, size(f, 1)); end -function testFixturesRoundTripAsPassthroughs(testCase) +function testFixturesRoundTripAsPassthroughsOrCarries(testCase) +% INVERTED 2026-10-29 from testFixturesRoundTripAsPassthroughs, which asserted +% all four were silent pass-1 passthroughs stamped did_v2. Decided by Jess +% Haley, 2026-10-29 [one folder, final classes only] (did-schema +% V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no +% v1 document passes through under it. vmneuralresponseresiduals and +% vmspikesummary (no V2 home, read by no later pass) are now carried whole as +% v1 with a note ([passthrough stays v1]); the two readers still pass through +% for resolveEpochProbemap, which folds their files. f = fixtures(); for k = 1:size(f, 1) [label, v1] = f{k, :}; out = runRaw(v1); verifyEmpty(testCase, out.quarantine, label); verifyEqual(testCase, numel(out.passthrough), 1, [label ': a pass-1 passthrough']); - verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), ... - 'did_v2', [label ': a passthrough is not a carry']); + isCarry = any(strcmp(v1.document_class.class_name, ... + {'vmneuralresponseresiduals', 'vmspikesummary'})); + if isCarry + verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), ... + 'did_v1', [label ': carried whole as v1']); + [carried, notes] = did2.unittest.helpers.carryNote({v1}); + verifyTrue(testCase, carried, [label ': carried as the untouched v1 source']); + verifyTrue(testCase, contains(notes, 'has no V2 home'), notes); + else + verifyEqual(testCase, char(out.migrated{1}.get('document_class.schema_version')), ... + 'did_v2', [label ': a passthrough is not a carry']); + end r = did2.convert.roundtrip.check(v1, out.migrated); - verifyFalse(testCase, r.carried, label); + verifyEqual(testCase, r.carried, isCarry, label); did2.unittest.helpers.verifyRoundTrip(testCase, r, label, {}); end end diff --git a/tests/+did2/+unittest/testRawV1RoundTripMeasurement.m b/tests/+did2/+unittest/testRawV1RoundTripMeasurement.m index d45a0ca5..8c235e05 100644 --- a/tests/+did2/+unittest/testRawV1RoundTripMeasurement.m +++ b/tests/+did2/+unittest/testRawV1RoundTripMeasurement.m @@ -27,8 +27,11 @@ % the renamed path it passed through as a V2 `measurement`, a `retire` class, % which NDI's last step stored as v1: the same end state). subjectmeasurement % converts with its datestamp as an absolute time reference. -% stimulus_parameter is kept as it is (its decided target is built by no -% migrator yet). No known findings. +% stimulus_parameter is carried whole as v1 (its decided target is built by +% no migrator yet; INVERTED 2026-10-29 from "kept as it is", a silent +% pass-1 passthrough: [one folder, final classes only], Jess Haley, +% 2026-10-29, deletes a retire class's V2 file once no v1 document passes +% through under it). No known findings. tests = functiontests(localfunctions); end @@ -76,6 +79,30 @@ function testADateIsCarriedNotPassedThrough(testCase) verifyTrue(testCase, contains(notes, 'no V2 statement'), notes); end +function testAStimulusParameterIsCarriedNotPassedThrough(testCase) +% [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema +% V_eta_go_forward_class_audit.md): carried whole as v1 with a note +% ([passthrough stays v1]); no register row covers it yet +[carried, notes] = did2.unittest.helpers.carryNote({stimulusParameter()}); +verifyTrue(testCase, carried); +verifyTrue(testCase, contains(notes, 'stimulus_parameter has no V2 home'), notes); +end + +function testASubjectMeasurementItCannotStateIsCarried(testCase) +% the guards that used to pass the document through silently now carry it +% with a note ([one folder, final classes only], Jess Haley, 2026-10-29) +v1 = subjectAge(); +v1.depends_on = struct('name', 'subject_id', 'value', ''); +[carried, notes] = did2.unittest.helpers.carryNote({v1}); +verifyTrue(testCase, carried, 'no subject'); +verifyTrue(testCase, contains(notes, 'no subject_id'), notes); +v1 = subjectAge(); +v1.subjectmeasurement.measurement = ''; +[carried, notes] = did2.unittest.helpers.carryNote({v1}); +verifyTrue(testCase, carried, 'no name'); +verifyTrue(testCase, contains(notes, 'subjectmeasurement.measurement is empty'), notes); +end + function testTheConvertedDocumentsValidate(testCase) f = fixtures(); for k = 1:size(f, 1) @@ -129,7 +156,7 @@ function testAnUnplacedDocumentFailsTheRoundTrip(testCase) 'measurement, a term', measurementTerm(), 'converted'; ... 'measurement, a date', measurementDate(), 'carried'; ... 'subjectmeasurement, age with a datestamp', subjectAge(), 'converted'; ... - 'stimulus_parameter, temptable2stimulusparameters', stimulusParameter(), 'kept'}; + 'stimulus_parameter, temptable2stimulusparameters', stimulusParameter(), 'carried'}; % INVERTED 2026-10-29 from 'kept' end function d = writerMeasurement(id, node, name, stringValue, numericValue) diff --git a/tests/+did2/+unittest/testRawV1RoundTripProbe.m b/tests/+did2/+unittest/testRawV1RoundTripProbe.m index 7884d822..01f49826 100644 --- a/tests/+did2/+unittest/testRawV1RoundTripProbe.m +++ b/tests/+did2/+unittest/testRawV1RoundTripProbe.m @@ -32,8 +32,13 @@ % ([probe contact geometry carried]). The same document % with no shank_id (the "converted shape", DID-side) % converts and loses nothing. -% site2channelmap a pass-1 passthrough (the NDI second pass joins it to -% its geometry); it round trips as itself. +% site2channelmap carried whole as v1 (no V2 home; nothing joins it to +% its geometry yet). INVERTED 2026-10-29 from "a pass-1 +% passthrough": [one folder, final classes only] (Jess +% Haley, 2026-10-29) deletes a retire class's V2 file once +% no v1 document passes through under it, so the +% migrator now carries it with a note. It round trips as +% itself. % electrode_offset_voltage converts; with a temperature and with the % writer's NaN. Nothing lost. % No writer-shaped document loses a field: there are no known findings for @@ -157,6 +162,15 @@ function testAFieldTheMigratorDoesNotReadCarriesTheDocument(testCase) verifyCarried(testCase, v1, 'site2channelmap.headstage'); end +function testAnOffsetThatIsNotANumberIsCarriedWithANote(testCase) +% [one folder, final classes only] (Jess Haley, 2026-10-29, did-schema +% V_eta_go_forward_class_audit.md): the "nothing to fold" branch used to pass +% the document through silently under the retire class; it is now carried +% whole as v1 with a `carried_as_v1` note ([passthrough stays v1]) +v1 = writerOffset(NaN, 21.5); +verifyCarried(testCase, v1, 'electrode_offset_voltage.offset is not one finite number'); +end + function testTheWriterGeometryCarriesItsShanks(testCase) [carried, notes] = did2.unittest.helpers.carryNote({writerGeometry()}); verifyTrue(testCase, carried); @@ -237,7 +251,7 @@ function testAnUnplacedDocumentFailsTheRoundTrip(testCase) f = { ... 'probe_geometry, fromStruct', writerGeometry(), 'carried'; ... 'probe_geometry, converted shape', convertedGeometry(), 'converted'; ... - 'site2channelmap', writerSiteMap(), 'passthrough'; ... + 'site2channelmap', writerSiteMap(), 'carried'; ... % INVERTED 2026-10-29 from 'passthrough' ([one folder, final classes only]) 'electrode_offset_voltage, with a temperature', writerOffset(0.012, 21.5), 'converted'; ... 'electrode_offset_voltage, temperature not recorded (null)', writerOffset(-0.0031, NaN), 'converted'; ... 'electrode_offset_voltage, temperature not recorded (NaN)', nanTemp, 'converted'}; diff --git a/tests/+did2/+unittest/testRawV1RoundTripSpike.m b/tests/+did2/+unittest/testRawV1RoundTripSpike.m index cc94adf6..5c4bda0a 100644 --- a/tests/+did2/+unittest/testRawV1RoundTripSpike.m +++ b/tests/+did2/+unittest/testRawV1RoundTripSpike.m @@ -135,6 +135,17 @@ function testCarriesCiteTheirRows(testCase) 'neuron_extracellular.waveform_sample_times (no mean_waveform'); end +function testAHartleyWithNoSubjectIsCarriedWithANote(testCase) +% [one folder, final classes only] (Decided by Jess Haley, 2026-10-29, did-schema +% V_eta_go_forward_class_audit.md): a hartley_calc naming no subject used to +% pass through silently (jCalculation hands it back unchanged); it is now +% carried whole as v1 with a `carried_as_v1` note ([passthrough stays v1]) +v1 = hartley(); +v1.depends_on(1).value = ''; +verifyCarriedFor(testCase, {v1, presentationOf(hartley())}, ... + 'no element_id or subject_id, so no calculation subject'); +end + function testAnotherJrclustNameCarries(testCase) % [jrclust default name is notation] (Decided by Jess Haley, 2026-10-27): only % the writer's default 'jrclust.prm' is notation; any other name on the diff --git a/tests/+did2/+unittest/testTemplateLiteralTypeTraps.m b/tests/+did2/+unittest/testTemplateLiteralTypeTraps.m index 490dd271..0bec6e42 100644 --- a/tests/+did2/+unittest/testTemplateLiteralTypeTraps.m +++ b/tests/+did2/+unittest/testTemplateLiteralTypeTraps.m @@ -69,7 +69,9 @@ % --------------------------------------------------------------------- % NOTHING HERE ASSERTS A DISPOSITION % --------------------------------------------------------------------- -% Both classes stay PASSTHROUGHS. `stimulus_parameter` is held for the +% Both classes stay PASSTHROUGHS (a `stimulus_parameter` is carried whole as +% v1 since 2026-10-29, [one folder, final classes only], so its placeholder is +% kept as written). `stimulus_parameter` is held for the % stimulus model (#31) and `image_stack` / `image_stack_parameters` were taken % BACK OUT of _DELETE_PHASE8 precisely so a passthrough has a schema to % validate against. These tests gate the normalisation, not a fold. Since @@ -183,27 +185,32 @@ % ===================== stimulus_parameter.value ============================ -function testStimulusParameterEmptyValuePlaceholderIsDropped(testCase) -% THE REPAIR. The template's `value: ""` is a char in a field NDI's own schema -% types `double`, so validateTypeShape (cache.m:1229 -> :1383) rejects it. There -% is no numeric substitute that invents nothing -- 0 is a recorded measurement, -% NaN is a value the field may reject -- and absence is what "unset" means and -% the only spelling that passes (cache.m:1212-1225). +function testStimulusParameterEmptyValuePlaceholderIsCarriedAsWritten(testCase) +% INVERTED 2026-10-29 from testStimulusParameterEmptyValuePlaceholderIsDropped. +% Decided by Jess Haley, 2026-10-29 [one folder, final classes only] (did-schema +% V_eta_go_forward_class_audit.md): a retire class's V2 file is deleted once no +% v1 document passes through under it, so a stimulus_parameter is now carried +% whole as v1 ([passthrough stays v1]). A carried document is not read against +% V2, so the template's `value: ""` (a char in a field NDI's schema types +% `double`, which validateTypeShape rejected) no longer has to be dropped to +% get through: it is kept as v1 wrote it, never coerced. out = runJ(stimulusParameterBody('')); verifyEmpty(testCase, out.quarantine); d = onlyClass(testCase, out, 'stimulus_parameter'); +verifyEqual(testCase, char(d.get('document_class.schema_version')), 'did_v1'); b = d.get('stimulus_parameter'); -verifyFalse(testCase, isfield(b, 'value'), ... - 'the empty char placeholder must be DROPPED, not coerced'); +verifyTrue(testCase, isfield(b, 'value'), 'the placeholder is kept, not dropped'); +verifyEqual(testCase, b.value, '', 'and not coerced'); % the two string fields are untouched: `string` accepts '' (cache.m:1349-1371) verifyEqual(testCase, b.ontology_name, 'NDIC:12'); verifyEqual(testCase, b.name, 'Command temperature constant'); end function testStimulusParameterPlaceholderDocumentValidates(testCase) -% The assertion that matters: with the placeholder dropped the document -- which -% is otherwise exactly what ndi.document('stimulus_parameter') produces -- gets -% through the validator instead of quarantining on typeMismatch. +% The assertion that matters: the document -- exactly what +% ndi.document('stimulus_parameter') produces -- gets through the validator +% instead of quarantining on typeMismatch. Since 2026-10-29 it is carried whole +% as v1 ([one folder, final classes only]), which is not validated against V2. out = runJValidated(stimulusParameterBody('')); verifyEmpty(testCase, out.quarantine); verifyEqual(testCase, numel(out.migrated), 1); diff --git a/tests/+did2/+unittest/testWholeBatchRoundTrip.m b/tests/+did2/+unittest/testWholeBatchRoundTrip.m index 367681d2..0a1dffc4 100644 --- a/tests/+did2/+unittest/testWholeBatchRoundTrip.m +++ b/tests/+did2/+unittest/testWholeBatchRoundTrip.m @@ -169,10 +169,16 @@ function testAnIngestedManifestRoundTripsThroughItsDecomposition(testCase) verifyFalse(testCase, isfield(r.failures_by_class, 'epochfiles_ingested'), describe(r)); end -function testAnOpenmindsSubjectRoundTrips(testCase) +function testAnOpenmindsSubjectRoundTripsWithTheSpeciesTextAccepted(testCase) +% INVERTED 2026-10-29 from testAnOpenmindsSubjectRoundTrips, which asserted the +% species' definition and synonym were LOST. Decided by Jess Haley, 2026-10-29 +% [species text is the openMINDS equivalent] (did-schema +% V_eta_go_forward_class_audit.md): they are not stored in V2 (the registry +% records the openMINDS species instance as the term's equivalent), and "the +% round trip reports the v1 text as an accepted difference". % JH's shapes (ndi.database.fun.openMINDSobj2ndi_document): a sex states no % definition and round trips clean; a species states openMINDS's definition and -% synonym, which nothing in DID holds, and the round trip reports exactly those +% synonym, and the round trip reports exactly those as accepted (equivalent) sex = openmindsV1('wb_om_sex', 'openminds_subject', 'subject_id', 'BiologicalSex', ... 'hermaphrodite', 'PATO:0001340', '', ''); species = openmindsV1('wb_om_sp', 'openminds_subject', 'subject_id', 'Species', ... @@ -184,10 +190,12 @@ function testAnOpenmindsSubjectRoundTrips(testCase) did2.unittest.helpers.verifyRoundTrip(testCase, r, 'openminds_subject (sex)', {}); out = did2.convert.v1_to_v2({species}, 'Validate', false, 'TargetVersion', 'V_eta'); r = did2.convert.roundtrip.check(species, out.migrated); -verifyEqual(testCase, sort({r.lost.path}), ... - {'openminds.fields.definition', 'openminds.fields.synonym'}); -verifyEmpty(testCase, r.invented); -verifyEmpty(testCase, r.unplaced); +verifyFalse(testCase, r.carried); +did2.unittest.helpers.verifyRoundTrip(testCase, r, 'openminds_subject (species)', {}); +verifyTrue(testCase, all(ismember({'openminds.fields.definition', ... + 'openminds.fields.synonym'}, r.equivalent)), ... + 'the species text is reported as an accepted difference'); +verifyTrue(testCase, r.ok); end function testAnOpenmindsSubjectIdIsReadBackThroughTheEquivalentList(testCase) @@ -213,20 +221,54 @@ function testAnOpenmindsSubjectIdIsReadBackThroughTheEquivalentList(testCase) did2.unittest.helpers.verifyRoundTrip(testCase, r, 'openminds_subject (no id)', {}); end -function testAnOpenmindsElementReportsWhatItDoesNotKeep(testCase) -% Dab's shape: a cell type with an openMINDS id and a description; the -% migrator keeps neither, and the round trip says so +function testAnOpenmindsElementWithAnIdAndADescriptionIsCarried(testCase) +% INVERTED 2026-10-29 from testAnOpenmindsElementReportsWhatItDoesNotKeep, +% which asserted the element was CONVERTED and its openminds_id and description +% LOST. Decided by Jess Haley, 2026-10-29 [openMINDS element leftovers carried] +% (did-schema V_eta_go_forward_class_audit.md): "a v1 `openminds_element` +% holding an `openminds_id` or a `fields.description` is carried whole as v1" +% (carry-128), so the round trip compares the carry and nothing is lost. +% Dab's shape: a cell type with an openMINDS id and a description ct = openmindsV1('wb_om_ct', 'openminds_element', 'element_id', 'CellType', ... 'type III BNST neuron', 'EMPTY:0000073', '', ''); ct.openminds.openminds_id = 'CellType/dc2f5dd6-5cea-477c-a035-ca2dd3e6649d'; ct.openminds.fields.description = 'A type of neuron.'; +% openminds_element is read on the renamed path, so pass 1 hands back the +% renamed body as a passthrough and the pipeline stores the untouched v1 source +% (v1_to_v2.m, "A CARRY IS THE V1 SOURCE"; ndi.migrate.internal. +% carryUnchangedAsV1). Compare what is stored, as the table-row test does. +out = did2.convert.v1_to_v2({ct}, 'Validate', false, 'TargetVersion', 'V_eta'); +verifyTrue(testCase, any(strcmp({out.passthrough.id}, 'wb_om_ct')), ... + 'pass 1 records the element as carried through'); +r = did2.convert.roundtrip.wholeBatch({ct}, out); +verifyEqual(testCase, r.sources_compared, 1); +verifyEqual(testCase, r.sources_ok, 1, describe(r)); +verifyTrue(testCase, isfield(r.stored_as_v1_by_class, 'openminds_element'), ... + 'the element is stored as its v1 source'); +% each half alone carries too +for half = {'id', 'description'} + one = ct; + if strcmp(half{1}, 'id') + one.openminds.fields.description = ''; + else + one.openminds.openminds_id = ''; + end + out = did2.convert.v1_to_v2({one}, 'Validate', false, 'TargetVersion', 'V_eta'); + verifyTrue(testCase, any(strcmp({out.passthrough.id}, 'wb_om_ct')), ... + ['carried for its ' half{1} ' alone']); +end +end + +function testAnOpenmindsElementWithNeitherConverts(testCase) +% the other side of [openMINDS element leftovers carried]: a cell type stating +% neither an openminds_id nor a description still converts to the term +% assertion, and round trips clean +ct = openmindsV1('wb_om_ct2', 'openminds_element', 'element_id', 'CellType', ... + 'type III BNST neuron', 'EMPTY:0000073', '', ''); out = did2.convert.v1_to_v2({ct}, 'Validate', false, 'TargetVersion', 'V_eta'); r = did2.convert.roundtrip.check(ct, out.migrated); verifyFalse(testCase, r.carried); -verifyEqual(testCase, sort({r.lost.path}), ... - {'openminds.fields.description', 'openminds.openminds_id'}); -verifyEmpty(testCase, r.invented); -verifyEmpty(testCase, r.unplaced); +did2.unittest.helpers.verifyRoundTrip(testCase, r, 'openminds_element (converted)', {}); end % ===================== fixtures =========================================