Skip to content

MAT values that could not be read warn and are not shown as if they had been; copies and saves refuse what they cannot spell - #153

Merged
ww-mw merged 13 commits into
mainfrom
mat-values-warn-and-refuse-what-they-cannot-hold
Oct 19, 2026
Merged

ww-mw merged 13 commits into
mainfrom
mat-values-warn-and-refuse-what-they-cannot-hold

Conversation

@ww-mw

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

Copy link
Copy Markdown
Member

Nine items from the group H audit, one commit each. Review added three commits on top: two tests (937e2cf, b447807) and a docs row (ddd41ec), plus a merge of main (e63c64c). Each makes a MAT value that could not be read show as not decoded, with a warning, instead of as a made-up value. Each also makes a copy or save refuse a value the writers cannot spell. Each commit has dist regenerated (rm -rf dist && npm run build) and passed npm run verify on its own.

Rulings: theme 1 (the group F ruling of 2026-10-18, group 2): "warn and show what was read; never refuse the file, never crash. Measure MATLAB first where the item says so; a save the writer cannot spell is refused rather than written wrong." Theme 2: follow MATLAB, measured first, and fix at the root cause. F1-10 (group F): a copy or save of a value the reader did not decode is refused.

MATLAB truth is from R2027a 27.1.0.3409468. The probes opened only files the generators wrote under /tmp.

B-04: a host's empty string in a binary dictionary (fc841c0)

  • Ruling: group 1, "refuse what the writers cannot spell", using Writers keep what was read or refuse it: no [object Object], MATLAB's 1x1 cell, and host empties that keep their class #147's measured truth.
  • Wrong: a host {_type:'string', _emptyDims} at an entry, a cell element or a struct field was written as a bare Class="string" Dimension="0*3"/>. MATLAB reads that as "Invalid string returned.". With a trailing 1 it was 0*3*1, a spelling MATLAB was never asked about.
  • Root cause: the empty arm of MatlabVariableNode._serializeArrayXml had no string case.
  • Fix: a rank-2 host string empty is now written with the String bag's envelope (DataNode._stringXml). This is the same output _serializeStringXml and the property writer give for that value, byte for byte.
  • MATLAB: I re-ran probe_host_string_empties_binary.m. Exactly 9 rows changed (the host rows at entry, cell and field). Each now reads back as a string of its size, and the other 41 rows are byte-identical. The truth file is refreshed.
  • Tests: hostStringEmptiesBinary.test.ts. New: a trailing 1 is written as the rank-2 bag (before the fix it failed with Dimension="0*3*1"). A control: the N-D empty and the reader's own empty are written as before. The truth test also checks the host rows.
  • Existing test changed: "...written as before, which MATLAB cannot read (control: left as it is)" pinned exactly the spelling this ruling removes. It now expects the bag's spelling and a string read-back. Before the fix it failed with Invalid string returned..
  • Left out: an N-D host string empty (for example 0x3x2) at these places, and at a property or a Parameter's Value. MATLAB was never asked about it, and R2027a cannot save one in a binary dictionary. The options are to write the N-D envelope, to refuse it (as the text dictionary does), or to measure first. Recommendation: measure, then refuse if MATLAB reads neither spelling back.

B-11: an N-D cell or struct holding a not-decoded member (c275491)

B-12: a struct with a not-decoded member, edited at a sibling and saved (5eba059)

  • Ruling: F1-10 (the behaviour was right but untested).
  • Wrong: nothing tested it. The case is a text dictionary's struct pasted into a binary dictionary.
  • Fix: tests only. There is no src change.
  • Tests: undecodedCopy.test.ts. 9 kinds, each pasted both from the JSON and from a stream. g takes the edit, and the save is refused at pasted.f (not-decoded). A second save is refused the same way, and the edit stands. The control is the same edit in the source dictionaries, which saves. Fail-before used mutants. With the XML arm refusing only at an entry, the save wrote <2x3 double, not decoded> as f, which reopened as f = 0 with g lost. Not refusing in a binary owner fails the stream route. Two over-refusing mutants fail the control.
  • Left out: an UNEDITED struct read from a stream, holding a sparse member with unread values, pasted into binary. It is written as its stream's hex, as read. MATLAB's read-back of that is unmeasured, so whether it should be refused is the maintainer's call.

