From 00e360d70cfb9497d0ab9d17f4542eb3e4ccda2e Mon Sep 17 00:00:00 2001 From: Benjamin Brock Date: Mon, 27 Jul 2026 18:28:17 +0000 Subject: [PATCH 1/2] Write SSMC notes metadata as one multi-line string The SSMC "notes" field is a MATLAB char matrix, whose rows are blank padded to a uniform width. Writing each row as a separate JSON string exposed that padding in the file, giving an array of fixed-width strings with trailing whitespace instead of a text block. Join the rows into a single string separated by newlines, with trailing blanks stripped. Readers rebuild the char matrix with char(), which pads to the longest line, so the original width is only recoverable if some line reaches it. When no line does, the widest line keeps just enough padding to preserve the width; at most one line in a block ends up with trailing whitespace, and the round trip stays exact. This keeps the strict isequal comparison in ssexport's check passing without any change on the SuiteSparse side. On the read side, split embedded line terminators out of every element and never deblank, so both the new single string and the padded row arrays in existing files produce the same char matrix. Verified with ssexport(ids, 'check', '', {'MM','RB','BSP'}) on Mycielski/mycielskian2 and LPnetlib/lp_blend, whose widest note lines are blank padded, plus HB/ash219, Pajek/football, and HB/bcsstk01. Co-Authored-By: Claude Opus 5 (1M context) --- bindings/matlab/binsparse_to_ssmc_problem.m | 14 ++++++++-- .../matlab/binsparse_write_ssmc_problem.m | 26 +++++++++++++++++-- .../matlab/test_binsparse_to_ssmc_problem.m | 25 ++++++++++++++++++ .../test_binsparse_write_ssmc_problem.m | 26 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/bindings/matlab/binsparse_to_ssmc_problem.m b/bindings/matlab/binsparse_to_ssmc_problem.m index 3309216..dbc4746 100644 --- a/bindings/matlab/binsparse_to_ssmc_problem.m +++ b/bindings/matlab/binsparse_to_ssmc_problem.m @@ -442,8 +442,13 @@ function require_strictly_increasing(values, label) end function value = text_rows(value) +% Rebuild a Matlab char matrix from metadata text. The text is normally a +% single multi-line string, but older files store an array of rows, so any +% embedded line terminators are split out of every element. Rows are never +% deblanked here: whatever padding the writer kept is what char() needs to +% restore the original width. if ischar(value) - return; + value = num2cell(value, 2); elseif isstring(value) value = cellstr(value(:)); elseif iscellstr(value) @@ -451,7 +456,12 @@ function require_strictly_increasing(values, label) else error('BinSparse:InvalidMetadata', 'notes must contain text'); end -value = char(value); +rows = cell(0, 1); +for k = 1:numel(value) + pieces = regexp(value{k}, '\r\n|\n|\r', 'split'); + rows = [rows; pieces(:)]; %#ok +end +value = char(rows); end function value = normalize_component_text(value, use_cellstr) diff --git a/bindings/matlab/binsparse_write_ssmc_problem.m b/bindings/matlab/binsparse_write_ssmc_problem.m index bb1b11e..41235e6 100644 --- a/bindings/matlab/binsparse_write_ssmc_problem.m +++ b/bindings/matlab/binsparse_write_ssmc_problem.m @@ -244,7 +244,7 @@ function write_string_dataset(output_filename, name, value) ok = false; if ischar(value) if size(value, 1) > 1 - value = char_rows(value); + value = text_block(value); else value = char(value); end @@ -253,7 +253,7 @@ function write_string_dataset(output_filename, name, value) if isscalar(value) value = char(value); else - value = cellstr(value(:)); + value = text_block(value); end ok = true; elseif (islogical(value) || isnumeric(value)) && numel(value) <= 64 @@ -261,6 +261,28 @@ function write_string_dataset(output_filename, name, value) end end +function text = text_block(value) + % Join multi-row text into a single multi-line string, stripping the + % trailing blanks that Matlab char matrices use as padding. Readers + % re-pad with char(), which recovers the original width only if some + % row reaches that width; when no row does, the widest row keeps just + % enough padding to preserve it. That makes the round trip exact + % while leaving trailing blanks on at most one line. + if ischar(value) + raw = num2cell(value, 2); + else + raw = cellstr(value(:)); + end + rows = regexprep(raw, ' +$', ''); + lengths = cellfun('length', rows); + width = max(cellfun('length', raw)); + if ~isempty(lengths) && max(lengths) < width + [~, k] = max(lengths); + rows{k} = [rows{k} repmat(' ', 1, width - lengths(k))]; + end + text = strjoin(reshape(rows, 1, []), sprintf('\n')); +end + function rows = char_rows(value) rows = cell(size(value, 1), 1); for k = 1:size(value, 1) diff --git a/bindings/matlab/test_binsparse_to_ssmc_problem.m b/bindings/matlab/test_binsparse_to_ssmc_problem.m index 6f57293..e48f101 100644 --- a/bindings/matlab/test_binsparse_to_ssmc_problem.m +++ b/bindings/matlab/test_binsparse_to_ssmc_problem.m @@ -79,6 +79,31 @@ Problem = binsparse_to_ssmc_problem(raw); assert(isequal(Problem.b, [1 2 3; 4 5 6])); +% notes may be a single multi-line string, and blank rows are preserved. +notes_meta = metadata; +notes_meta.notes = sprintf('a longest line with trailing space \nshort\n\nlast'); +Problem = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +assert(isequal(Problem.notes, char({'a longest line with trailing space '; ... + 'short'; ''; 'last'})), 'multi-line notes mismatch'); +assert(isequal(size(Problem.notes), [4 35]), 'multi-line notes width mismatch'); + +notes_meta.notes = sprintf('abc\n'); +Problem = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +assert(isequal(Problem.notes, char({'abc'; ''})), 'trailing blank note row lost'); + +% Padded row arrays written by older versions read back the same way. +notes_meta.notes = {'first note '; 'second note'}; +legacy = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +notes_meta.notes = sprintf('first note\nsecond note'); +joined = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +assert(isequal(legacy.notes, joined.notes), ... + 'legacy and multi-line notes disagree'); +assert(isequal(joined.notes, char({'first note'; 'second note'}))); + bad = formats{1}; bad.indices_1([1 2]) = bad.indices_1([2 1]); assert_throws(@() binsparse_to_ssmc_problem( ... diff --git a/bindings/matlab/test_binsparse_write_ssmc_problem.m b/bindings/matlab/test_binsparse_write_ssmc_problem.m index 7b6824d..33fa038 100644 --- a/bindings/matlab/test_binsparse_write_ssmc_problem.m +++ b/bindings/matlab/test_binsparse_write_ssmc_problem.m @@ -93,6 +93,32 @@ function test_binsparse_write_ssmc_problem() 'Primary metadata id is not a JSON number'); assert(~contains(json, '"ssmc_metadata"'), 'Unexpected legacy metadata key'); +% notes are stored as one multi-line string. Trailing blanks are stripped, +% except on the widest row when that row is what carries the char matrix +% width, so that char() rebuilds the original notes exactly. +decoded = jsondecode(json); +notes_text = decoded.metadata.notes; +assert(ischar(notes_text) && isrow(notes_text), ... + 'Notes metadata is not a single string'); +assert(isequal(notes_text, sprintf('first note\nsecond note ')), ... + 'Notes metadata text mismatch'); +rebuilt = char(regexp(notes_text, '\r\n|\n|\r', 'split')); +assert(isequal(rebuilt, Problem.notes), 'Notes round trip is not exact'); + +% When the widest row has no padding, no trailing blanks survive at all. +clean = Problem; +clean.notes = ['abc'; 'de ']; +clean_file = [tempname() '.bsp.h5']; +cleanup_clean = onCleanup(@() delete_if_exists(clean_file)); %#ok +binsparse_write_ssmc_problem(struct('Problem', clean), clean_file, ... + format, compression_level); +clean_notes = jsondecode(h5readatt(clean_file, '/', 'binsparse')); +clean_notes = clean_notes.metadata.notes; +assert(isequal(clean_notes, sprintf('abc\nde')), ... + 'Notes metadata retained recoverable padding'); +assert(isequal(char(regexp(clean_notes, '\n', 'split')), clean.notes), ... + 'Notes round trip is not exact'); + fprintf('Test passed.\n'); end From b5f2da340a14a290593d77dc1c3f0df1ace634b6 Mon Sep 17 00:00:00 2001 From: Benjamin Brock Date: Mon, 27 Jul 2026 18:42:01 +0000 Subject: [PATCH 2/2] Strip trailing blanks from aux string datasets Aux text was written to its HDF5 string dataset row by row with the blank padding of the source char matrix intact, while sstextwrite deblanks the same data for the MM and RB formats. Strip it here too, reusing the rule already used for notes: rows lose their trailing blanks, except that the widest row keeps just enough padding for a char() rebuild to recover the original width. Single rows and the elements of cell and string arrays carry no matrix padding, so a single row is left alone and cell elements are simply stripped. Aux text now round trips exactly even when the widest row is blank padded, which is more than MM and RB manage: for aux.padded = ['alpha ';'beta '], BSP returns the 2-by-6 original while MM and RB both return a 2-by-5 matrix, so ssexport's check would fail on them. Co-Authored-By: Claude Opus 5 (1M context) --- .../matlab/binsparse_write_ssmc_problem.m | 36 ++++++++++--------- .../matlab/test_binsparse_to_ssmc_problem.m | 7 ++++ .../test_binsparse_write_ssmc_problem.m | 11 +++++- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/bindings/matlab/binsparse_write_ssmc_problem.m b/bindings/matlab/binsparse_write_ssmc_problem.m index 41235e6..2fba51c 100644 --- a/bindings/matlab/binsparse_write_ssmc_problem.m +++ b/bindings/matlab/binsparse_write_ssmc_problem.m @@ -185,20 +185,23 @@ function write_string_dataset(output_filename, name, value) 'BSP text output requires binsparse_write_string_dataset on the path'); end + % Trailing blanks are stripped row by row, as sstextwrite does for the + % MM and RB formats. A single row is left alone: it is the widest row, + % so its blanks are what a char() rebuild needs to recover the width. if isstring(value) if isscalar(value) value = char(value); else - value = cellstr(value(:)); + value = strip_text_rows(value); end elseif ischar(value) if size(value, 1) > 1 - value = char_rows(value); + value = strip_text_rows(value); else value = char(value); end elseif iscellstr(value) - value = value(:); + value = strip_text_rows(value); else error('binsparse_write_ssmc_problem:InvalidStringValue', ... 'Text aux value must be char, string, or cellstr'); @@ -261,33 +264,32 @@ function write_string_dataset(output_filename, name, value) end end -function text = text_block(value) - % Join multi-row text into a single multi-line string, stripping the - % trailing blanks that Matlab char matrices use as padding. Readers - % re-pad with char(), which recovers the original width only if some - % row reaches that width; when no row does, the widest row keeps just - % enough padding to preserve it. That makes the round trip exact - % while leaving trailing blanks on at most one line. +function rows = strip_text_rows(value) + % Strip the trailing blanks that Matlab uses to pad the rows of a char + % matrix. A reader rebuilds the matrix with char(), which pads every + % row to the longest one, so the original width survives only if some + % row reaches it; when none does, the widest row keeps just enough + % padding to preserve it. The round trip is then exact, with trailing + % blanks left on at most one row. Cell and string arrays carry no such + % padding, so their elements are simply stripped. if ischar(value) raw = num2cell(value, 2); + width = size(value, 2); else raw = cellstr(value(:)); + width = 0; end rows = regexprep(raw, ' +$', ''); lengths = cellfun('length', rows); - width = max(cellfun('length', raw)); if ~isempty(lengths) && max(lengths) < width [~, k] = max(lengths); rows{k} = [rows{k} repmat(' ', 1, width - lengths(k))]; end - text = strjoin(reshape(rows, 1, []), sprintf('\n')); end -function rows = char_rows(value) - rows = cell(size(value, 1), 1); - for k = 1:size(value, 1) - rows{k} = value(k, :); - end +function text = text_block(value) + % Join multi-row text into a single multi-line string. + text = strjoin(reshape(strip_text_rows(value), 1, []), sprintf('\n')); end function json = encode_json_or_empty(value) diff --git a/bindings/matlab/test_binsparse_to_ssmc_problem.m b/bindings/matlab/test_binsparse_to_ssmc_problem.m index e48f101..3f0b252 100644 --- a/bindings/matlab/test_binsparse_to_ssmc_problem.m +++ b/bindings/matlab/test_binsparse_to_ssmc_problem.m @@ -74,6 +74,13 @@ assert(isequal(Problem.aux.seq{2}, [7; 8])); assert(isequal(Problem.aux.label, char({'abc'; 'def'}))); +% A stripped string dataset rebuilds the char matrix it was written from, +% including the width carried by the widest row. +raw.aux.label = {'hello '; 'there'}; +Problem = binsparse_to_ssmc_problem(raw); +assert(isequal(Problem.aux.label, ['hello '; 'there ']), ... + 'stripped string dataset mismatch'); + dmat = dense_matrix([1; 2; 3; 4; 5; 6], 2, 3, 'DMAT'); raw = struct('metadata', metadata, 'A', formats{1}, 'b', dmat); Problem = binsparse_to_ssmc_problem(raw); diff --git a/bindings/matlab/test_binsparse_write_ssmc_problem.m b/bindings/matlab/test_binsparse_write_ssmc_problem.m index 33fa038..f6ec3d2 100644 --- a/bindings/matlab/test_binsparse_write_ssmc_problem.m +++ b/bindings/matlab/test_binsparse_write_ssmc_problem.m @@ -53,6 +53,7 @@ function test_binsparse_write_ssmc_problem() Problem.aux.D = [1 0 2; 3 4 5]; Problem.aux.S = sparse([1 2], [2 3], [9 8], 3, 3); Problem.aux.note = ['hello '; 'there ']; +Problem.aux.note2 = ['hello!'; 'there ']; Problem.aux.tags = {'alpha'; 'beta'}; problem = struct('Problem', Problem); @@ -84,8 +85,16 @@ function test_binsparse_write_ssmc_problem() expected_sparse = full(Problem.aux.S); assert(matrices_equal(aux_sparse_mat, expected_sparse), 'Aux sparse matrix mismatch'); -check_string_dataset(out_file, 'note', {'hello '; 'there '}); +% String datasets are stripped row by row. No row of note reaches the char +% matrix width, so the widest row keeps one blank; note2 needs none. Either +% way char() rebuilds the original char matrix exactly. +check_string_dataset(out_file, 'note', {'hello '; 'there'}); +check_string_dataset(out_file, 'note2', {'hello!'; 'there'}); check_string_dataset(out_file, 'tags', {'alpha'; 'beta'}); +assert(isequal(char(as_cellstr(h5read(out_file, '/note'))), Problem.aux.note), ... + 'Aux note round trip is not exact'); +assert(isequal(char(as_cellstr(h5read(out_file, '/note2'))), Problem.aux.note2), ... + 'Aux note2 round trip is not exact'); json = h5readatt(out_file, '/', 'binsparse'); assert(contains(json, '"metadata"'), 'Primary metadata not nested');