Skip to content

Writers keep what was read or refuse it: no [object Object], MATLAB's 1x1 cell, and host empties that keep their class - #147

Merged
ww-mw merged 8 commits into
mainfrom
writers-keep-or-refuse-the-last-lossy-spellings
Oct 18, 2026
Merged

ww-mw merged 8 commits into
mainfrom
writers-keep-or-refuse-the-last-lossy-spellings

Conversation

@ww-mw

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

Copy link
Copy Markdown
Member

Three writer follow-ups from group F's reviews (agenda follow-ups 1, 3 and 4), one commit each, plus a merge of main. The DECIDED 2026-10-18 (group G) block says "items 1–4 are built under the rulings they already fall under". For these three items those rulings are:

  • group 1: "refusing what the writers cannot spell";
  • group 2 / theme 1: "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."

1. An object of no MATLAB shape is shown as its JSON, and refused in a binary dictionary (0b03151)

Ruling. Follow-up 1: "[object Object] at an entry or a field (#136). {_array_class: null}, {_object_class: null} or {foo: 1} at an entry or a struct field opens as an opaque value showing [object Object]. It is written Class="double">[object Object]."

What was wrong. A hand-edited text dictionary can hold an object of no MATLAB shape: {foo:1}, {}, {_array_class:null|0,...}, {_object_class:null,...}, {_type:'double'} with no _value, or {_array_type:'Bogus'}. At an entry, a struct field, a cell element or an Architectural Data Constant, such a value:

  • opened as a double scalar, showing [object Object], with an editor and no warning;
  • was written to XML as Class="double">[object Object];
  • was written as 0 by MatWriter in two cases: the hex of a cell that also holds a sparse array, and the text save of an edited N-D JSON cell.

As a property or a Parameter's Value, the XML was already refused (C-205), but the row still showed [object Object].

Root cause (line numbers at 71378b5).

  • MatlabVariableNode.parseScalar (MatlabVariableNode.ts:4054), last arm: any non-null object becomes a double scalar holding the object. NodeClassMap.parseValue (NodeClassMap.ts:178) sends it there when no marker matches, and a falsy _array_class or _object_class matches none.
  • _formatScalar (:796) and serializeXml (:2904) → _serializeScalarXml (:3082) → formatFloatXml both end in formatMatlabNum, which is String(). The struct and cell writers call the child's serializeXml, so all places share this tail.

Fix.

  • New unshapedValue.ts. unshapedText gives the JSON the file holds, cut at 60 characters and shown in angle brackets (<{"foo":1}>). As for every summary, the angle brackets mean the row has no editor. A value JSON cannot spell falls back to its keys, so it never throws. The module also keeps a first-and-count record and builds the warning from it.
  • The row is no longer [object Object]. SlddNode.parse gives one part-unreadable warning per entry: "x" holds {"foo":1} at x.f, which is not a value MATLAB writes; it is shown as read.
  • serializeXml refuses it with C-205's DataNode._unspellable, a MatWriteError: Cannot write "x": x.f is ..., which a binary dictionary has no spelling for. Nothing was written.
  • _buildVarObject marks it unwritable, so the MAT stream is refused too.
  • No change to:
    • the text save and the copy, which already replayed what was read;
    • the property and Value refusal texts.

MATLAB truth (R2027a, on dictionaries generated under /tmp from a repo fixture). MATLAB opens every file with no error. It reads [ ] (1x0 double) at an entry and '' (0x0 char) for a cell element, and it drops the field from a struct. So MATLAB reads no value from such an object, and the row shows what the file holds.

Tests. New test/unshapedObjectValue.test.ts, 25 tests. Each one appends its cases to a real text fixture, ingests it, writes the XML through serializeEntryToXml, then copies into a binary fixture and saves with session.serializeSource.

  • Before the change: 18 fail and 7 pass.
    • The failures: rows show [object Object] and have an editor; warnings is undefined; expected undefined to be 'MatWriteError' because the XML was written; the N-D cell's text save returned no error.
    • The 7 that pass are controls: an untouched save stays byte-identical, copies, a sibling edit, a paste into a text dictionary, the C-205 refusals, and a spellable entry.
  • Mutants:
    • removing the XML refusal fails 8 tests;
    • removing the unwritable mark fails 2;
    • removing the display arm fails 8.
  • No existing test changed.
  • A read-only census over the corpus, repo fixtures and parity artifacts (235 files, 335,881 entries; one file over 60 MB skipped) shows no new warnings and no new refusals on any file.

