Skip to content

An MCOS object's numeric values keep their MATLAB class, and a .mat object array keeps each element's class - #128

Merged
ww-mw merged 4 commits into
mainfrom
mcos-values-keep-matlab-classes
Oct 17, 2026
Merged

ww-mw merged 4 commits into
mainfrom
mcos-values-keep-matlab-classes

Conversation

@ww-mw

@ww-mw ww-mw commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Implements the maintainer's 2026-10-16 group E ruling.

This PR covers two theme 2 items from the backlog, one commit each. The ruling for theme 2 says: "follow MATLAB, and measure it first (mw -using Bmain matlab -nodesktop -batch …) where it is unmeasured." The maintainer agreed each item's recommendation ("Agree your suggestions ... Add unit tests to lock down the fix."). Each recommendation is quoted below. Every new test was written first and runs the real reader or session. Each commit includes its own rebuilt dist/, and npm run verify was green at each commit.

Gate

  • Head (9c247d6): rm -rf dist && npm run build leaves the tree clean. npm run verify returns rc 0. Test files: 254 passed / 1 skipped. Tests: 8390 passed / 26 skipped. The typecheck, smoke, pack, leak and browser checks pass.
  • Merged with current main (3decebe, which took Duplicate-name ids, a model's MAT workspace for Usage, a lone config set, and struct-array element names #127 after this branch was cut from 5cd013c): git merge-tree reports no conflict. In the merged tree, the committed dist/ matches a fresh build. Verify there: 256 files / 8491 passed / 26 skipped.

C-033: an MCOS object's non-double numbers keep their class, and a single shows MATLAB's digits (dddd172)

Recommendation: "(a), limited to non-double classes so double output (and the tests pinning it) are untouched, and decided together with the single-precision display limitation." Option (a) is: "Emit the dictionary spelling for non-double classes ... and the logical typed literal for logical vectors. Doubles stay bare."

What was wrong: in a .mat or a model workspace, a Simulink.Parameter whose Value is int32([100 200 300 400]) showed as class double, where both dictionary formats show int32. A logical row also showed as double. single(3.3) showed 3.299999952316284, where both dictionaries show 3.3. The corpus's MATLAB-written twins (param_*, parity_*) show all of these.

Root cause: McosParser.resolveValue passed every numeric scalar and row through bare, and buildMatrixValue stamped _type: 'double' on every column and matrix. The node layer reads a bare number as a double. Separately, MatParser read a single as the double that getFloat32 widens it to.

Fix:

  • McosParser.resolveValue: a real value of any class other than double now gets the binary reader's typed spelling, as a scalar, a row or a Matrix(). A logical row gets the typed logical literal. A double is unchanged. Two readers of resolved values are adjusted to match: functionHandleText reads its marker from the raw cell, and enumerationValue reads a typed int32.Data.
  • MatParser: an array of class single (full, complex or sparse) gives each element in MATLAB's digits (singleDigits). This is gated on the class, not on the miSINGLE storage type, so a double stored as miSINGLE keeps its double value.

MATLAB measurement (R2027a, 3 sessions): a 4,086-pattern float32 sweep and 7 exact ties, in both dictionary formats. MATLAB writes C %.6g when that gives back the same single, otherwise %.9g with ties to even (1000000.125 → 1000000.12), and %.10g for realmax('single'). This is not "the shortest decimal": MATLAB writes single(1/3) as 0.333333343. Over the 4,082 patterns kept in the fixture, the shortest-decimal rule misses 3,520 and this rule misses 0. It also misses 0 of the 127,743 non-zero singles in a customer text dictionary in the corpus. The rule is lossless. An exact-arithmetic fast path gives the same numbers as a string reference on every 31st bit pattern. A million singles take 75 ms, against 400 ms with toPrecision alone.

Tests (68 fail on 5cd013c, each on the old class or digits):

  • typedMcosFixtures.test.ts, 54 failing before, with new MATLAB-made fixtures (make_typed_fixtures.m: typed_objects.mat, typed_ws.slx, typed_mcos.sldd, typed_mcos_binary.sldd, and a truth file). It checks that:

    • every value shows alike in all four venues, with MATLAB's class and size;
    • the .mat's numbers are MATLAB's, bit for bit;
    • the sweep and the ties show MATLAB's own text;
    • a Parameter copied out of the .mat reopens in each dictionary as MATLAB's own entry. Before the fix, the binary Value tag read Class="double">-42.0 where MATLAB wrote Class="int8">-42.

    It also has a guard that keeps each int32 enumeral's number.

  • matSingleDigits.test.ts, 7 failing before. It builds MAT bytes by hand and covers plain, complex and sparse singles, decodeMatStream, ties, and a lossless sweep. Its controls are exact values, Inf/NaN/-0, a double stored as miSINGLE, and doubles.

  • mcosColumnVector.test.ts, 6 failing before. It patches ParamMat.mat's class byte to get int32 1x6, int16 6x1, uint8 1x1, single 2x3, uint64 1x3 and logical 1x6, with double and logical scalars as controls.

  • mcosParser.test.ts, 1 failing before (below).

Existing tests changed, each because it pinned exactly what the ruling removes:

  • mcosColumnVector.test.ts, "control: a logical 1x6 is still the bare row of booleans it was": it now expects the typed logical row, shown as class logical. A bare boolean list is what showed as double.
  • mcosParser.test.ts, "array of logicals resolves as boolean[], not number[] (Dimensions)": it now expects {_type: 'logical', _value: '[0, 0]'}, for the same reason.
  • complexMcosValue.test.ts: a comment only. The case still tests the formatter on a widened double, which a .mat no longer hands over.

Corpus (168 .mat/.slx files, main against this commit): 82 rows change class (param_* int32 and logical, and topMat.mat's uint64 mID). 88 rows change digits (allMidiSeq.mat 74, parity_* 10, param_* 4). Nothing else changes, and no file newly fails to load.

Docs: two DESIGN.md known limitations are removed, because this commit fixes them.

Left out: an empty array of a class other than double still resolves to [], a double 0x0. The binary reader's classed empty carries BINARY_EMPTY_KEY, and what that key means for a value read from a .mat is a separate question. The opaque-node summary (case 2 of modelOpaqueMcosVariable) for an unmodelled class whose Value is a non-double scalar is not touched: no corpus file or fixture reaches it. The binary fixture leaves out the sweep, because binarySaveKeepsUnedited edits every element and ran past its 120 s timeout with it.

F1-47: a mixed-class object array in a .mat keeps each element's class (9c247d6)

Recommendation: "(a). It is the D6/D15 rule applied at the reader where the defect starts (the standing rule is to fix at the root cause), and one shared rule cannot drift between the two readers." Option (a) is: "Move arrayClassOf, heterogeneousBaseOf, componentsBaseOf and CONFIG_COMPONENTS into one shared parser module, and call it from both BinarySlddParser and McosParser, with a per-element _object_class where it differs."

What was wrong: an object array held by an MCOS property, in a .mat or a model workspace, was named after its first element, and no element kept its own class. A config set's Components read as a Simulink.SolverCC array, and a copy pasted into a dictionary wrote all nine components as Simulink.SolverCC. A dexdata.DerivedSignal beside a Simulink.Signal read as two DerivedSignals.

Root cause: resolveValue took _array_class from the first element's class and built every element as {_properties} only. The D6/D15 rules that the binary reader uses were private to BinarySlddParser.

Fix: a new module, src/datamodel/parser/objectArrayClass.ts, holds arrayClassOf, heterogeneousBaseOf, componentsBaseOf and their lists, moved unchanged except for comment wording. BinarySlddParser imports it and behaves as before; objectArrayElementClass.test.ts is unchanged and green. In McosParser.resolveValue:

  • the array is named by componentsBaseOf(owner, propName), or otherwise by arrayClassOf over the elements' classes;
  • each element whose class differs from the array's gets _object_class, placed first;
  • a later element with no class row counts as the first element's class, as in the binary reader.

MATLAB measurement (R2027a): a new fixture (make_configset_fixture.m → configset.mat, configset.truth.json) holds cs (grt.tlc), csErt (ert.tlc) and mixed (Kids = [dexdata.DerivedSignal, Simulink.Signal]). MATLAB's class() is Simulink.ConfigComponent for both config sets' Components and their RTWCC's Components, and Simulink.Signal for mixed.Kids. That is the class the shared rule names in each case.

Tests (written first):

  • objectArrayClass.test.ts (6 tests) does not load on dddd172, because the module does not exist.

  • mcosMixedObjectArray.test.ts: 10 of its 14 tests fail on dddd172, each on the old class. One example: SolverCC where ConfigComponent is expected. It covers:

    • configset.mat against MATLAB's truth;
    • the node layer: <1x2 Simulink.Signal>, with an ObjectNode and a SignalNode under it;
    • each value pasted into a binary dictionary: the <Element Class> written, and what reads back;
    • busArray.mat with its object table re-pointed: two MathWorks classes, a user's class beside one, and a classless later element.

    The other 4 tests are a precondition and controls (a uniform array, and an enumeration Value), and they pass both before and after.

Existing tests changed: none. Three tests that walk every fixture now also open configset.mat, and they pass.

Corpus (168 files, the parent commit against this one): of 401 decoded MCOS variables, exactly 2 change: cs in findVar/dConfig.mat and in models/topMat.mat. The node-tree walk is the same on all 419,476 rows, and no file newly fails to load.

Left out: no binary-dictionary fixture with a ConfigSet was added. A new binary fixture would also be walked by binarySaveKeepsUnedited, which ran past its timeout in C-033. The test pastes into the existing sparse/sparse_binary.sldd instead. A component's Name is not saved in the .mat's MCOS, so names are not asserted. A top-level .mat variable that is itself a mixed-class object array is still refused whole by decodeMcosVariables's class check. That case comes from reading the code and was not measured; no file in the corpus or fixtures has one. It is recorded in DESIGN.md.

Review follow-ups (9e22f8f, 881e8a9)

These are the reviewers' three non-blocking notes, each now locked down by a unit test.

  • Coverage gap: componentsBaseOf is now load-bearing in a test (9e22f8f, test only).
    • Before this, removing the call from McosParser.resolveValue left the whole suite green. On configset.mat every component is in CONFIG_COMPONENTS, so arrayClassOf already named both arrays Simulink.ConfigComponent.
    • mcosMixedObjectArray.test.ts now re-points configset.mat's class table, the way it already does for busArray.mat. SlCovCC.ConfigComp becomes ConfigComp, which is not in the list (the same situation as the corpus's CoderTarget and SSC components). With that change, arrayClassOf alone would name cs's Components Simulink.SolverCC and its RTWCC's Simulink.CodeAppCC.
    • Both arrays still read as Simulink.ConfigComponent. A control checks that the same classes under an NdHolder's Kids still go through the shared rule.
    • With componentsBaseOf(owner, propName) ?? removed from src, 2 of the 4 new tests fail. All 4 pass at 9c247d6.
  • Edge regression: a rank-3 empty logical is typed again (881e8a9).
    • C-033's if (val.length === 0) return [] came before the shape test. So a logical 2x0x2 in an MCOS property became [], which reads as a double 0x0. At base it was {_type: 'logical', _value: 'Matrix(2,0,2)\n[[]; []]'}, shown as <2x0x2 logical>.
    • The early return now applies only where base's own shape test, restored unchanged, also gave []. Every other case is unchanged: the rank-2 empty logical, every numeric empty, and every non-empty value.
    • New test mcosEmptyLogical.test.ts: typed_objects.mat's int16 2x2x2 pInt16Nd, with its dims respelled (same length) and the logical bit set.
    • Results: 2 tests fail at 9c247d6 and pass here; the 4 controls pass at both.
  • Doc (881e8a9). DESIGN.md gets back a one-bullet limitation for what C-033 leaves out: an empty numeric or logical array of a class other than double, inside an MCOS object, reads as a double 0x0. The exception is a logical of rank 3 or more.

Gates at 881e8a9:

  • rm -rf dist && npm run build leaves the tree clean.
  • npm run verify passes (rc 0): 255 files passed / 1 skipped, 8400 tests passed / 26 skipped. That is the 10 new tests on top of 8390.

Head and gate after review: head 881e8a9 (two commits on top of 9c247d6: 9e22f8f test-only, 881e8a9 src + dist + test + DESIGN.md). npm run verify: 8400 passed / 26 skipped. Merged with main 3decebe: 8501 passed / 26 skipped. C-033's left-out classed empty now excludes a logical of rank 3 or more, which keeps its typed shape again.

ww-mw added 4 commits October 16, 2026 20:05
…ows MATLAB's digits

C-033, per the maintainer's group E ruling of 2026-10-16 (theme 2: follow MATLAB, and
measure it first). A Simulink.Parameter whose Value is int32([100 200 300 400]) showed
class double in a .mat and a model workspace, where both dictionaries show int32; a
logical row showed double; and single(3.3) showed 3.299999952316284 where both
dictionaries show 3.3. The corpus twins param_* and parity_* show all of these.

