Add structured SampleSet export/import - #95
Conversation
bernalde
left a comment
There was a problem hiding this comment.
Review of #95 (structured SampleSet export/import). I am the PR author, so GitHub only permits a COMMENT event from me; the formal verdict (approval or change request) must come from another maintainer. Based on the findings below my verdict would be REQUEST_CHANGES.
Setup: fetched origin/main and head 842eaeb, diffed against merge-base 8ef0b39. File/line totals match the PR (5 files, +594/-0). Reviewed on a clean tree at the PR head.
Intent vs issue #93: the PR delivers write_samples/read_samples/sampleset_table, the stable CSV schema, embedded/sidecar JSON metadata, and the documented save/reload workflow, which covers most of the issue's scope. Closes #93 is appropriate in principle, but see the blocking bug — a core acceptance criterion ("round-trip tests preserve states ... and domain") is not actually met for spin states, so I would not auto-close the issue on merge yet.
- Blocking issues
- Spin SampleSets containing an all-up sample (
[1,1,1]) corrupt or fail on round-trip. State serialization chooses its delimiter from sample values, not the domain, so an all-+1spin state is written concatenated (111) and then parsed as the integer111on read. This either throwsAll samples must have states of equal length(mixed lengths) or silently turns[1,1,1]into[111]. Details and a reproduction are in the inline comment onsrc/library/solution/table.jl. This breaks the issue's "preserve states and domain" acceptance criterion and is untested. I would not merge this until it is fixed and covered by a test.
- Nonblocking issues
read_samples(...; bit_order=...)is silently overridden by the file's recordedbit_orderwhenever metadata is present, making the documented argument a no-op for any file with embedded/sidecar metadata. See inline comment.- Model
scale/offset/variablesare written (via themodelkwarg) but never restored byread_samples; only solutionmetadatais read back. The values are currently write-only and untested on the read path. See inline comment. - Files written with
metadata_path(sidecar) carry no embedded frame, so reading them WITHOUT passingmetadata_pathsilently yields the default frame (sense = Min,domain = BoolDomain) and defaultbit_order, with no warning. For a reverse-ordered or spin file this silently corrupts the recovered states/frame. Consider always embedding a minimal frame, or warning when a metadata-less file is read. read_samplesalways returnsSampleSet{Float64,Int}regardless of the sourceSampleSet's parametrization (e.g.{Float32,Int16}widens on round-trip). Minor; worth a docstring note.
- Questions
- Is the silent override of the
bit_orderargument by file metadata intended? If so, the docstring should say the recorded order always wins; if not, the explicit argument should take precedence. - Should
read_samplesreconstruct (or at least surface) the persisted modelscale/offset/variables, given #93 lists them as in-scope?
- Tests run and outcomes
julia --project=. -e 'using QUBOTools; ...'smoke load: ok.- Targeted
test_solution_sampleset_io(): 16/16 pass. - Full
test_solution()group: 141/141 pass. - Manual edge probes: empty SampleSet round-trip ok; scientific-notation/negative values ok; all-up spin round-trip FAILS (see blocking). I did not re-run the full
Pkg.test()(the PR reports 671 passing); the new code path's failure is in an untested case, so the reported count does not exercise it.
- Merge-readiness
- Not ready as-is. One blocking correctness bug in the core round-trip path for spin data, plus the nonblocking items above. After the spin serialization is fixed (delimiter chosen by domain, not values) and covered by an all-up spin round-trip test, this should be close to mergeable. The nonblocking items do not by themselves block merge.
| reverse!(ψ) | ||
| end | ||
|
|
||
| if all(x -> x == 0 || x == 1, ψ) |
There was a problem hiding this comment.
Blocking: the delimiter is chosen from the sample's values (all(x -> x == 0 || x == 1, ψ)) rather than the solution's domain. A spin sample that is all-up ([↑,↑,↑] = [1,1,1]) has every entry in {0,1}, so it is written concatenated as 111 instead of space-separated. On read, _parse_samples_state only splits a concatenated string into per-character bits for the bool domain (line 432), so in a spin file 111 is parsed as the single integer 111.
Result: a spin SampleSet with any all-up sample either throws All samples must have states of equal length (when other samples parse to a different length) or silently round-trips [1,1,1] to [111]. Reproduced:
using QUBOTools; using QUBOTools: ↑
mktempdir() do dir
sol = SampleSet{Float64,Int}(Sample{Float64,Int}[Sample([↑,↑,↑], -3.0, 5)]; sense=:max, domain=:spin)
p = joinpath(dir,"sp.csv"); write_samples(p, sol)
@show QUBOTools.state(read_samples(p), 1) # => [111], expected [1, 1, 1]
endFix: choose the serialization format from domain(sol), not from the values — concatenate only for the bool domain and always space-separate spin states. Thread the domain into sampleset_table/_samples_state_string. Add a round-trip test for an all-up spin state (the current tests only cover mixed spin states, so this is missed).
There was a problem hiding this comment.
Addressed in 6829fab. sampleset_table now passes domain(sol) into state serialization, so spin states are always space-separated; test_solution_sampleset_io() includes an all-up spin sidecar round-trip regression.
|
|
||
| tokens = if occursin(r"[\s,;]", text) | ||
| split(replace(replace(text, "," => " "), ";" => " ")) | ||
| elseif sample_domain === 𝔹 && all(c -> c == '0' || c == '1', text) |
There was a problem hiding this comment.
Read side of the blocking bug above: the per-character split only fires for sample_domain === 𝔹, so a concatenated spin state written by _samples_state_string is never split back into bits. Once the write side selects its delimiter by domain (always space-separating spin), this branch is consistent; until then spin states whose entries are all +1 are mis-parsed here.
There was a problem hiding this comment.
Addressed in 6829fab. The writer no longer emits concatenated spin states, so the bool-only compact parse branch stays consistent; the all-up spin round-trip test covers this path.
| return bit_order | ||
| end | ||
|
|
||
| function _samples_read_bit_order(file_metadata::Dict{String,Any}, bit_order::Symbol) |
There was a problem hiding this comment.
Nonblocking/Question: this makes the file's recorded bit_order unconditionally override the bit_order argument passed to read_samples whenever metadata is present (get(file_metadata, "bit_order", bit_order)). The read_samples(path; bit_order=...) parameter documented in the docstring is therefore a no-op for any file carrying embedded or sidecar metadata. Either honor the explicit argument or document that the recorded order always wins.
There was a problem hiding this comment.
Addressed as documentation and regression coverage in 6829fab. Recorded metadata intentionally wins so files written with bit_order=:reverse restore native order; the docs now say the keyword is for metadata-less input, and the test covers the precedence.
| ) | ||
|
|
||
| if !isnothing(model) | ||
| file_metadata["model"] = Dict{String,Any}( |
There was a problem hiding this comment.
Nonblocking: model scale/offset/variables are persisted here, but read_samples only restores metadata (via _samples_solution_metadata); this model block is dropped on read and has no read-path test. #93 lists preserving model scale/offset/variables as in-scope. Since read_samples returns a SampleSet rather than a model this may be acceptable, but the values are currently write-only — worth wiring through or documenting the limitation.
There was a problem hiding this comment.
Addressed as documentation and coverage in 6829fab. read_samples remains a SampleSet importer and does not reconstruct a model; the docs now state that, and the test verifies model scale/offset/variables are written to the JSON metadata.
bernalde
left a comment
There was a problem hiding this comment.
Review update for #95. I am the PR author, so GitHub only permits a COMMENT event from me; the formal verdict must come from another maintainer. My substantive verdict would be REQUEST_CHANGES because of the blocking issue below. I would not merge this until the blocking issue is addressed.
Setup: fetched origin/main and origin/fix/issue-93-sampleset-io, reviewed clean head 842eaeb against merge-base 8ef0b39. Local totals match GitHub's PR totals exactly: 5 files, +594/-0.
Intent vs issue #93: the PR implements the requested public helpers, CSV schema, metadata sidecar/embedded metadata, table conversion, tests, and docs. However, the current implementation does not satisfy the core round-trip acceptance criterion for spin states.
Blocking:
src/library/solution/table.jl:238: spin SampleSets with an all-up state corrupt on round-trip._samples_state_stringchooses the compact011-style representation based on sample values rather thandomain(sol). A spin state[1, 1, 1]is written as111; because the reader only character-splits compact states for the bool domain, it reads back as[111]. I reproduced this locally. Fix by choosing serialization from the solution domain: concatenate only bool states and space-separate spin states. Add an all-up spin round-trip test.
Nonblocking:
src/library/solution/table.jl:219: metadata silently overrides the explicitread_samples(...; bit_order=...)argument whenever metadata exists. Either honor the explicit argument or document that recorded metadata always wins.src/library/solution/table.jl:266: modelscale/offset/variablesare written into file metadata but are not surfaced byread_samples; only solution metadata is restored. Since #93 names these fields as in scope, either expose/document this as write-only metadata or provide a way to recover it.- Sidecar-mode CSV files carry no embedded frame. Reading such a CSV without passing the matching
metadata_pathsilently defaults to min/bool/native, which can corrupt spin or reverse-order files. Consider embedding a minimal frame even when using a sidecar, or warning on metadata-less reads. read_samplesalways returnsSampleSet{Float64,Int}. This may be acceptable, but it should be documented because parametric source sample sets widen on import.
Questions:
- Should user-provided
bit_orderoverride metadata, or is metadata intended to be authoritative? - Should
read_samplesexpose the persisted model metadata, or is the sidecar intended only for external consumers?
Tests run:
- Targeted write/read smoke for duplicate bool states: passed.
- Targeted no-metadata duplicate import: passed.
- Targeted model sidecar metadata check: passed.
- Targeted all-up spin round-trip: failed as described above.
env QUBOTOOLS_FOREIGN_TESTS=true /home/bernalde/.juliaup/bin/julialauncher +release --project=. -e 'using Pkg; Pkg.test()': passed, 674 QUBOTools tests including foreign package tests for ToQUBO, QUBODrivers, and QUBO.gh pr checks 95 --repo JuliaQUBO/QUBOTools.jl: all reported checks passed, including Julia 1.10/1 ubuntu, Julia 1 windows, build, documenter/deploy, and label.
Merge-readiness:
- Not ready while the all-up spin round-trip bug remains. CI passes because the current tests miss that case.
|
Pushed commit:
Main changes:
Tests run:
Comments intentionally not addressed with behavior changes:
Remaining risks / follow-up:
|
Performance Report -
|
| case | main |
dev |
diff |
|---|---|---|---|
/constructors/n=128/Model/MOI/bool |
666.373 μs (714.818 μs) ± 472.864 μs | 687.464 μs (743.417 μs) ± 552.147 μs | invariant (3.17%) |
/constructors/n=128/Model |
708.623 μs (747.851 μs) ± 946.085 μs | 698.369 μs (748.689 μs) ± 989.728 μs | invariant (-1.45%) |
/constructors/n=2048/Model/MOI/bool |
28.800 ms (30.505 ms) ± 42.210 ms | 29.073 ms (30.787 ms) ± 39.898 ms | invariant (0.95%) |
/constructors/n=2048/Model |
29.426 ms (33.430 ms) ± 24.714 ms | 29.276 ms (32.917 ms) ± 24.803 ms | invariant (-0.51%) |
/constructors/n=384/Model/MOI/bool |
1.996 ms (2.308 ms) ± 625.603 μs | 1.890 ms (2.175 ms) ± 693.101 μs | invariant (-5.32%) |
/constructors/n=384/Model |
2.510 ms (2.711 ms) ± 1.568 ms | 2.531 ms (2.798 ms) ± 1.741 ms | invariant (0.84%) |
/conversions/n=128/form/Dict |
184.593 μs (228.952 μs) ± 3.169 ms | 178.784 μs (229.754 μs) ± 3.080 ms | invariant (-3.15%) |
/conversions/n=128/form/Matrix/Float32 |
38.827 μs (45.878 μs) ± 158.146 μs | 40.019 μs (50.204 μs) ± 206.556 μs | invariant (3.07%) |
/conversions/n=128/form/Matrix |
52.057 μs (64.034 μs) ± 3.175 ms | 53.208 μs (66.268 μs) ± 3.499 ms | invariant (2.21%) |
/conversions/n=128/form/SparseMatrixCSC |
166.004 μs (180.376 μs) ± 222.614 μs | 177.942 μs (193.381 μs) ± 250.104 μs | invariant (7.19%) |
/conversions/n=128/ising/Matrix |
52.207 μs (62.532 μs) ± 3.096 ms | 51.817 μs (62.742 μs) ± 3.174 ms | invariant (-0.75%) |
/conversions/n=128/qubo/Matrix/min |
642.198 μs (694.113 μs) ± 7.735 ms | 636.889 μs (696.657 μs) ± 8.000 ms | invariant (-0.83%) |
/conversions/n=128/qubo/Matrix |
343.196 μs (378.413 μs) ± 5.609 ms | 347.953 μs (378.038 μs) ± 5.917 ms | invariant (1.39%) |
/conversions/n=384/form/Dict |
341.744 μs (625.477 μs) ± 5.233 ms | 401.553 μs (616.660 μs) ± 3.888 ms | regression (17.50%) |
/conversions/n=384/form/Matrix/Float32 |
174.928 μs (244.516 μs) ± 12.777 ms | 171.523 μs (262.527 μs) ± 11.766 ms | invariant (-1.95%) |
/conversions/n=384/form/Matrix |
282.046 μs (382.745 μs) ± 19.916 ms | 283.648 μs (416.359 μs) ± 19.299 ms | invariant (0.57%) |
/conversions/n=384/form/SparseMatrixCSC |
1.269 ms (1.384 ms) ± 8.921 ms | 1.421 ms (1.559 ms) ± 9.582 ms | invariant (11.98%) |
/conversions/n=384/ising/Matrix |
276.627 μs (388.603 μs) ± 19.954 ms | 313.002 μs (416.625 μs) ± 19.583 ms | invariant (13.15%) |
/conversions/n=384/qubo/Matrix/min |
6.269 ms (6.457 ms) ± 40.104 ms | 6.156 ms (6.339 ms) ± 38.728 ms | invariant (-1.80%) |
/conversions/n=384/qubo/Matrix |
3.156 ms (3.327 ms) ± 32.263 ms | 3.107 ms (3.272 ms) ± 30.590 ms | invariant (-1.56%) |
/evaluation/n=128/value/dense-form |
363.165 μs (396.234 μs) ± 692.988 μs | 363.907 μs (395.513 μs) ± 574.227 μs | invariant (0.20%) |
/evaluation/n=128/value/dict-form |
266.894 μs (268.636 μs) ± 7.346 μs | 270.048 μs (270.950 μs) ± 8.173 μs | invariant (1.18%) |
/evaluation/n=128/value/model |
51.476 μs (82.957 μs) ± 684.417 μs | 51.196 μs (54.159 μs) ± 388.829 μs | invariant (-0.54%) |
/evaluation/n=128/value/sparse-form |
51.416 μs (83.132 μs) ± 694.488 μs | 51.296 μs (79.442 μs) ± 506.765 μs | invariant (-0.23%) |
/evaluation/n=384/value/dense-form |
4.148 ms (4.178 ms) ± 228.578 μs | 4.231 ms (4.268 ms) ± 200.773 μs | invariant (2.00%) |
/evaluation/n=384/value/dict-form |
505.716 μs (514.238 μs) ± 10.937 μs | 511.955 μs (520.208 μs) ± 7.341 μs | invariant (1.23%) |
/evaluation/n=384/value/model |
173.165 μs (180.126 μs) ± 211.001 μs | 167.918 μs (186.015 μs) ± 170.715 μs | invariant (-3.03%) |
/evaluation/n=384/value/sparse-form |
172.184 μs (179.044 μs) ± 222.001 μs | 176.460 μs (186.575 μs) ± 202.065 μs | invariant (2.48%) |
Summary
sampleset_table,write_samples, andread_samplesfor solver-independentSampleSetdistribution caches.rank,state,reads,value,probability, with optional probability omission and native/reverse state ordering.Closes #93
Tests
/home/bernalde/.juliaup/bin/julialauncher +release --project=. -e 'using QUBOTools; println("loaded")'/home/bernalde/.juliaup/bin/julialauncher +release --project=. -e 'using QUBOTools; mktempdir() do dir; sol = SampleSet{Float64,Int}(Sample{Float64,Int}[Sample([0, 1], 1.0, 2), Sample([0, 1], 1.0, 3)]; metadata = Dict{String,Any}("origin" => "smoke"), sense = :min, domain = :bool); path = joinpath(dir, "samples.csv"); write_samples(path, sol); dst = read_samples(path); @assert length(dst) == 1; @assert QUBOTools.reads(dst, 1) == 5; @assert QUBOTools.metadata(dst)["origin"] == "smoke"; @assert sampleset_table(dst)[1].probability == 1.0; end; println("samples smoke ok")'/home/bernalde/.juliaup/bin/julialauncher +release --project=. -e 'using QUBOTools; mktempdir() do dir; path = joinpath(dir, "dups.csv"); open(path, "w") do io; println(io, "rank,state,reads,value,probability"); println(io, "1,01,2,1.0,0.4"); println(io, "2,01,3,1.0,0.6"); end; dst = read_samples(path); @assert length(dst) == 1; @assert QUBOTools.state(dst, 1) == [0, 1]; @assert QUBOTools.reads(dst, 1) == 5; end; println("no metadata import ok")'/home/bernalde/.juliaup/bin/julialauncher +release --project=. -e 'using QUBOTools; mktempdir() do dir; sol = SampleSet{Float64,Int}(Sample{Float64,Int}[Sample([1, -1, 1], 2.0, 4)]; sense = :max, domain = :spin); path = joinpath(dir, "spin.csv"); meta = joinpath(dir, "spin.json"); write_samples(path, sol; metadata_path = meta, bit_order = :reverse); dst = read_samples(path; metadata_path = meta); @assert QUBOTools.state(dst, 1) == [1, -1, 1]; @assert QUBOTools.sense(dst) === QUBOTools.Max; @assert QUBOTools.domain(dst) === QUBOTools.SpinDomain; end; println("metadata bit order ok")'/home/bernalde/.juliaup/bin/julialauncher +release --project=. -e 'using Pkg; Pkg.test()'passes with 671 tests.Notes:
Pkg.test()re-resolved the temporary test environment and emitted the repo's existing Documenter warning about undocumented docstrings; the suite passed.