B-13: the refusal says "sparse", as the row does (c314bf0)

  • Ruling: a plain wording bug: the refusal and the row disagreed.
  • Wrong: the row reads <3x4 sparse double, not decoded>, but the refusal said "a 3x4 double".
  • Fix: _undecodedRefusal now adds sparse when _matVar.isSparse, as MatParser spells the row.
  • Tests: a new case in sparseUnreadValues.test.ts covers logical, complex, single, a field and a cell element, with a full-double control. B-11 and B-12 now check the whole message. 8 failed before the fix, each only on the message (true at that commit; at the head, 11 tests fail without the fix, because B-15's sparse case also reads this wording).
  • Existing tests changed (each pinned the old wording):
    • sparseUnreadValues.test.ts:228, the one the row names: "a 3x4 double" is now "a 3x4 sparse double".
    • undecodedCopy.test.ts:254-255 and sparseFixtures.test.ts:610: the same sparse node, "a 10000000x2 double" is now "a 10000000x2 sparse double".

B-14: a cell slot under an MCOS object that could not be read (7f27ca5)

  • Ruling: theme 1, with the F1-4 precedent.
  • Wrong (reproduced): 4 kinds of unreadable slot in holder.C showed an editable 0, with no warning. A copy of the slot gave 0.
  • Root cause: the cell arm of McosParser.resolveValue maps the slot to undefined.
  • Fix: each slot that resolves to undefined goes into a side table keyed by the cell's bag, so what a copy replays does not change. The node builds that slot as not decoded, with no editor and a refused copy. A slot with no reason of its own gets the F1-4 warning ("holder.C" declares 2 cell(s) and only 1 could be read.). A slot that has a reason is reported per slot, as before.
  • Deviation: the row names the null slot. I fixed every slot that resolves to undefined, because they share the root and the symptom.
  • Tests: mcosShortValues.test.ts, 4 cases plus an identity control (5 tests). 4 failed before the fix, on the warning.
  • Left out: a copy of the whole holder still replays null in the slot. Text writes [ ], and binary refuses it. A struct field or a property under an object has the same root (from reading the code). Each is its own item. A copy of holder.C itself (the cell, not the whole holder) also replays null in the slot. It pastes into both text and binary as {[ ], [3+4i 5+6i]}, and binary does not refuse it. So a slot that now shows <unknown, not decoded> copies as [ ]. main behaves the same, so this is not a regression. Follow-up under F1-10: refuse the copy of a bag that holds an unread slot.

B-15: a MAT numeric array too large to hold no longer fails the open (f460fd3)

  • Ruling: theme 1 (never crash). The group F group 3 ruling leaves lazy reading for later.
  • Wrong: a 1x(2^27+1) uint8 made parseMat and addMatSource throw RangeError: Invalid array length, which failed the whole open.
  • Root cause: readNumericArray pushes one JS number per element, and V8 throws past 2^27 elements (measured on Node 24).
  • Fix: past MAT_ARRAY_ELEMENT_LIMIT = 2 ** 27 (with a test seam, setMatArrayElementLimit), the array is recorded as not decoded, with a reason. The existing machinery then gives the warning, keeps the bytes, withholds the editor and refuses the copy. Sparse row indices and column starts get the same check. Because the limit is 2^27, only arrays that throw today change.
  • Deviation: the sparse ir/jc reads are included because they share the root.
  • Tests: new matElementLimit.test.ts, 11 cases: the real size, at and past a small limit, complex, compressed, sparse, nested, a dictionary's cdata and hex, a model workspace, and the seam set back. All 11 failed before the fix: the real size with the RangeError, the rest because the value was decoded. That holds with the test seam kept. A plain revert removes the seam, and 9 cases are then skipped.
  • Left out: memory can still run out below the limit. That needs a budget or lazy reading (group 3).

B-20: a 1x1 struct's field slot that is not a matrix (dda09c3)

  • Ruling: theme 1 (warn and show what was read).
  • Wrong: in a 1x1 struct, a field slot that is not a matrix was dropped with no warning (fields ['b'], warnings []).
  • Root cause: the not-a-matrix arm of MatParser.parseMatrix covered only struct arrays.
  • Fix: a 1x1 with the tag whole now records the field as an undecoded unknown, with the reason "it is not a matrix (a data element of type T)". collectUndecoded warns, and the copy is refused. A cut tag is still read as a cut.
  • Tests: a new describe in matStructArray.test.ts (rows, warning, refused copies, neighbours) and a cut control in matParser.test.ts. Both new cases failed before the fix, on the missing field.
  • Existing test changed: matParser.test.ts "skips a field slot whose element is not a matrix" pinned fields {}, exactly what the ruling removes (the row names it). It now checks that the field is recorded, not decoded, with one warning. A comment in the struct-array test was updated too; no assertion there changed.
  • Left out: under an MCOS object, a struct property's field that was not decoded shows as an editable 0 (probed on main). This is B-14's twin for struct fields and its own item.

B-21: a real MAT value of a class the reader does not model (b5ae3a0)

B-26: copies of a short .mat struct array or cell (c507b2d)

  • Ruling: the group F group 2 ruling: "measure MATLAB first ...; a save the writer cannot spell is refused".
  • Wrong: a short array copied its declared dims with only the rows it held. A 1x3 cell holding none was Dimension="0*0" in binary.
  • MATLAB: short_array_copy_readback.mjs + probe_short_array_copy_readback.m test 12 shapes. load refuses all 10 damaged files. 21 of 24 copies read back as the padded value core shows (one of the 21 is cell_none in text: it reads back padded, a 1x3 cell of [], where core shows {}. It is refused anyway, because a copy cannot know where it will be pasted, and in binary it is written 0*0, which reads as 0x0). The 3 that do not are struct-holding-none (text and binary), which read with NO fields, and cell-holding-none in binary, which reads as 0x0.
  • Fix: applied shape by shape, as measured. A copy that holds some elements is kept and pinned. One that holds none is refused with a final not-decoded MatBudgetError ("none of whose 3 elements could be read"). The commit message says "It does not read back a rank-2 array holding NONE of them"; that is true only for the struct, and for the cell in binary (the commit cannot be amended, so this body says so). In serializeValue the check comes after the stream arm, because N-D streams were measured to read back padded.
  • Tests: new shortArrayCopy.test.ts, 11 tests: the truth (24 fragments, 3 differing rows pinned), top-level and nested refusals, the XML paste route, an N-D pin and a binary pin, plus the 1x1 test and the empty-cell control from the fix rounds below. 4 failed before the fix, each because the copy or save went through. With B-26's src reverted at the head, 5 tests fail, all in shortArrayCopy.test.ts: those 4 and the 1x1 test. The empty-cell control passes on the revert, as a control should.
  • Left out: a host's own {Cell,[1,3],[]} bag pasted into binary still writes 0*0. The audit's "refuse every short array" was not built, because MATLAB reads those copies as padded.

Fix round 1 (937e2cf, ddd41ec)

  • 937e2cf (B-26, tests only): no test covered a 1x1 .mat cell that holds none of its one element. A mutant that changed _heldNone's cell arm from count > 0 to count > 1 passed the whole suite. Under that mutant the copy is {Cell,[1,1],[]} and the binary XML is Class="cell" Dimension="0*0", the spelling MATLAB was measured to read as a 0x0 cell. A new test in shortArrayCopy.test.ts covers three paths: the top-level cell, the cell as element 2 of a 1x2 cell, and a text dictionary's cdata of it pasted into a binary dictionary. It checks the warning ("c" declares 1 cell(s) and only 0 could be read.), and checks that the copy, the holder's copy and the save are each refused with "a 1x1 cell whose one element could not be read", naming the path. Under the mutant all three refusals fail, because nothing is thrown. Under the mutant the 1x1 cell's binary copy is <P Name="Value" Class="cell" Dimension="0*0"/>. That is byte for byte the measured cell_none_binary fragment, which MATLAB R2027a reads as a 0x0 cell. No src or dist change.
  • ddd41ec (docs): test/parity/matlab/README.md now has a row for probe_short_array_copy_readback.m and short_array_copy_readback.mjs, as it has for every other probe. The row says "so those copies are refused". For the cell, read that as "in both venues": a copy cannot know where it will be pasted.

Fix round 2 (b447807, e63c64c)

  • b447807 (B-26, tests only): no test showed that a .mat cell declaring zero elements still copies. A mutant that changed _heldNone's cell arm from count > 0 to count >= 0 passed the whole suite (10235/10235). Under that mutant every empty cell read from a MAT stream ({}, 1x0, 0x3) has its copy refused as "a 0x0 cell none of whose 0 elements could be read", and so does the copy of any cell or struct that holds one. A new control in shortArrayCopy.test.ts covers:

    • a 0x0, a 1x0 and a 0x3 cell at the top level, as elements 2 to 4 of a 1x4 cell, and as the fields of a 1x1 struct. They open with no warning, and each copies as {_array_type: 'Cell', _dimensions, _elements: []};
    • MATLAB's own three empty cells in test/fixtures/display/dispcat.mat, which copy as their bags too;
    • a text dictionary's cdata of each of those five values, pasted into a binary dictionary and saved. The binary dictionary writes each with its extents (Dimension="0*0", "1*0", "0*3", also inside the cell and the struct), as emptyCellEntry.test.ts pins for MATLAB's spelling, and the saved file reopens as that value.

    Under the mutant this test fails at the first copy, with the refusal above. Run on their own, the dispcat copies and all five binary saves fail under the mutant as well. Over the full suite the mutant fails exactly this one test, both before the merge (10235 others pass) and at e63c64c (10814 others pass). It changes no existing test and no src or dist.

  • e63c64c: merge of main. See Gates.

Follow-ups (outside the rows, and the same on main)

  • Under an MCOS cell, a logical or char slot that MatParser recorded without decoding still shows a made-up editable value, with no warning. resolveValue's logical and char arms return a value, not undefined.

  • A 1x1 .mat struct that declares a field but holds no value for it opens as a fieldless <1x1 struct>, with no warning. Its copy drops the field.

  • readOpaqueHandle (MatParser) still reads a handle's uint32 words with no element limit.

  • A .mat struct array that declares fields but no elements (for example 0x0 or 1x0 with fields a and b) copies as {_array_type: 'Struct', _dimensions, _elements: [{}], _fields: []}. In a binary dictionary it saves as <P Name="Value" Class="struct" Dimension="1*0"/>, without the <Field Name="a"/> that MATLAB writes (emptyStructEntry.test.ts). So its field names are lost. The analogous mutant on _heldNone's struct arm (count > 1 to count >= 0) also passes the whole suite. No test was added for it: a control would pin a copy that drops the fields. Whether to keep that copy or refuse it (theme 1) is for a later item.

  • A 1x3 .mat cell holding none of its elements shows {}, the 0x0 spelling (it warns); one holding 1 of 3 shows {1, [ ], [ ]}.

Gates (at the head, e63c64c)

Implements the maintainer's rulings, as audited in group H (2026-10-19).

ww-mw added 13 commits October 18, 2026 22:22
…a field of a binary dictionary

A host hands an empty string in as {_type: 'string', _emptyDims}. Pasted
into a binary dictionary at an entry's value, a cell's element or a struct
field, the node writer wrote it as the bare tag Class="string"
Dimension="0*3"/>. MATLAB R2027a cannot read that tag at those places
("Invalid string returned."). The empty arm of
MatlabVariableNode._serializeArrayXml now writes the String bag's envelope
for it, at its dims without trailing 1s. That is the envelope
_serializeStringXml writes for the bag itself, and the envelope the
property writer already writes for this same value. The binary reader's own
empty is still written as it was read. An N-D one is left as it was; MATLAB
has not been asked about it.

The existing probe was re-run on core's new saves
(probe_host_string_empties_binary.m, R2027a). Only the nine host rows at
entry, cell and field changed. Each now has the bag's spelling and reads
back as the string it is. The refreshed truth is committed.

hostStringEmptiesBinary.test.ts: one existing test changes because it
pinned the old spelling as a control. A new test covers the trailing-1
case, and a new control covers the N-D and reader-held cases.
…code refuses its copy, as MATLAB measured

A .mat file's (or a model workspace's) 1x1x2 cell or struct whose element 2 is a value the reader
did not decode has a member with nothing to copy, as a rank-2 one has (F1-10). #138 left it copying
as its whole MAT stream, with the member's own bytes in it, where the member is a sparse array whose
values the file does not hold (F1-5). On main the copy is already refused: #132 made _matStream ask
_matWritable after the encode, so serializeValue goes on to the member, which throws
_undecodedRefusal. Nothing tested that. With the check narrowed to strings, the whole suite still
passed, and the copies were again the streams #138's branch wrote, byte for byte. The struct members
the reader did not decode were encoded into the stream as well.