The class. McosParser.resolveValue handed every numeric scalar and row over bare, and
stamped double on every column and matrix. The node layer reads a bare number as a
double. A real value of any class other than double is now the binary dictionary
reader's typed spelling of it: {_type: 'int8', _value: '-42'}, '[100, 200, 300, 400]',
'Matrix(3,1)...'. A logical row is the typed logical literal. A double is unchanged.
Two readers of resolved values follow:
- the function-handle marker is read off the raw cell, since the uint32 word now resolves
  typed;
- an int32 enumeration's `int32.Data` is read from its typed spelling, so each
  enumeral keeps its number.

The digits. MatParser read a single as the double it widens to. A value of class single
now reads each element as MATLAB spells that float32. This was measured on R2027a, in
both dictionary formats, over 4,086 float32 bit patterns and 7 exact ties; the fixture
keeps 4,082 of the patterns and 5 of the ties. The rule is C's
%.6g where that gives the same single, otherwise %.9g with ties to even, and %.10g for
realmax('single'). It is not "the shortest decimal that is the same single": MATLAB
writes single(1/3) as 0.333333343. The shortest-decimal rule differs from MATLAB on
3,520 of the 4,082 patterns. This rule matches all of them, and all 127,743 non-zero
singles in a customer text dictionary in the corpus. It is lossless, because the
number is the same float32. An exact-arithmetic path handles the common range; it
gives the same numbers on every 31st bit pattern, and a million singles take 75 ms,
not 400 ms. A double stored as miSINGLE keeps its double.