Left out.

  • A bare JSON array with a non-number element ([1,{foo:1}], [1,null], [1,'a']) is still written through the numeric array writer's element tail. That is a family of its own, and MATLAB needs measuring for each spelling first.
  • The Class and Size columns stay double and 1x1. That matches the class MATLAB reports at an entry, and changing them would reach the Constant gate and the MAT paths.
  • {_type:'double',_value:5} as a property is still written 5.0 by the property writer (pre-existing).
  • A cyclic object pasted by a host still overflows MatWriter.textForm (pre-existing).

vscode: only the pin bump. The row arrives as a summary with no editor, the warning uses the existing part-unreadable channel, and the refusal reaches BinarySlddEditorProvider's catch as a named MatWriteError, as C-205's do.

2. A 1x1 cell is written with no Dimension in a binary dictionary, as MATLAB does (f2a7d40)

Ruling. Follow-up 3: "A 1x1 cell is written Dimension="1*1", which MATLAB omits (#135). It is the last Dimension difference in the save-fidelity census: 414 differences before W1 and W2, 1 after."

What was wrong. When a binary dictionary's 1x1 cell was written from the model, the writer gave it Dimension="1*1". That covers an entry's Value, a nested cell element and a struct field. MATLAB writes no Dimension on any of them.

Root cause. MatlabVariableNode._serializeCellXml (MatlabVariableNode.ts:3213 at 71378b5) writes Dimension on every non-empty cell. DataNode._cellXml handles properties and raw cells, and it already omits the Dimension of a 1x1 cell.

Fix. _serializeCellXml now follows _cellXml's rule: no Dimension when the dims are [1,1] and there is exactly one element. The readers already read a cell with one element and no Dimension as 1x1. The text writers are unchanged, because MATLAB writes _dimensions [1, 1] there too.

MATLAB truth.

  • A scan of every MATLAB-written binary chunk (repo fixtures, parity artifacts and 35 corpus dictionaries, read in place) finds no 1x1 cell with a Dimension at any place. Every other cell has one. The only corpus Dimension="1*1" is in an entry that core wrote.
  • New fixture script test/fixtures/make_cell1x1.m (R2027a) writes cell1x1_binary.sldd and cell1x1_text.sldd. Together they hold a 1x1 cell at every place, plus controls.
  • MATLAB read four files back: its own file, the text twin, a copy with Dimension="1*1" added, and core's regeneration. All 15 entries are isequal in all four (cell1x1.truth.json). So the new spelling reads as the same value, and so do older saves.

Tests. New test/cellOneByOneDimension.test.ts, 27 tests.

  • With the src change reverted: 17 fail and 10 pass. Each failure is the writer spelling Dimension="1*1" where MATLAB has none. The failing cases:
    • 11 regenerated entries;
    • the whole chunk;
    • a removal down to one element;
    • {7} typed in;
    • a paste from the text twin;
    • a copy;
    • the truth's core tags.
  • The 10 that pass are controls and truth checks.

Existing tests changed. Each one pinned Dimension="1*1", the output this removes:

  • test/dimsWriters.test.ts, lines 811, 844, 848, 955, 957, 992 and 1043. These are expected writer output, or hand-built "binary dictionary's own" inputs that the test says are written back as read.
  • test/emptyClassedValues.test.ts, "a binary dictionary's 0x0 logical in a cell": its input and its two expectations.
  • test/binaryEditKeepsUntouched.test.ts (8 tests, plus the no-origin test, which now also opens the three cell fixtures as older saves) and test/binarySaveKeepsUnedited.test.ts (2 rename tests). These 11 used the writers' 1x1 respelling as their proof that a part had been written from the model. A new helper, openOlderSave, now opens the fixture as an earlier core save left it, with Dimension="1*1" added. MATLAB reads that file as the same value, so the proof still holds. Of the 11, 7 failed after the change. The other 4 still passed but had lost their proof: the no-merge mutant no longer killed them. With the helper they are killed again.
  • Power check: the no-merge mutant kills the same 45 tests before and after. The no-bytes mutant kills 1,212 in both states. An edited binary entry keeps its untouched parts (S2: D1, D2, D3, keptPartsViolations) #143's three cell mutants are still killed.

Left out. A 1x0 char at an entry is still regenerated without its Dimension, so it reads back as 0x0. MATLAB writes it <P Name="Value" Class="char" Dimension="1*0"/>. This is the census's actual last Dimension difference; the agenda credited that difference to the 1x1 cell, which the census cannot see at all. It is a candidate follow-up.