MATLAB was asked first (theme 2). nd_undecoded_copy_readback.mjs writes each 1x1x2 cell and struct as
a .mat file, with a member whose values are absent, of a type with no width, or 2 of 4, plus a sparse
array that decodes as the control. It pastes the stream each one copied as into a text and a binary
dictionary through core's API. probe_nd_undecoded_copy_readback.m (R2027a) compares what `load`
reads of the variable with what the dictionary reads of the pasted entry. 14 of the 16 match. The
struct whose member's values are absent does not: it reads back from the text dictionary as a 1x0
double, and from the binary one not at all ("bad version or endian-key"). Nothing in the bytes tells
that stream from one that reads back, so the copy stays refused, naming the member.

Tests (test/undecodedCopy.test.ts):
- for each of the 6 kinds F1-10 lists and the 3 unread sparse arrays, at element 2 of a 1x1x2 cell and
  struct out of a .mat and a model workspace: the copy is refused with the member's own not-decoded
  refusal (entry, path c{2} or s(2).f, reason), and there is nothing to paste into either dictionary;
- control: the same cell and struct holding a sparse array that decodes copy as their stream and
  paste as what they are;
- the truth: the generator, run on the source, makes the streams MATLAB was asked about; exactly the
  two absent-struct rows differ from `load`; the copy of each case but the control is refused.