Corpus, all 168 .mat/.slx files, main against this: 82 rows change class (param_* int32
and logical, topMat.mat's uint64 mID). 88 rows change digits (allMidiSeq.mat 74,
parity_* 10, param_* 4). Nothing else changes, and no file newly fails to load.

New fixtures, made by MATLAB (test/fixtures/mcos/make_typed_fixtures.m):
typed_objects.mat, typed_ws.slx, typed_mcos.sldd and typed_mcos_binary.sldd, with a
truth file. typedMcosFixtures.test.ts checks four things:
- every value presents alike in all four venues, with MATLAB's class and size;
- the .mat's numbers are MATLAB's, bit for bit;
- the sweep shows MATLAB's own text, element by element;
- a Parameter copied out of the .mat into either dictionary reopens as MATLAB's own
  entry. In the binary dictionary the Value tag is also MATLAB's own.
matSingleDigits.test.ts locks the digits on hand-built MAT bytes: plain, complex,
sparse, through decodeMatStream, ties, and a lossless sweep.

Existing tests changed, each because it pinned exactly what the ruling removes:
- mcosColumnVector.test.ts, "control: a logical 1x6 is still the bare row of booleans
  it was": now the typed logical row, shown as class logical.
- mcosParser.test.ts, "array of logicals resolves as boolean[]...": a logical [0 0]
  Dimensions is now {_type: 'logical', _value: '[0, 0]'}.