vscode: only the pin bump. vscode writes no XML, and no vscode file pins Class="cell" Dimension="1*1". S2's kept-parts gate will see fewer respelled parts.

3. Empty strings and N-D empties keep their class and size in a text dictionary, and what has no spelling there is refused (dd51bee)

Ruling. Follow-up 4: "Lossy host empties (#133). A host's string or function-handle {_type,_emptyDims} at an entry or an object property is written [], which reopens as double 0x0 (as before group F). An N-D empty past int32 at a property, field or cell is still written lossy; only the entry place is refused."

What was wrong.

  • A host's empty string or function handle ({_type,_emptyDims}):
    • at an entry or an object property it was written [], which reopens as double 0x0;
    • at a field or in a cell it was written as handed in, which MATLAB reads back as char 0x0 in a cell and as double 1x1 at a property, and at a field MATLAB drops the field;
    • its copy out of a binary dictionary was [].
  • A binary dictionary's N-D empty, pasted into a text dictionary:
    • a numeric or logical one was written [] or '' at a property or field, and as the bare Matrix(0,2,2) header in a cell, which MATLAB reads back as 0x2;
    • an N-D char was written '' at every place;
    • an N-D enum was written '', [] or as a bag holding only its dims;
    • past int32, all of these were still written; only a numeric or logical entry was refused.
  • An unedited text dictionary holding a host-form empty string or function handle at an entry or a property was rewritten [] on save.

Root cause (line numbers at 71378b5).

  • MatWriter.emptyDimsHeldForm (MatWriter.ts:1620) returned null for string and function_handle. So MatlabVariableNode.serializeValue wrote them from their dims ([]), and textForm left them as handed in.
  • textEmptySpelling (:1558) returned undefined for those classes and for rank 3 and above. heldTextSpelling (:1500) then fell through to the older tails.

Fix, in MatWriter.ts.

  • textEmptySpelling now spells or refuses each kind:
    • an empty string becomes the String bag MATLAB writes for strings(0,3);
    • an N-D numeric, logical or char empty becomes its MAT stream at every place, so past int32 it is refused as at an entry;
    • a function handle at any rank, an N-D enum and an N-D string are refused with the new MatBudgetError reason 'no-spelling'. The node names it as it names 'past-int32': ... and a text dictionary has no spelling of it that MATLAB reads back as it is.
  • emptyDimsHeldForm now passes string and function-handle empties on in the header form, so a copy keeps their class and size.
  • heldByTextDictionary now records every {_type,_emptyDims} as the text dictionary's own, so a save with no edits writes the same file.
  • index.ts: the comment on reason names the new value.

MATLAB truth (R2027a 27.1.0.3409468; the probe opens only files it writes). The probe is committed as test/parity/matlab/probe_lossy_empties_readback.m, with its core half lossy_empties_readback.mjs, its answers lossy_empties_readback.truth.json (85 self rows, 85 candidate rows), and MATLAB's binary dictionary test/fixtures/lossy_empties_readback_binary.sldd.

  • String bag: MATLAB reads it back as string 0x3, 0x0 or 1x0 at an entry, a cell, a field and a property.
  • N-D MAT streams: MATLAB reads them back with their class and size at every place. MATLAB's own text writer gives only the 0x0 of the class.
  • Function handles: MATLAB refuses them at an entry, a cell, a field and a Parameter's Value. At a property, MATLAB's own spelling reads back as a 1x1 handle. So no spelling keeps an empty function handle.
  • N-D empty enum: every spelling reads back without its size. The _enum_value bag around an empty stream crashed R2027a on open in 6 of 6 variants.
  • N-D string: MATLAB refuses it in a cell or a field, and reads its own entry back as double 1x0.

Tests. New test/lossyEmptiesReadback.test.ts, 6 tests. It pastes MATLAB's binary values and the host forms at each place, then grades each save against the truth: the value is either written in a spelling MATLAB read back with its class and size and that core reopens the same way, or refused where MATLAB read back no spelling.

  • Before the change: 5 of the 6 fail. The failures are 38 binary rows, 22 host rows, 10 copies, the function-handle message, and the unedited file.
  • Mutation: each of the 8 arms of the change, removed alone, fails 1 to 5 tests.