With _matStream's check narrowed to strings, 6 of these fail, each because the copy returned a stream
instead of refusing. No source change, so dist is unchanged. No existing test changed.
…its save after a sibling edit

A struct whose field f is a value the reader did not decode has a member with nothing to write but
its placeholder (F1-10). Where such a struct takes an edit at a sibling and is then saved, the save
has to be refused at f, naming it, and nothing written in its place. On main it is, but nothing
tested it.

Where a user can make that edit: a text dictionary keeps a struct it read whole from a MAT stream
read-only (C-012), and a binary one its own hex. So the struct is one a text dictionary held, pasted
into a binary dictionary, which has no spelling for f. It takes two routes there:
- read from the text dictionary's JSON, with f a cdata: the binary writer reaches f through
  StructNode and the XML arm of MatlabVariableNode, which refuses it;
- read whole from a MAT stream: the struct's XML arm writes it through _serializeStructValue, and
  f's serializeValue refuses it. Unedited, a struct like this with a sparse member is written as
  the hex of its stream as read; edited, it is refused at f.
In the dictionary the struct was read from, the same edit saves with f as its own cdata or hex.

Tests (test/undecodedCopy.test.ts):
- for each of the 6 kinds F1-10 lists and the 3 unread sparse arrays, at f of a text dictionary's
  struct {f, g}, from its JSON and from a MAT stream, pasted into a binary dictionary: g takes an
  edit, the save is refused with f's not-decoded refusal (entry "pasted", path pasted.f, final),
  the next save meets the same refusal, and the edit stands;