- complexMcosValue.test.ts: a comment only. The case still tests the formatter on a
  widened double, which a .mat no longer hands over.
DESIGN.md: two known limitations are removed, because this commit fixes them: "A REAL
array inside an MCOS object is classed double" and "A single shows the double it
widens to".

Not done: an empty array of a class other than double still resolves to [], which
reads as a double 0x0. The binary reader's classed empty carries BINARY_EMPTY_KEY,
whose meaning for a value out of a .mat is a separate question.
…, as a dictionary does

F1-47, per the maintainer's group E ruling of 2026-10-16 (theme 2: follow MATLAB, and
measure it first; recommendation (a)). An object array held by an MCOS object's
property, in a .mat or a model workspace, was named after its first element, and no
element kept a class of its own. A config set's Components read as a Simulink.SolverCC
array, and a copy of the config set pasted into a dictionary wrote all nine components
as Simulink.SolverCC. A dexdata.DerivedSignal beside a Simulink.Signal read and showed
as two DerivedSignals.

The binary dictionary reader already reads such an array as MATLAB's text format names
it (the maintainer's D6 and D15), but its rules were private to BinarySlddParser. They
move to one new module, src/datamodel/parser/objectArrayClass.ts: arrayClassOf,
heterogeneousBaseOf, componentsBaseOf and the lists they use, unchanged but for the
wording of their comments. BinarySlddParser imports them and behaves as before
(objectArrayElementClass.test.ts is unchanged and green). McosParser.resolveValue now
uses the same rules:
- the array is named by componentsBaseOf where its place names a class (a ConfigSet's or
  an RTWCC's Components), and otherwise by arrayClassOf over its elements' classes;
- each element of another class than the array's carries `_object_class`, ahead of its
  `_properties`, as the binary reader writes it;
- a later element whose object names no class row is one of the first element's class,
  as a classless later element is in a binary dictionary.
buildProperties passes the owning object's class and the property's name. A struct field
and a cell element pass neither, as in the binary reader.

Measured on R2027a: MATLAB's class() of each array, read back out of the new fixture, is
the class the rule names. It is Simulink.ConfigComponent for both config sets' Components
and for their RTWCC's Components, and Simulink.Signal for a dexdata.DerivedSignal beside a
Simulink.Signal.

Corpus, all 168 .mat/.slx files, this commit's parent against this commit:
- Of the decoded MCOS variables, exactly two change: the config set cs in
  findVar/dConfig.mat and in models/topMat.mat. Their Components and RTWCC Components are
  now Simulink.ConfigComponent arrays, with each component's own class.
- The node tree walk is unchanged, 0 of 419,476 rows, because a ConfigSet shows no
  Components rows. No file newly fails to load.

New fixture, made by MATLAB (test/fixtures/mcos/make_configset_fixture.m):
configset.mat, 23 KB, and configset.truth.json. It holds cs (grt.tlc), csErt (ert.tlc,
so its RTWCC's Components holds an ERTTargetCC) and mixed, an NdHolder whose Kids is
[dexdata.DerivedSignal, Simulink.Signal].

New tests. On the parent commit, objectArrayClass.test.ts does not load, because the
module does not exist, and 10 of the 14 tests in mcosMixedObjectArray.test.ts fail, each
on the class the old reader gave. The other 4 are preconditions and controls.
- objectArrayClass.test.ts: the rule itself, the binary reader's assertions asked of the
  module.
- mcosMixedObjectArray.test.ts:
  - configset.mat against MATLAB's truth: each array's class, and each element's in order;
  - the node layer: mixed's Kids shows <1x2 Simulink.Signal>, an ObjectNode and a
    SignalNode;
  - each value copied out of the .mat and pasted into a binary dictionary writes each
    element's own Class, and reads back the same;
  - busArray.mat with its object table re-pointed: two MathWorks classes, a user's class
    beside one, and a later element of no class;
  - controls: a uniform array (busArray, ndNested) and an enumeration Value carry no
    element class.

No existing test changed. Three tests that walk every fixture (noObjectObject,
mcosDecodeWarnings, usageScanSummary) now also open configset.mat, and pass.

Not in scope, recorded in DESIGN.md: a .mat variable that is itself a mixed-class object
array is refused whole, by decodeMcosVariables's check that every object is of the
declared class. That is read from the code. It was not measured on a MATLAB-written
file, and no such variable is in the corpus or the fixtures.
…y its elements

Review of F1-47 found that removing componentsBaseOf from McosParser.resolveValue left
the whole suite green. On configset.mat as MATLAB wrote it, every component is one that
CONFIG_COMPONENTS lists, so the shared element rule (arrayClassOf) already names both
config sets' Components, and their RTWCC's, Simulink.ConfigComponent. The call was live
but redundant on every fixture and corpus file. The case it exists for is a component the
list does not hold, such as the CoderTarget.SettingsController and SSC.SimscapeCC of the
corpus binary dictionaries. No test had one.

mcosMixedObjectArray.test.ts now re-points configset.mat's class table one word at a time,
as it already does to busArray.mat:
- SlCovCC.ConfigComp loses its package, so the last component of both config sets is
  ConfigComp, a class the list does not hold and the shared rule counts as not MathWorks'.
  arrayClassOf would name the array Simulink.SolverCC, after its first element.
- cs's Simulink.GRTTargetCC is re-pointed at that class, so its RTWCC's Components is a
  CodeAppCC beside a ConfigComp. arrayClassOf would name the array Simulink.CodeAppCC.
Both arrays still read as Simulink.ConfigComponent, with every element under its own class.
A precondition asserts the tables and what arrayClassOf alone would say. A control
re-points mixed's Kids, a property of an NdHolder, at the same two classes, and that
array is read by the shared rule.

With `componentsBaseOf(owner, propName) ??` removed from src, 2 of the 4 new tests fail
(cs: expected 'Simulink.SolverCC' to be 'Simulink.ConfigComponent'; expected
'Simulink.CodeAppCC' to be 'Simulink.ConfigComponent'). At 9c247d6 all four pass.
…e again

C-033 made McosParser.resolveValue's logical arm return [] for every empty logical,
before it read the shape. Base (5cd013c) gave an empty logical of rank 3 or more the typed
literal, {_type: 'logical', _value: 'Matrix(2,0,2)\n[[]; []]'}, which shows as a <2x0x2
logical>. The [] reads as a double 0x0. No fixture or corpus file reaches it, and no test
pinned it; the review found it by reading the code.

The early return now applies only where base's own shape test also gave a plain []. That
test is the one base used to choose the typed literal, and it is restored unchanged. So
every empty base gave [] still gets []: an empty logical of rank 2, and an empty of every
numeric class. Every empty base typed is typed again. Nothing that is not empty changes.

mcosEmptyLogical.test.ts takes typed_objects.mat's pInt16Nd, MATLAB's int16 2x2x2, and
respells its dims subelement (same length) and sets its logical bit, as
mcosColumnVector.test.ts does to ParamMat.mat. A logical 2x0x2, 0x2x2 and 2x2x0 are typed
and show as logical at their shape. Those 2 tests fail at 9c247d6 ("className": "double",
dims [0, 0]) and pass here. The controls hold at both: an int16 2x0x2 and logical 0x0, 1x0,
0x1, 2x0 and 0x3 are []; a logical 2x2x2 is typed.

DESIGN.md gets back a one-bullet limitation for what C-033 leaves out. An empty numeric or
logical array of a class other than double, in an MCOS object, reads as a double 0x0. The
bullet that covered it went out whole with C-033.
@ww-mw
ww-mw merged commit cb86531 into main Oct 17, 2026
4 checks passed
@ww-mw
ww-mw deleted the mcos-values-keep-matlab-classes branch October 17, 2026 01:23
@ww-mw ww-mw mentioned this pull request Oct 17, 2026
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