Existing tests changed. Each pinned the output this removes:

  • test/emptyClassedValues.test.ts:
    • "a host's string or function handle {_type, _emptyDims} is written there as before, at each place" now expects the String bag, the refusal of a function handle, and the unedited file at all four places;
    • the N-D char Ch3 (0x0x2), written '' (char 0x0) until now, is its stream, reopens as char 0x0x2, and shows <0x0x2 char> when pasted.
  • test/textEmptyReadback.test.ts also reads the new truth file. It now grades N-D empties for class and size, where it used to skip them ("an N-D one is as it was").

Left out.

  • Object-array N-D empties (an object class at 0x0x2) are left as they are. They sit with the rank-2 object-empty call the maintainer holds (1A-TEXT).
  • String bags past int32 are not measured, so they are not refused there either.
  • The binary writer's Class="string" Dimension="0*3"/> for a host string empty is not changed: that is a binary write, not this item.

vscode: only the pin bump. vscode already shows a core refusal from a save, a copy or a drag by its message, and no vscode code switches on reason. After the bump:

  • a copy of a host string empty out of a binary dictionary keeps string 0x3 instead of becoming [];
  • pasting an empty function handle, an N-D empty enum or a past-int32 N-D empty into a text dictionary makes its save refuse, with a message naming the path.

Merge of main (93b8427)