- control: the same edit in the text and the binary dictionary the struct was read from saves, with
  f as read and g as edited.
Each new case was checked against a mutant of the source that the rest of the suite survives:
- the XML arm refusing only at an entry: the 9 cases fail, each because the save went through.
  It wrote f as the unescaped placeholder text, which reopened as 0, with g lost;
- serializeValue not refusing in a binary dictionary: the same 9 fail, on the stream route;
- an edited struct refusing a member that is its own cdata or hex: the control fails.
No source change, so dist is unchanged. No existing test changed.
… or save is refused

A sparse array whose indices place non-zeros the file holds no values for is shown as
`<3x4 sparse double, not decoded>` (F1-5), and its copy or save is refused (F1-10). The
refusal said "S is a 3x4 double that was not decoded": a full array, which the file does not
hold, so the message and the row disagreed. MatlabVariableNode._undecodedRefusal spelled the
class from the node, which holds no non-zeros for such an array and so does not know it is
sparse. It now adds "sparse" when the variable as read is sparse (MatVariable.isSparse), as
MatParser spells the row. A full array the reader did not decode is named as before.

Tests:
- test/sparseUnreadValues.test.ts: a new case opens a .mat holding an unread sparse logical,
  complex double and single, one as a struct field and one as a cell element, and a full double
  the reader did not decode. Each sparse one's copy is refused naming "a 3x4 sparse <class>",
  as its row reads; the full one is still "a 3x4 double".
- test/undecodedCopy.test.ts: the three unread sparse arrays in the N-D cell and struct copy
  cases and the sibling-edit save cases now check the whole refusal message, not just its ends.
Existing assertions changed, each because it pinned the old wording of a sparse array:
- test/sparseUnreadValues.test.ts, the session case: "a 3x4 double" becomes "a 3x4 sparse double";
- test/undecodedCopy.test.ts and test/sparseFixtures.test.ts, the spTall node built with no
  stream: "a 10000000x2 double" becomes "a 10000000x2 sparse double".
