From 28a1000b31f9b8515ee2fa7725216d74cd50777c Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 09:05:51 +0200 Subject: [PATCH 1/5] fix: release the type registry when a model version is selected A model version is selected by putting its generated classes on the search path, and MATLAB reads a class definition from the path again only once nothing holds an instance of it. The type registry holds enumeration values of the version it was built for, and nothing released it on a switch, so it kept the previous version's Types enumeration in memory. The instance library, rebuilt on the switch, then resolved the selected version's instances against that enumeration, and every type the two versions do not share came out untyped: 2827 of 17096 on a switch to v3.0, with a warning naming them, six times per CI run. selectModelVersion now releases the registry after the path change, before the pause that lets the reload happen and before the library resolves types. The registry's next use rebuilds it. The library itself pins nothing, as it records type names as strings, and neither does an instance of a type class; with the registry released the enumeration reloads on the switch, the library resolves every instance, and the warning does not fire anywhere in the suite. The assertion that a version switch leaves no instance untyped, which the pin had forced out of the integration test, is back. Co-Authored-By: Claude Fable 5.1 --- .../@MetaTypeRegistry/MetaTypeRegistry.m | 22 ++++++++++++ code/+openminds/selectModelVersion.m | 7 ++++ .../InstanceLibraryIntegrationTest.m | 12 ++++--- .../tests/unitTests/SelectModelVersionTest.m | 34 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tools/tests/unitTests/SelectModelVersionTest.m diff --git a/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m b/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m index 429cd1f55..977b8f81c 100644 --- a/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m +++ b/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m @@ -47,6 +47,28 @@ % getSingleton - Method for retrieving singleton object % Defined in separate file in class folder singletonObject = getSingleton(options) + + function notifyModelVersionChanged() + % notifyModelVersionChanged - Release the registry for a change of model version + % + % Syntax: + % openminds.introspection.internal.MetaTypeRegistry.notifyModelVersionChanged() + % + % The registry holds enumeration values of the model version it + % was built for. MATLAB reads the classes of a newly selected + % version from the path only once nothing holds those of the + % previous one, so a registry left in place across a switch keeps + % the previous version's types in memory. It is deleted here and + % rebuilt by its next use, as getSingleton does when it finds + % none. + + singletonObject = getappdata(0, ... + openminds.introspection.internal.MetaTypeRegistry.SINGLETON_NAME); + + if ~isempty(singletonObject) && isvalid(singletonObject) + delete(singletonObject) + end + end end methods (Access = private) diff --git a/code/+openminds/selectModelVersion.m b/code/+openminds/selectModelVersion.m index 12abad5ca..e7ce8332d 100644 --- a/code/+openminds/selectModelVersion.m +++ b/code/+openminds/selectModelVersion.m @@ -73,6 +73,13 @@ function selectModelVersion(versionNumber) % enumerations and the controlled term base class generated for it. addpath(genpath( fullfile(generatedFolder, versionAsString) )) + % MATLAB reads the classes of the selected version only once nothing + % holds those of the previous one. The type registry holds enumeration + % values of the version it was built for, so it is released here: before + % the pause that lets the reload happen, and before the instance library + % resolves types against the reloaded classes. Its next use rebuilds it. + openminds.introspection.internal.MetaTypeRegistry.notifyModelVersionChanged() + % Add a second pause for changes to take effect. pause(1) % Ad hoc value. Usually at least 0.3 - 0.4 seconds is necessary diff --git a/tools/tests/unitTests/InstanceLibraryIntegrationTest.m b/tools/tests/unitTests/InstanceLibraryIntegrationTest.m index 87f3c5a89..a8f3b8de3 100644 --- a/tools/tests/unitTests/InstanceLibraryIntegrationTest.m +++ b/tools/tests/unitTests/InstanceLibraryIntegrationTest.m @@ -90,15 +90,19 @@ function testLibraryVersionFollowsTheModelVersion(testCase) 'The instance library holds no instances for this version.') % Check that the instances were read from the v3.0 folder, not - % merely labelled v3.0. Whether every instance then resolves to - % a type depends on the model classes having been reloaded, - % which MATLAB cannot do while objects of those classes exist - % in the session, so that is not checked here. + % merely labelled v3.0. readFromVersion = contains(library.InstanceTable.Filepath, ... fullfile(filesep, "v3.0", filesep)); testCase.verifyTrue(all(readFromVersion), ... 'The library must read the instances of the selected version.') + + % The instances must also be typed against that version's + % classes. Selecting a version releases the type registry, so + % the classes reload and every instance resolves to a type. + testCase.verifyEmpty( ... + library.InstanceTable(library.InstanceTable.Type == "", :), ... + 'Every instance of the selected version must resolve to a type.') end function testModelVersionWithoutInstancesIsReported(testCase) diff --git a/tools/tests/unitTests/SelectModelVersionTest.m b/tools/tests/unitTests/SelectModelVersionTest.m new file mode 100644 index 000000000..7f54efb0c --- /dev/null +++ b/tools/tests/unitTests/SelectModelVersionTest.m @@ -0,0 +1,34 @@ +classdef SelectModelVersionTest < matlab.unittest.TestCase +% SelectModelVersionTest - Tests for selecting a model version within a session +% +% A model version is selected by putting its generated classes on the +% search path. MATLAB reads a class definition from the path again only +% once nothing holds an instance of it, so anything the toolbox itself +% keeps across a switch pins the classes of the previous version. + + methods (Test) + function testTheTypeRegistryDoesNotPinThePreviousVersion(testCase) + % The type registry holds enumeration values of the version it was + % built for. Left in place across a switch, it kept the previous + % version's Types enumeration in memory, and the instance library + % then resolved the selected version's instances against it: every + % type the two versions do not share came out untyped. + + % Held, as a session that has looked any type up holds it. + openminds.introspection.internal.MetaTypeRegistry.getSingleton(); + + testCase.applyFixture(ommtest.helper.ModelVersionFixture("v3.0")) + + % BrainAtlas is declared by v3.0 and not by v5.0 or latest, + % where it became AnatomicalAtlas. meta.class reports the + % enumeration in memory, not the one on the path. + membersInMemory = string({ ... + meta.class.fromName("openminds.enum.Types").EnumerationMemberList.Name}); + + testCase.verifyTrue(ismember("BrainAtlas", membersInMemory), ... + 'The Types enumeration in memory must be the selected version''s.') + testCase.verifyFalse(ismember("AnatomicalAtlas", membersInMemory), ... + 'The previous version''s Types enumeration must not stay in memory.') + end + end +end From 30dc4cae0fec2ae191ee51779f4a7790df3c5f69 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 11:43:44 +0200 Subject: [PATCH 2/5] docs: update docstring for MetaTypeRegistry/notifyModelVersionChanged --- .../@MetaTypeRegistry/MetaTypeRegistry.m | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m b/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m index 977b8f81c..154b60d9d 100644 --- a/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m +++ b/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m @@ -54,13 +54,14 @@ function notifyModelVersionChanged() % Syntax: % openminds.introspection.internal.MetaTypeRegistry.notifyModelVersionChanged() % - % The registry holds enumeration values of the model version it - % was built for. MATLAB reads the classes of a newly selected - % version from the path only once nothing holds those of the - % previous one, so a registry left in place across a switch keeps - % the previous version's types in memory. It is deleted here and - % rebuilt by its next use, as getSingleton does when it finds - % none. + % The registry holds (persistent) enumeration values + % (openminds.enum.Types, openminds.enum.Modules) of the model + % version it was built for, and a registry left in place across a + % version switch keeps the previous version's types in memory. + % This method deletes the registry, and will be rebuilt on next + % use. It is called from openminds.selectModelVersion. + % + % See also openminds.selectModelVersion singletonObject = getappdata(0, ... openminds.introspection.internal.MetaTypeRegistry.SINGLETON_NAME); From 2f16a5e3791f1517945e8fa5e39502afa4949def Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 11:34:25 +0200 Subject: [PATCH 3/5] test: select the version the registry is built for rather than assume it The test built the registry for whatever version the session was on and switched to v3.0, checking for the types that tell v3.0 apart from latest. On a session already on v3.0, or on v4.0, which shares those types, both checks held before the switch had done anything, and the test passed without exercising the pin it exists for. It now selects latest first, and says why those two versions: latest declares AnatomicalAtlas, which v3.0 calls BrainAtlas. Should latest ever stop declaring it, the test says so rather than failing on the wrong thing. Co-Authored-By: Claude Fable 5.1 --- .../tests/unitTests/SelectModelVersionTest.m | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tools/tests/unitTests/SelectModelVersionTest.m b/tools/tests/unitTests/SelectModelVersionTest.m index 7f54efb0c..298213a0e 100644 --- a/tools/tests/unitTests/SelectModelVersionTest.m +++ b/tools/tests/unitTests/SelectModelVersionTest.m @@ -13,17 +13,24 @@ function testTheTypeRegistryDoesNotPinThePreviousVersion(testCase) % version's Types enumeration in memory, and the instance library % then resolved the selected version's instances against it: every % type the two versions do not share came out untyped. + % + % The two versions are chosen for what tells them apart: latest + % declares AnatomicalAtlas, which v3.0 calls BrainAtlas. The first + % is selected here rather than assumed, so that the test does not + % pass for nothing in a session that is on v3.0 or v4.0 already. + + import ommtest.helper.ModelVersionFixture + + testCase.applyFixture(ModelVersionFixture("latest")) + testCase.assumeTrue(ismember("AnatomicalAtlas", typesInMemory()), ... + 'latest is expected to declare AnatomicalAtlas.') % Held, as a session that has looked any type up holds it. openminds.introspection.internal.MetaTypeRegistry.getSingleton(); - testCase.applyFixture(ommtest.helper.ModelVersionFixture("v3.0")) + testCase.applyFixture(ModelVersionFixture("v3.0")) - % BrainAtlas is declared by v3.0 and not by v5.0 or latest, - % where it became AnatomicalAtlas. meta.class reports the - % enumeration in memory, not the one on the path. - membersInMemory = string({ ... - meta.class.fromName("openminds.enum.Types").EnumerationMemberList.Name}); + membersInMemory = typesInMemory(); testCase.verifyTrue(ismember("BrainAtlas", membersInMemory), ... 'The Types enumeration in memory must be the selected version''s.') @@ -32,3 +39,9 @@ function testTheTypeRegistryDoesNotPinThePreviousVersion(testCase) end end end + +function names = typesInMemory() +% typesInMemory - The Types enumeration as loaded, not as on the path + names = string({ ... + meta.class.fromName("openminds.enum.Types").EnumerationMemberList.Name}); +end From 33573c567f3954d09e9b89d7f34cd8bed097e08d Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 11:37:43 +0200 Subject: [PATCH 4/5] test: switch between two pinned model versions, not from latest The version the registry was built for was latest, which floats: the type that tells it apart from v3.0 today may be renamed in a later generation, and the guard on that would turn the test into a skip rather than a failure, quietly. The numbered versions are pinned to a schema commit and do not change, so the registry is now built for v5.0, where BrainAtlas became AnatomicalAtlas, and the switch is to v3.0. The precondition on v5.0 is asserted rather than assumed: a pinned enumeration that changed is something to hear about. Co-Authored-By: Claude Fable 5.1 --- tools/tests/unitTests/SelectModelVersionTest.m | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/tests/unitTests/SelectModelVersionTest.m b/tools/tests/unitTests/SelectModelVersionTest.m index 298213a0e..ec42f4fd5 100644 --- a/tools/tests/unitTests/SelectModelVersionTest.m +++ b/tools/tests/unitTests/SelectModelVersionTest.m @@ -14,16 +14,18 @@ function testTheTypeRegistryDoesNotPinThePreviousVersion(testCase) % then resolved the selected version's instances against it: every % type the two versions do not share came out untyped. % - % The two versions are chosen for what tells them apart: latest - % declares AnatomicalAtlas, which v3.0 calls BrainAtlas. The first - % is selected here rather than assumed, so that the test does not - % pass for nothing in a session that is on v3.0 or v4.0 already. + % The two versions are numbered ones, which are pinned to a schema + % commit and do not change, unlike latest. They are chosen for + % what tells them apart: v5.0 declares AnatomicalAtlas, which v3.0 + % calls BrainAtlas. The first is selected here rather than assumed, + % so that the test does not pass for nothing in a session that is + % on v3.0 or v4.0 already. import ommtest.helper.ModelVersionFixture - testCase.applyFixture(ModelVersionFixture("latest")) - testCase.assumeTrue(ismember("AnatomicalAtlas", typesInMemory()), ... - 'latest is expected to declare AnatomicalAtlas.') + testCase.applyFixture(ModelVersionFixture("v5.0")) + testCase.assertTrue(ismember("AnatomicalAtlas", typesInMemory()), ... + 'v5.0 is pinned and declares AnatomicalAtlas; something changed it.') % Held, as a session that has looked any type up holds it. openminds.introspection.internal.MetaTypeRegistry.getSingleton(); From 17efec247e545553935aaf49f6b3ab7ed86b7c44 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 13:50:26 +0200 Subject: [PATCH 5/5] docs: tidy the notifyModelVersionChanged help text Co-Authored-By: Claude Fable 5.1 --- .../+internal/@MetaTypeRegistry/MetaTypeRegistry.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m b/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m index 154b60d9d..17192dbd1 100644 --- a/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m +++ b/code/+openminds/+introspection/+internal/@MetaTypeRegistry/MetaTypeRegistry.m @@ -54,12 +54,12 @@ function notifyModelVersionChanged() % Syntax: % openminds.introspection.internal.MetaTypeRegistry.notifyModelVersionChanged() % - % The registry holds (persistent) enumeration values - % (openminds.enum.Types, openminds.enum.Modules) of the model - % version it was built for, and a registry left in place across a - % version switch keeps the previous version's types in memory. - % This method deletes the registry, and will be rebuilt on next - % use. It is called from openminds.selectModelVersion. + % The registry holds enumeration values (openminds.enum.Types) + % of the model version it was built for, and a registry left in + % place across a version switch keeps the previous version's + % types in memory. This method deletes the registry; it is + % rebuilt on its next use. It is called from + % openminds.selectModelVersion. % % See also openminds.selectModelVersion