The branch started at 71378b5. Since then, main has gained the 1.41.0 bump (#144) and #146, which also edits MatlabVariableNode.ts. The source merged cleanly. The only conflicts were two dist source maps for MatlabVariableNode, and those were regenerated with rm -rf dist && npm run build.

Gates

  • At each item commit, dist was regenerated and committed, and npm run verify was green:

    • 0b03151: 289 files and 9,807 tests passed;
    • f2a7d40: 290 files and 9,846 tests passed;
    • dd51bee: 291 files and 9,860 tests passed.
  • At head 93b8427:

  • Live MATLAB tests (DEX_MATLAB_CMD="mw -using Bmain matlab", R2027a, each file run alone):

    • at f2a7d40: fidelitySmoke 2/2, variable.fidelity 51/51, writeback.live 32/32;
    • at head 93b8427: variable.fidelity 51/51, writeback.live 32/32, fidelitySmoke 2/2. On the first run of fidelitySmoke, its json case timed out: it took 160 s against the 60 s limit while MATLAB cold-started on a loaded machine. Run alone again, it passed 2/2 (the same timeout seen at f2a7d40).

    None of these tests builds an unshaped object, writes a cell or holds an empty string, function handle or N-D empty. For each item, the MATLAB read-back is that item's committed truth file.

Review rounds (842b58e, 72a7f1e, 27dd229, d44bb11)

Round 1.

  • The correctness review found three behaviours that no test pinned:
    • the unshaped-value record taken before each entry is read;
    • the _rawInput check for a hand-edited array element;
    • three of the four parts of the 1x1-cell rule.
  • The ruling review found that dd51bee also changed binary saves without saying so. A host's empty string at an object's property or a Parameter's Value went from Class="double" Dimension="0*0" to a bare Class="string" Dimension="0*3"/>. An empty function handle there became refused.
  • 842b58e and 72a7f1e add the tests (no src change, dist unchanged). With its guard removed, each new test fails for its stated reason.
  • 27dd229 asks MATLAB R2027a. The new probe_host_string_empties_binary.m and host_string_empties_binary.truth.json grade a host's form, the String bag and the binary reader's empty, at five places, each in a dictionary of its own.
    • At an object's property, the bare tag reads back as a double 1x1. At an entry, a cell or a field it cannot be read at all.
    • The String bag, which the binary writers spell as MATLAB's own saveobj envelope, reads back as the string it is at an entry, a cell, a field and a property.
    • At a Parameter's Value, every spelling reads back as a double 0x0, because MATLAB refuses an empty string there.
  • So emptyDimsHeldForm now hands a host's empty string on as the String bag:
    • the text bytes are unchanged;
    • at a property, a binary dictionary gets the envelope MATLAB reads back as the string;
    • a copy carries the bag.
  • The function handle's refusal at a property and a Value stays (group 1).
  • New test/hostStringEmptiesBinary.test.ts (6). A read-only walk of 477 corpus and repo files (544,777 entries) is identical to the earlier head, apart from run-to-run XML noise.

Round 2. The correctness review found two parts of the new string arm unpinned.

  • d44bb11 adds tests, with no src change:
    • the bag's _dimensions drops trailing 1s, as every other class does. Writing dims for d fails all 15 new text cases, plus the binary property;
    • an N-D string is refused at all five places, a Parameter's Value included. Hoisting the arm above the rank-2 return fails all 10.

Left as it is (since before this PR), a follow-up: the node writer (MatlabVariableNode.serializeXml) still writes a host's empty string pasted as an entry, a cell element or a struct field with the bare string tag, which MATLAB cannot read. So do the binary writers, at every place, for the binary reader's empty string.

Gates at the head (d44bb11)

Implements the maintainer's 2026-10-18 rulings (group G).

ww-mw added 8 commits October 18, 2026 10:02
…it in a binary dictionary

A text dictionary whose value, at an entry, a struct field or a cell element, is a JSON
object MATLAB never writes ({"foo": 1}, {}, or a class marker with no class such as
{"_array_class": null, ...} or {"_object_class": null, ...}) opened as a double scalar
holding the object. Its row showed String() of it, "[object Object]", with an editor and no
warning, and every XML writer wrote it into a double's spelling,
<P Name="Value" Class="double">[object Object]</P>. MatWriter wrote it as 0 in a cell
stream: the hex of a cell that also holds a sparse array, and the text save of an edited
N-D cell. This was MatlabVariableNode's own String() writer tail, the counterpart of the
two that C-205 removed from DataNode.

MATLAB R2027a opens such a file and reads no value from it. It reads a 1x0 double at an
entry, the struct without the field, and a 0x0 char in a cell (measured on dictionaries
generated from typed_text.sldd).

Under the 2026-10-18 rulings (group 1: refuse what the writers cannot spell; theme 1: warn
and show what was read):
- MatlabVariableNode._unshapedObject() marks an untouched scalar that holds the object it
  was read as (parseScalar's last arm).
- Its row shows the JSON it holds, cut at 60 characters, as a summary with no editor:
  <{"foo":1}>. This includes the row of an object's property and of a Simulink.Parameter's
  Value.
- One part-unreadable warning per entry names the first such object and its path, and how
  many there are (new unshapedValue.ts, taken by SlddNode.parse as it takes the other
  per-entry records).
- serializeXml refuses it with DataNode._unspellable's MatWriteError, naming the entry and
  the path, as the property writer already refused the same object.
- _buildVarObject marks its variable as one MatWriter refuses, so the cell stream is
  refused, or falls back to the XML, which refuses the object there.
- A text save and a copy still write it back as read.

Tests: new test/unshapedObjectValue.test.ts (25). Before the change, 18 failed for the
expected reasons: the rows showed [object Object] and had editors, there were no warnings,
and the XML was written instead of refused. The 7 that passed are controls: the untouched
text save is byte-identical; copies, a sibling edit and a paste into a text dictionary keep
the object as read; the property and Value refusals are unchanged; and a spellable entry
is still written. No existing test changed.
…does

MATLAB writes no Dimension on a 1x1 cell in a binary dictionary, at any place: an entry's
Value, a cell element, a struct field, an object's property, and a string's saveobj cell.
This holds for every cell tag in the MATLAB-written binary dictionaries this project reads
(the fixtures, the parity artifacts and the corpus). Every other cell, 1xN, Nx1 or empty,
has one. In a text dictionary it writes "_dimensions": [1, 1], which core already does.

MatlabVariableNode._serializeCellXml wrote Dimension="1*1" on every 1x1 cell it wrote: an
entry's Value, each cell element under it, and a struct field of an entry. That covered a
save that rebuilt an entry, a removal that left one element, {7} typed in, and a paste or
copy into a binary dictionary. DataNode._cellXml, the property writer, already left it out.
MATLAB reads both spellings as the same 1x1 cell, so nothing read back wrong; the cost was a
rewritten tag nobody edited. The writer now applies _cellXml's rule: no Dimension when the
dims are [1, 1] and there is one element. The binary reader takes the absence as 1x1 at an
entry and as 1xN in a cell or a struct.

New MATLAB fixture (R2027a): test/fixtures/make_cell1x1.m writes cell1x1_binary.sldd and
cell1x1_text.sldd, a 1x1 cell at every place a cell goes, with 1x2, 2x1 and 0x0 controls.
cell1x1.truth.json is MATLAB's reading of four files: its own file, the text twin, a copy
with Dimension="1*1" added to its 17 such cells (the old spelling), and core's regeneration
of the file (make_cell1x1_core.mjs). All 15 entries read back isequal in all four. It also
records the cell tags of each file.

Tests: new test/cellOneByOneDimension.test.ts (27). Before the change, 17 failed, each
because the writer spelled Dimension="1*1" where MATLAB's file has none: 11 entries
regenerated, the whole chunk, a removal to one element, {7} typed in, a paste from the text
twin, a copy, and the tags in MATLAB's reading of core's file. The 10 that passed are the
fixture's own rule in both formats, four control entries (0x0, 1x2, 2x1, the Widget's
properties), an edit inside a 1x1 cell (the merge keeps the file's tag), core's read-back of
the new spelling, the text writers' [1, 1], and MATLAB's read-back of all four files.

Existing tests changed, each only where it pinned Dimension="1*1" on a 1x1 cell:
- dimsWriters (7 lines) and emptyClassedValues (3 lines): the writer's expected output, and
  hand-built inputs that are written back "as read", now spell MATLAB's tag.
- binaryEditKeepsUntouched (9 tests) and binarySaveKeepsUnedited (2): each used the
  writers' respelling of cellarr_binary.sldd (11 of its 14 entries) or of cases.sldd's
  cellNest as its proof that a part was written from the model. After the change, 7 of them
  failed on that proof. The other 4 still passed, but no longer failed when every edited
  entry is written whole (a mutant with no merge). They now read those fixtures as an
  earlier save left them: every such cell is given Dimension="1*1", which MATLAB reads as
  the same value. Their claims and expected bytes are otherwise unchanged. The no-merge
  mutant now fails the same 45 tests as before the change. #143's three cell mutants still
  fail the cellNest case: positional pairing, no cell origins in _buildCellChildren, and no
  recordReadElements in cellValue. The no-origin test also takes the three cell fixtures as
  an earlier save left them, beside the others, so that its "merged > 30" floor still holds.
  It is 36 now; without them it would be 21, and it was 73 before the change.

The save-fidelity census counts differing leaves, and a cell's open tag is never a leaf, so
its totals do not move here; entries changed by a regeneration go from 402 to 365 over the
fixtures, the parity artifacts and the corpus, and every file's reading and every text save
are unchanged. Its one remaining Dimension leaf is not a cell: a 1x0 char at an entry,
written as a 0x0 char. That is another writer and is not changed here.
…ctionary, and refuse what has no spelling there

A text dictionary was given a lossy spelling for three kinds of empty:
- a host's string or function-handle {_type, _emptyDims} was written [] at an entry and at
  an object's property, which reopens as a double 0x0, and as handed in at a struct field and
  in a cell, which MATLAB reads back as a char 0x0 in a cell, a double 1x1 at a property and,
  at a field, as a struct without the field. Its copy out of a binary dictionary was [];
- a binary dictionary's N-D empty was written [] or '' at a property or a field and as its
  bare Matrix(...) header in a cell (MATLAB drops the third extent); an N-D char '' at every
  place, an entry too; an N-D enumeration '', [] or a dims-only bag;
- past an int32, all of these were written so too. Only a numeric or logical entry was
  refused.

MatWriter.textEmptySpelling now spells or refuses each, at every place, as MATLAB R2027a
reads them back:
- an empty string: the String bag MATLAB writes for strings(0,3), which it reads back as the
  string it is at an entry, a cell, a field and an object's property;
- an N-D numeric, logical or char empty: its MAT stream, which MATLAB reads back with its
  class and size at every place (its own writer writes the 0x0 of the class). Past an int32
  the stream cannot state the extent, and the save is refused, as at an entry;
- an empty function handle, at any rank: refused. MATLAB refuses a function handle at an
  entry, a cell, a field and a Parameter's Value, and reads its own spelling at an object's
  property back as a 1x1 handle. So is an N-D empty enumeration: MATLAB reads every spelling
  back without its size, and the bag it writes for a non-empty one crashed R2027a on open
  for all six empty variants tried. An N-D string's too, which MATLAB refuses in a cell or a
  field.
The refusal is a MatBudgetError with the new reason 'no-spelling', named by the node as
'past-int32' is: "Cannot write "h": h.f is an empty function handle (1x0), and a text
dictionary has no spelling of it that MATLAB reads back as it is. Nothing was written." The
paste goes through; its save and its copy refuse.

MatWriter.emptyDimsHeldForm now hands a host's string or function-handle empty to that
spelling, so a copy carries its class and size. A text dictionary's own such value
(heldByTextDictionary now records every {_type, _emptyDims}) is written as read, so a save
that edits nothing writes the same file; before, one at an entry or an object's property
was rewritten [].

MATLAB truth (R2027a 27.1.0.3409468, 2026-10-18): probe_lossy_empties_readback.m asks about
17 values at 5 places, MATLAB's own text and binary spellings and 17 candidate spellings
(the core half, lossy_empties_readback.mjs, writes them from core's output). The answers are
in lossy_empties_readback.truth.json, and MATLAB's binary dictionary of the values is
test/fixtures/lossy_empties_readback_binary.sldd. The probe opens only files it writes.

Tests: new test/lossyEmptiesReadback.test.ts (6) pastes every value of MATLAB's binary
dictionary, and a host's forms, at each place, into a text dictionary through core's API,
and grades each save against the truth: written in a spelling MATLAB read back with the
value's class and size, and reopened so here, or refused where MATLAB read back none. Before
the change, 5 failed: 38 binary rows (N-D empties lossy, past-int32 ones unrefused), 22
host rows (written [] or as handed in), 10 copies (written [] or as another value), the
function handle written where it is refused, and the unedited file rewritten. Each of the
eight arms of the change, removed alone, fails it.

Existing tests changed, each where it pinned the output this removes:
- emptyClassedValues: "a host's string or function handle ... is written there as before"
  becomes the String bag at each place, the refusal of a function handle's, and the unedited
  file at all four places; the N-D char Ch3 (0x0x2) is its stream, reopened as char 0x0x2,
  where it was '' (char 0x0), and it shows <0x0x2 char> when pasted.
- textEmptyReadback: it grades every spelling against MATLAB's answers; it now reads the new
  truth file too, and grades N-D empties for class and size instead of skipping them.

Left as they are: an object array's N-D empty (with the rank-2 object-empty call the
maintainer holds), a String bag past an int32 (unmeasured), and the binary writer's spelling
of a host string empty.
Brings in 1.41.0 and #146. The only conflicts were two dist source maps for
MatlabVariableNode, which were regenerated with rm -rf dist && npm run build.
…rray element of one shows as before

Two parts of G1-OBJTAIL were pinned by no test (PR #147's review, round 1):

- SlddNode.parse takes the unshaped-value record before each entry is read. A paste records an
  object of no MATLAB shape as a read does, and nothing takes the record then. Without that
  take, the next dictionary opened, in any session, warned on its first entry about the pasted
  entry's path: '"gadget" holds {"foo":1} at e5.f, which is not a value MATLAB writes; it is
  shown as read.' New test: paste one into a text dictionary, open an untouched one, and expect
  no warning.
- MatlabVariableNode._unshapedObject asks that the scalar holds the very object it was read
  from (_rawInput). An element of a hand-edited numeric array, [1, {"foo": 1}], holds the object
  without being read from it, and this item leaves that family out. Without the check, its row
  arr(2) showed <{"foo":1}> with no editor, where it shows [object Object] with one, as before.
  New control test: arr(2) and inCell{2}(2) as before, with no warning.

Each new test fails with its guard removed, for that reason (the warning above; the row's text
and editor), and passes here. No source change; dist rebuilt, unchanged.
The binary writer leaves Dimension out only for a 1x1 cell of one element
(MatlabVariableNode._serializeCellXml: rank 2, one row, one column, one element). Three of those
four parts were pinned by no test (PR #147's review, round 1). With any one of them dropped, the
suite passed, but a hand-edited text cell lost its Dimension when written as XML or pasted into
a binary dictionary:
- a [1, 1] of two elements, which then reopens as a 1x2 in a cell or a struct;
- a [1, 1, 1];
- a [2, 1] or a [0, 1] of one element.

New control tests hold those four inputs and a true 1x1 of one element, both written from the
text dictionary's model (regenerateEntryXml) and pasted into a binary dictionary and saved. Each
part removed fails them, naming its input, and they pass here. No source change; dist rebuilt,
unchanged.
…onary as MATLAB does

PR #147's empties commit (dd51bee) handed a host's empty string, {_type: 'string', _emptyDims},
on from MatWriter.emptyDimsHeldForm as the binary reader's empty. The text writer spells that as
the String bag, as intended. But the binary writers build an object's property and a
Simulink.Parameter's Value from the same form, so a paste into a binary dictionary changed there
too, with no test and no disclosure (review, round 1):
- an empty string at either place went from Class="double" Dimension="0*0" to
  Class="string" Dimension="0*3"/>;
- an empty function handle at either place went from a double 0x0 to the refusal FH-PASTE
  gives at an entry, a cell and a field.

MATLAB R2027a was asked. The new probe_host_string_empties_binary.m reads the dictionaries its
core half, host_string_empties_binary.mjs, saves through core's API: a host's form, the String
bag and the binary reader's empty, at five places, each in a dictionary of its own. The answers
are in host_string_empties_binary.truth.json:
- the bare Class="string" Dimension="r*c"/> reads back as a double 1x1 at an object's property.
  At an entry, a cell or a field it cannot be read at all ("Invalid string returned.");
- the String bag, which the binary writers spell as MATLAB's own saveobj envelope, reads back as
  the string it is at an entry, a cell, a field and an object's property;
- at a Parameter's Value, every spelling reads back as a double 0x0. MATLAB refuses an empty
  string there.

So emptyDimsHeldForm now hands a host's empty string on as the String bag:
- a text dictionary is given the same bytes as before, the String bag;
- at an object's property, a binary dictionary is given the envelope, which MATLAB reads back as
  the string. dd51bee wrote the bare tag (a double 1x1), and main wrote a double 0x0;
- at a Parameter's Value, MATLAB reads the envelope back as the double 0x0 it read of main's
  spelling, and core reopens it as the string, as in a text dictionary;
- a copy carries the bag, so a copy pasted into a binary dictionary as an entry is the envelope
  too, which MATLAB reads back as the string. It was the bare tag, which MATLAB cannot read, and
  [] on main.
The function handle's refusal at a property and at a Value stays (group 1).

Tests: new test/hostStringEmptiesBinary.test.ts (6) grades each save against the truth. Without
the new arm, 3 fail: the property's spelling (MATLAB reads a double 1x1), the Value's spelling
(the bare tag) and the copy's (MATLAB cannot read it). With dd51bee's source reverted as well,
the function handle's test fails too: it is written, and reads back as a double 0x0. A truth
check and a control pass in both states; the control pins the node writer's spelling at an
entry, a cell and a field, which this does not reach. A read-only walk of 477 corpus and repo
files (544,777 entries) finds rows, copies, text forms, warnings and untouched saves identical
to the PR's head, and its XML differs only where two runs of the head differ.

Left as it is, since before PR #147, and a follow-up: the node writer
(MatlabVariableNode.serializeXml) still writes a host's empty string pasted as an entry, a cell
element or a struct field with the bare string tag, which MATLAB cannot read. So do the binary
writers, at every place, for the binary reader's empty string, the form core reads that tag back as.
…ne is refused

Two parts of 27dd229's string arm in MatWriter.emptyDimsHeldForm were pinned by no test, and
either could change without a test failing (PR #147's review, round 2):

- The String bag's `_dimensions` is the host's dims without trailing 1s (effectiveDims), as every
  other class is written. Written with them, a host's `{_type: 'string', _emptyDims: [0, 3, 1]}`
  was `"_dimensions": [0, 3, 1]` in a text dictionary and `Dimension="0*3*1"` in the binary
  envelope, spellings MATLAB was never asked about, and it reopened as a 0x3x1. New tests, in a
  text dictionary at an entry, a cell, a field, an object's property and a Parameter's Value, and in
  a binary dictionary at a property, a Value and in a copy, take 0x3x1, 1x0x1 and 0x0x1x1. Each is
  graded as the string MATLAB reads back without the 1, or, at a text Value, where MATLAB reads
  every spelling back as a double 0x0, compared with the one without it. With `dims` in place of
  `d`, all 15 text cases fail, and the binary property fails on its first case.
- The arm comes after the rank-2 return, so an N-D string is not handed on as a bag. A host's
  0x0x2 and 0x3x2 string in a text dictionary is refused at all five places, a Parameter's Value
  too (lossyEmptiesReadback's host list had no N-D string, and its places leave out the Value).
  The refusal names it "a 0x0x2 string". With the arm hoisted above the return, the Value is
  written as the N-D bag, which MATLAB reads back in no text spelling, and the other four
  refusals call it "a 0x0x2 char": all 10 fail.

The header of probe_host_string_empties_binary.m still described the first head of PR #147, which
handed the string on as the binary reader's empty at a property and a Value. It now says the
String bag goes there, spelled as MATLAB's saveobj envelope. No source changes, and dist is
unchanged.
@ww-mw
ww-mw merged commit 38821f6 into main Oct 18, 2026
4 checks passed
@ww-mw
ww-mw deleted the writers-keep-or-refuse-the-last-lossy-spellings branch October 18, 2026 20:11
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