…ecoded, and say so

The MCOS decoder resolved each slot of a cell held by an object, and kept a
slot it could not read as undefined: an element that is not a matrix, one the
MAT reader recorded without decoding (more elements declared than its bytes
hold), or one of a class no arm spells. The node layer built that as the
double 0. So holder.C showed {0, [3+4i 5+6i]}, the 0 editable, with no
warning, and a copy of the slot was 0.

Each such slot is now shown as not decoded, in its own place: the MAT
reader's placeholder for a value it recorded without decoding, its class
and size for any other (<1x1 unknown, not decoded>), and
<unknown, not decoded> for an element that is not a matrix. It has no
editor, and a copy of it is refused as not decoded, naming its path.

It is also reported, in the words the same cell gets at the top of a .mat:
"holder.C{1}" was not decoded: <reason>. for a value the reader recorded
without decoding, and otherwise "holder.C" declares 2 cell(s) and only 1
could be read. A cell that also holds fewer cells than it declares gets one
warning that counts both.

The decoder records these slots in a side table keyed by the cell's value,
so the value a copy replays is unchanged. No MATLAB-written fixture has
such a slot.

dist is rebuilt from src.
…of failing the open

The MAT reader reads a numeric part as one JS array, one number per element.
V8 holds no more than 2^27 elements in one array (measured, Node 24): past
that, push threw "RangeError: Invalid array length", nothing caught it, and
the whole .mat failed to open, the variables beside it included. A 200 MB
uint8 array was enough.

Past MAT_ARRAY_ELEMENT_LIMIT (2^27) elements in one part, the array is now
recorded as the reader's other refusals are: its class and size, a
placeholder value, the reason, a part-unreadable warning naming it, and the
bytes it was read from. Its row has no editor and its copy is refused as
not decoded. The rest of the file reads as it did. The limit counts what
the bytes hold, so an array of 2^27 elements or fewer reads as before.

The same check covers a complex array's imaginary part, and a sparse
array's row indices and column starts, which are read the same way.

A setter for the limit, not in the package barrel, lets tests use a small
limit, as the inflate ceiling's does. test/matElementLimit.test.ts has one
case at the real size and the rest under a limit of 6: .mat variables,
compressed records, struct fields, cell elements, sparse arrays, dictionary
values (an untouched save writes them back as read) and model workspaces.
…coded, and say so

MATLAB writes every field of a struct as a matrix, so a field slot holding
any other data element is a damaged file. In a struct array the reader
already records the whole array as not decoded, with the reason. In a 1x1
it stepped over the slot and left the field out: the struct showed its
other fields only, the file gave no warning, and a copy of the struct had
no such field.

The field is now kept, in its place, as a value of no class or size the
bytes say, shown as <unknown, not decoded> (the spelling the MCOS decoder
already uses for a cell slot that is not a matrix). It has no editor. The
.mat reports it under its MATLAB path, for example:
"s.a" was not decoded: it is not a matrix (a data element of type 9).
It keeps no bytes, because there is no matrix to write it from. So its
copy, and the copy of the struct holding it, are refused as not decoded,
naming s.a. A tag that the end of the bytes cuts is still read as a cut,
as before.

Tests:
- test/matParser.test.ts: the case "skips a field slot whose element is
  not a matrix" pinned fields {}, the behaviour this removes. It now
  expects the field, not decoded, and one warning. It also covers the
  small-element form, nesting in a struct, a struct array element and a
  cell, and a new control for a 1x1 cut inside a field tag.
- test/matStructArray.test.ts: a new MatNode case. The struct shows both
  rows, with a not decoded and b still 2. The source has one
  part-unreadable warning. The copies are refused at s.a. The variables
  around it are unchanged.

dist is rebuilt from src.
…oded

A value whose flags word names a class the reader does not model (255,
18) holds nothing the reader read. A complex one already shows as not
decoded: the node builders fail on it and what holds it catches that. A
real one did not fail, so it fell to the numeric arm and was built from
the null it was left as. It showed null (a 1x3 [null], an empty [ ]),
with an editor, of class unknown, and a commit turned it into a double.
That happened at the top of a .mat or a model workspace, and in a cell or
a struct anywhere, including inside a binary dictionary's stream.

It is now shown as what the file says of it, its class and size, not
decoded: <1x1 unknown, not decoded>, with no editor and no rows. Its copy
is refused, naming it, and the copy of a cell or struct holding it is
refused at it (c{1}, s.a). A .mat still reports it once, with the
reader's own warning. A model workspace still does not warn.

A complex one keeps the path it has: what holds it shows the whole value
as not decoded, and a dictionary says why.

Tests: test/unmodelledClassVariable.test.ts, a new B-21 block. It covers
both words on a .mat scalar, a 1x3 and an empty one, a cell element and a
struct field, the same in a model workspace, and a cell inside a binary
dictionary's stream. No existing test changed.

dist is rebuilt from src.
…elements

A .mat struct array or cell whose bytes hold fewer elements than it
declares (a 1x3 struct holding 2, a 1x3 cell holding 1) opens with a
warning and a row for each element held. Its copy is the declared size
beside only those elements. MATLAB R2027a was asked whether it reads
those copies as the padded value core shows, for 12 shapes, each pasted
into a text and a binary dictionary. `load` refuses every damaged file.

MATLAB reads each copy of an array holding SOME of its elements back at
its declared size, with the field names kept, the held elements as they
are, and the rest []. That holds at rank 2 and N-D, and for a struct cut
inside an element. Those copies are kept, and the truth file pins them.

It does not read back a rank-2 array holding NONE of them. The struct
array's bag named no field (its field names come from its element rows),
and MATLAB read a 1x3 struct with no fields in both formats. The cell's
XML was Dimension="0*0", which MATLAB read as a 0x0 cell. Those writes are
now refused with a final not-decoded MatBudgetError, as a value the
reader did not decode is: "Cannot write "c": c is a 1x3 cell none of
whose 3 elements could be read, ...". That covers the copy, the save into
a binary dictionary (a text dictionary's cdata of one, pasted), and the
copy of a cell or struct holding one, at its path (c{2}, s.f). The MAT
stream, which MatWriter writes padded, is not refused, so an N-D one
still copies.

Tests: test/shortArrayCopy.test.ts (new), with the probe
test/parity/matlab/probe_short_array_copy_readback.m, its generator
short_array_copy_readback.mjs and its truth file. No existing test
changed.

dist is rebuilt from src.
No test covered a 1x1 .mat cell whose bytes hold none of the element it
declares. Changing _heldNone's cell arm from `count > 0` to `count > 1`
left the whole suite green, yet it changes what is written: the copy
becomes `{Cell,[1,1],[]}` and the binary dictionary's XML
`Class="cell" Dimension="0*0"`, the spelling MATLAB was measured to read
as a 0x0 cell. The `whose one element could not be read` wording of
_heldNoneRefusal was never run.

shortArrayCopy.test.ts now opens such a cell, at the top level and as an
element of a 1x2 cell, and pastes a text dictionary's cdata of it into a
binary dictionary. It pins the warning, and the refusal of each copy and
of the save, naming the path and that wording. Under the `count > 1`
mutant all three refusals fail, because nothing is thrown.

Test only; no src or dist change.
probe_short_array_copy_readback.m and its generator,
short_array_copy_readback.mjs, were the one probe pair with no row in
test/parity/matlab/README.md. The row says what they ask, what MATLAB
answered, where they write ($SHORT_COPY_OUT), the truth file they made and
the test that grades it. Docs only.
The refusal of a cell holding none of the elements it declares asks
for a cell that declares at least one. No test said so: with the
cell's test loosened from `count > 0` to `count >= 0`, the whole suite
stayed green, yet every empty cell read from a MAT stream ({}, 1x0,
0x3) had its copy refused, and so did any cell or struct holding one.

Add a control: a 0x0, a 1x0 and a 0x3 cell, alone, in a cell and in a
struct, open with no warning and copy as their empty bags; MATLAB's own
three empty cells in dispcat.mat copy as theirs; and a text
dictionary's cdata of each, pasted into a binary dictionary, saves with
its extents and reopens as it. Under the loosened test this one fails,
at the copy and at the save alike, and every other test passes.
The two dist source maps of MatlabVariableNode conflicted; dist is
rebuilt from the merged src (rm -rf dist && npm run build).
@ww-mw
ww-mw merged commit 3fb94fe into main Oct 19, 2026
4 checks passed
@ww-mw
ww-mw deleted the mat-values-warn-and-refuse-what-they-cannot-hold branch October 19, 2026 07:28
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