diff --git a/backends/native/runtime/CMakeLists.txt b/backends/native/runtime/CMakeLists.txt index c1fb34d4654..9ba19a624e3 100644 --- a/backends/native/runtime/CMakeLists.txt +++ b/backends/native/runtime/CMakeLists.txt @@ -45,8 +45,10 @@ add_library(native_reference_gguf_kquant utils/ReferenceGgufKQuant.cpp) target_link_libraries(native_reference_gguf_kquant PRIVATE Threads::Threads) add_library( - native_package deserialize/OwnedBytes.cpp deserialize/Package.cpp - deserialize/SafeTensorsReader.cpp deserialize/ZipReader.cpp + native_package + deserialize/OwnedBytes.cpp deserialize/Package.cpp + deserialize/SafeTensorsReader.cpp deserialize/Samples.cpp + deserialize/ZipReader.cpp ) target_link_libraries( native_package diff --git a/backends/native/runtime/deserialize/Samples.cpp b/backends/native/runtime/deserialize/Samples.cpp new file mode 100644 index 00000000000..a45e23b288a --- /dev/null +++ b/backends/native/runtime/deserialize/Samples.cpp @@ -0,0 +1,274 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ptn { +namespace { + +bool is_ssa_name(std::string_view name) { + return !name.empty() && !std::isdigit(static_cast(name[0])) && + std::all_of(name.begin(), name.end(), [](char c) { + return std::isalnum(static_cast(c)) || c == '_'; + }); +} + +bool is_set_name(std::string_view name) { + return !name.empty() && std::all_of(name.begin(), name.end(), [](char c) { + return std::isalnum(static_cast(c)) || c == '_' || c == '-'; + }); +} + +bool is_digits(std::string_view text) { + return !text.empty() && (text == "0" || text[0] != '0') && + std::all_of(text.begin(), text.end(), [](char c) { + return std::isdigit(static_cast(c)); + }); +} + +// "MAJOR.MINOR" with major 1; a newer minor only adds fields. +bool is_supported_version(const Json& version) { + if (!version.is_string()) { + return false; + } + const std::string_view text = version.get_ref(); + const size_t dot = text.find('.'); + return dot != std::string_view::npos && text.substr(0, dot) == "1" && + is_digits(text.substr(dot + 1)); +} + +// A newer minor may add fields that reference tensors this reader does not +// know. +bool is_newer_minor(const Json& version) { + const std::string_view text = version.get_ref(); + return text.substr(text.find('.') + 1) != "0"; +} + +// Rethrows the active exception prefixed with `where`, keeping the type of +// limit errors. +[[noreturn]] void rethrow_in(const std::string& where) { + try { + throw; + } catch (const ResourceLimitError& error) { + throw ResourceLimitError(where + ": " + error.what()); + } catch (const std::exception& error) { + throw std::runtime_error(where + ": " + error.what()); + } +} + +class SetDecoder { + private: + std::string where_; + std::vector blob_; + SafeTensorsReader tensors_; + size_t data_start_ = 0; + std::unordered_set referenced_; + + public: + SetDecoder(std::string where, std::vector blob) + : where_(std::move(where)), + blob_(std::move(blob)), + tensors_(SafeTensorsReader::open(blob_)) { + data_start_ = SafeTensorsReader::kLengthPrefixSize + + SafeTensorsReader::header_size(ByteSpan(blob_).first( + SafeTensorsReader::kLengthPrefixSize)); + } + + [[noreturn]] void fail(const std::string& message) const { + throw std::runtime_error(where_ + ": " + message); + } + + std::optional tol( + const Json& object, + std::optional fallback) const { + const auto it = object.find("tol"); + if (it == object.end()) { + return fallback; + } + if (!it->is_array() || it->size() != 2) { + fail("tol must be [atol, rtol]"); + } + for (const Json& t : *it) { + if (!t.is_number() || !std::isfinite(t.get()) || + t.get() < 0) { + fail("tol must be two finite numbers >= 0"); + } + } + return Tolerance{(*it)[0].get(), (*it)[1].get()}; + } + + SampleTensor tensor(const Json& value, const std::string& key) { + if (!value.is_string()) { + fail(key + " must be a tensor; scalar and list values are not supported"); + } + const std::string& name = value.get_ref(); + if (name != key) { + fail("key " + name + " must be " + key); + } + const TensorEntry* entry = tensors_.find(name); + if (entry == nullptr) { + fail("no tensor named " + name); + } + referenced_.insert(name); + const uint8_t* begin = blob_.data() + data_start_ + entry->offset; + return {entry->dtype, entry->sizes, {begin, begin + entry->nbytes}}; + } + + std::vector tensors( + const Json& values, + const std::string& key) { + if (!values.is_array()) { + fail(key + " must be a list"); + } + std::vector result; + result.reserve(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + result.push_back(tensor(values[i], key + "[" + std::to_string(i) + "]")); + } + return result; + } + + SampleCall call( + const Json& entry, + const std::string& at, + std::optional fallback) { + if (!entry.is_object() || !entry.contains("in")) { + fail(at + " must be an object with an 'in' list"); + } + const auto method = entry.find("method"); + if (method == entry.end() || !method->is_string() || + method->get_ref().empty()) { + fail(at + ".method must be a non-empty string"); + } + SampleCall result; + result.method = method->get(); + result.inputs = tensors(entry["in"], at + ".in"); + if (entry.contains("out")) { + result.outputs = tensors(entry["out"], at + ".out"); + } + if (entry.contains("intermediates")) { + const Json& intermediates = entry["intermediates"]; + if (!intermediates.is_object()) { + fail(at + ".intermediates must be an object"); + } + const std::string key_prefix = at + ".intermediates."; + for (const auto& [name, key] : intermediates.items()) { + if (!is_ssa_name(name)) { + fail(std::string("bad intermediate ") + .append(name) + .append(" in ") + .append(at)); + } + result.intermediates.emplace(name, tensor(key, key_prefix + name)); + } + } + result.tol = tol(entry, fallback); + return result; + } + + void check_all_referenced() const { + for (const std::string& name : tensors_.names()) { + if (!referenced_.contains(name)) { + fail("unreferenced tensor " + name); + } + } + } +}; + +} // namespace + +// cppcheck-suppress unusedFunction +SampleSet read_sample_set(const ZipReader& zip, const std::string& set) { + if (!is_set_name(set)) { + throw std::runtime_error( + "samples: set name '" + set + "' must match [A-Za-z0-9_-]+"); + } + const std::string prefix = "samples/" + set + "/"; + const std::string where = "samples: " + prefix; + const std::optional index_size = + zip.member_size(prefix + "index.json"); + if (!index_size || !zip.member_size(prefix + "data.safetensors")) { + throw std::runtime_error( + where + ": missing index.json or data.safetensors"); + } + if (*index_size > detail::kMaxJsonBytes) { + throw ResourceLimitError(where + ": index.json exceeds size limit"); + } + const std::vector index_bytes = zip.read(prefix + "index.json"); + Json index; + try { + index = parse_json(std::string_view( + reinterpret_cast(index_bytes.data()), index_bytes.size())); + } catch (...) { + rethrow_in(where + ": invalid index.json"); + } + if (!index.is_object()) { + throw std::runtime_error(where + ": index.json must contain a JSON object"); + } + const auto version = index.find("version"); + if (version == index.end() || !is_supported_version(*version)) { + throw UnsupportedVersionError( + where + ": unsupported version, expected 1.0"); + } + std::optional decoder; + try { + decoder.emplace(where, zip.read(prefix + "data.safetensors")); + } catch (...) { + rethrow_in(where); + } + + SampleSet result; + for (const auto& [field, value] : + {std::pair{"description", &result.description}, + std::pair{"source", &result.source}}) { + if (index.contains(field)) { + if (!index[field].is_string()) { + decoder->fail(std::string(field) + " must be a string"); + } + *value = index[field].get(); + } + } + const auto entries = index.find("samples"); + if (entries == index.end() || !entries->is_array() || entries->empty()) { + decoder->fail("samples must be a non-empty list"); + } + const std::optional set_tol = decoder->tol(index, std::nullopt); + for (size_t i = 0; i < entries->size(); ++i) { + const Json& entry = (*entries)[i]; + const std::string at = "samples[" + std::to_string(i) + "]"; + if (!entry.is_object() || !entry.contains("calls") || + !entry["calls"].is_array() || entry["calls"].empty()) { + decoder->fail(at + " must be an object with a non-empty 'calls' list"); + } + const std::optional sample_tol = decoder->tol(entry, set_tol); + Sample& sample = result.samples.emplace_back(); + const Json& calls = entry["calls"]; + for (size_t j = 0; j < calls.size(); ++j) { + sample.calls.push_back(decoder->call( + calls[j], at + ".calls[" + std::to_string(j) + "]", sample_tol)); + } + } + if (!is_newer_minor(*version)) { + decoder->check_all_referenced(); + } + return result; +} + +} // namespace ptn diff --git a/backends/native/runtime/deserialize/Samples.h b/backends/native/runtime/deserialize/Samples.h new file mode 100644 index 00000000000..bbbac2035c6 --- /dev/null +++ b/backends/native/runtime/deserialize/Samples.h @@ -0,0 +1,61 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +namespace ptn { + +// A recorded tensor, copied out of the package as contiguous little-endian +// bytes. +struct SampleTensor { + ScalarType dtype = kFloat; + std::vector sizes; + std::vector bytes; +}; + +struct Tolerance { + double atol = 0.0; + double rtol = 0.0; +}; + +struct SampleCall { + std::string method; + std::vector inputs; + // Absent when the producer recorded no outputs. + std::optional> outputs; + std::map intermediates; + // The call's tolerance, else its sample's, else its set's. + std::optional tol; +}; + +// Calls run in order from the program's initial state, with no reset between. +struct Sample { + std::vector calls; +}; + +struct SampleSet { + std::string description; + std::string source; + std::vector samples; +}; + +// Reads samples//, specified in backends/native/README.md. Every in and +// out value must be a real-valued tensor; scalar, list and complex values are +// not supported. Throws std::runtime_error if the set is absent or violates the +// format: UnsupportedVersionError for another major version, and +// ResourceLimitError when it exceeds a reader limit. +SampleSet read_sample_set(const ZipReader& zip, const std::string& set); + +} // namespace ptn diff --git a/backends/native/runtime/deserialize/targets.bzl b/backends/native/runtime/deserialize/targets.bzl index 00732a5ef64..cc52929be42 100644 --- a/backends/native/runtime/deserialize/targets.bzl +++ b/backends/native/runtime/deserialize/targets.bzl @@ -105,6 +105,24 @@ def define_common_targets(): ], visibility = ["PUBLIC"], ) + # Recorded method calls bundled under samples/ in a .ptn package. + runtime.cxx_library( + name = "samples", + srcs = ["Samples.cpp"], + exported_headers = ["Samples.h"], + exported_deps = [ + ":zip_reader", + "//executorch/backends/native/runtime/graph:scalar_type", + ], + deps = [ + ":byte_span", + ":deserialize_error", + ":json", + ":limits", + ":safetensors_reader", + ], + visibility = ["PUBLIC"], + ) runtime.cxx_test( name = "reader_bounds_test", srcs = ["test/ReaderBoundsTest.cpp"], diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index 68b9b119f94..6a59002dae9 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable( ../runtime/deserialize/test/ValidationTest.cpp ../runtime/test/MethodMetaTest.cpp ../runtime/test/ProgramVersionTest.cpp + runtime/deserialize/test_samples.cpp runtime/graph/test_graph.cpp runtime/graph/test_graph_utils.cpp runtime/graph/test_memory_planning.cpp diff --git a/backends/native/test/runtime/deserialize/targets.bzl b/backends/native/test/runtime/deserialize/targets.bzl index 15a46819dd8..93b5bc0c854 100644 --- a/backends/native/test/runtime/deserialize/targets.bzl +++ b/backends/native/test/runtime/deserialize/targets.bzl @@ -28,6 +28,16 @@ def define_common_targets(): ], ) + runtime.cxx_test( + name = "samples_test", + srcs = ["test_samples.cpp"], + deps = [ + "//executorch/backends/native/runtime/deserialize:deserialize_error", + "//executorch/backends/native/runtime/deserialize:package_test_data", + "//executorch/backends/native/runtime/deserialize:samples", + ], + ) + runtime.cxx_test( name = "zip_reader_test", srcs = ["test_zip_reader.cpp"], diff --git a/backends/native/test/runtime/deserialize/test_samples.cpp b/backends/native/test/runtime/deserialize/test_samples.cpp new file mode 100644 index 00000000000..bfb566ce1fb --- /dev/null +++ b/backends/native/test/runtime/deserialize/test_samples.cpp @@ -0,0 +1,258 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ptn { +namespace { + +using testing::make_safetensors; +using testing::make_zip; + +std::vector bytes_of(const std::string& text) { + return {text.begin(), text.end()}; +} + +// A zip holding samples/golden/ with `index` and a safetensors blob +// of `header` over `data`. +std::vector make_set( + const std::string& index, + const std::string& header, + const std::string& data) { + std::vector tensors = make_safetensors(header, data.size()); + std::memcpy( + tensors.data() + tensors.size() - data.size(), data.data(), data.size()); + return make_zip( + {{"program.ptg", {}}, + {"samples/golden/index.json", bytes_of(index)}, + {"samples/golden/data.safetensors", tensors}}); +} + +const std::string kTwoCallHeader = + R"({"samples[0].calls[0].in[0]":{"dtype":"U8","shape":[1,2],"data_offsets":[0,2]},)" + R"("samples[0].calls[0].out[0]":{"dtype":"F32","shape":[1],"data_offsets":[2,6]},)" + R"("samples[0].calls[0].intermediates.h":{"dtype":"U8","shape":[1],"data_offsets":[6,7]},)" + R"("samples[0].calls[1].in[0]":{"dtype":"U8","shape":[1,1],"data_offsets":[7,8]},)" + R"("samples[1].calls[0].in[0]":{"dtype":"U8","shape":[1],"data_offsets":[8,9]}})"; +const std::string kTwoCallData = + std::string("ab") + std::string("\x00\x00\x80\x3f", 4) + "hcd"; + +SampleSet read(const std::vector& archive) { + return read_sample_set(ZipReader::open(archive), "golden"); +} + +void expect_error( + const std::vector& archive, + const std::string& set, + const std::string& message) { + try { + read_sample_set(ZipReader::open(archive), set); + ADD_FAILURE() << "expected an exception"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find(message), std::string::npos) + << error.what(); + } +} + +// cppcheck-suppress-begin syntaxError +TEST(SamplesTest, ReadsCallsInOrderWithResolvedTolerance) { + const std::string index = + R"({"version":"1.7","description":"d","source":"eager", + "tol":[0.001,0.002],"unknown":0,"samples":[ + {"tol":[0.0001,0.0002],"calls":[ + {"method":"prefill","in":["samples[0].calls[0].in[0]"], + "out":["samples[0].calls[0].out[0]"], + "intermediates":{"h":"samples[0].calls[0].intermediates.h"}}, + {"method":"decode","in":["samples[0].calls[1].in[0]"],"tol":[0,0], + "unknown":0}]}, + {"calls":[{"method":"forward","in":["samples[1].calls[0].in[0]"]}]}]})"; + + const SampleSet set = read(make_set(index, kTwoCallHeader, kTwoCallData)); + + EXPECT_EQ(set.description, "d"); + EXPECT_EQ(set.source, "eager"); + ASSERT_EQ(set.samples.size(), 2); + ASSERT_EQ(set.samples[0].calls.size(), 2); + const SampleCall& prefill = set.samples[0].calls[0]; + EXPECT_EQ(prefill.method, "prefill"); + ASSERT_EQ(prefill.inputs.size(), 1); + EXPECT_EQ(prefill.inputs[0].dtype, kByte); + EXPECT_EQ(prefill.inputs[0].sizes, (std::vector{1, 2})); + EXPECT_EQ(prefill.inputs[0].bytes, bytes_of("ab")); + ASSERT_TRUE(prefill.outputs); + ASSERT_EQ(prefill.outputs->size(), 1); + EXPECT_EQ((*prefill.outputs)[0].dtype, kFloat); + float one = 0; + std::memcpy(&one, (*prefill.outputs)[0].bytes.data(), sizeof(one)); + EXPECT_EQ(one, 1.0f); + EXPECT_EQ(prefill.intermediates.at("h").bytes, bytes_of("h")); + ASSERT_TRUE(prefill.tol); + EXPECT_EQ(prefill.tol->atol, 0.0001); + EXPECT_EQ(prefill.tol->rtol, 0.0002); + const SampleCall& decode = set.samples[0].calls[1]; + EXPECT_EQ(decode.method, "decode"); + EXPECT_EQ(decode.inputs[0].bytes, bytes_of("c")); + EXPECT_FALSE(decode.outputs); + ASSERT_TRUE(decode.tol); + EXPECT_EQ(decode.tol->atol, 0.0); + EXPECT_EQ(set.samples[1].calls[0].method, "forward"); + ASSERT_TRUE(set.samples[1].calls[0].tol); + EXPECT_EQ(set.samples[1].calls[0].tol->atol, 0.001); +} + +TEST(SamplesTest, LeavesToleranceUnsetWhenNoLevelHasOne) { + const SampleSet set = read(make_set( + R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":["samples[0].calls[0].in[0]"]}]}]})", + R"({"samples[0].calls[0].in[0]":{"dtype":"U8","shape":[1],"data_offsets":[0,1]}})", + "a")); + + EXPECT_FALSE(set.samples[0].calls[0].tol); +} + +TEST(SamplesTest, RejectsMalformedSets) { + const std::string one_tensor = + R"({"samples[0].calls[0].in[0]":{"dtype":"U8","shape":[1],"data_offsets":[0,1]}})"; + const std::string one_call = + R"("samples":[{"calls":[{"method":"forward","in":["samples[0].calls[0].in[0]"]}]}])"; + const std::string call0 = + R"("method":"forward","in":["samples[0].calls[0].in[0]"])"; + const auto with_call = [&](const std::string& fields) { + return R"({"version":"1.0","samples":[{"calls":[{)" + call0 + "," + fields + + "}]}]}"; + }; + const std::vector> cases = { + {"[]", "must contain a JSON object"}, + {R"({"version":)", "invalid index.json"}, + {R"({"version":"2.0",)" + one_call + "}", "unsupported version"}, + {R"({"version":1,)" + one_call + "}", "unsupported version"}, + {R"({"version":"1",)" + one_call + "}", "unsupported version"}, + {R"({"version":"1.01",)" + one_call + "}", "unsupported version"}, + {R"({"version":"1.x",)" + one_call + "}", "unsupported version"}, + {R"({"version":"1.0","samples":[]})", "samples must be a non-empty list"}, + {R"({"version":"1.0","samples":[{"calls":[]}]})", + "samples[0] must be an object with a non-empty 'calls' list"}, + {R"({"version":"1.0","samples":[{"in":["samples[0].calls[0].in[0]"]}]})", + "samples[0] must be an object with a non-empty 'calls' list"}, + {R"({"version":"1.0","samples":[{"calls":[1]}]})", + "samples[0].calls[0] must be an object with an 'in' list"}, + {R"({"version":"1.0","samples":[{"tol":[1],"calls":[{"method":"forward","in":["samples[0].calls[0].in[0]"]}]}]})", + "tol must be [atol, rtol]"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":["samples[0].in[0]"]}]}]})", + "key samples[0].in[0] must be samples[0].calls[0].in[0]"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":["samples[0].calls[0].in[0]",1]}]}]})", + "samples[0].calls[0].in[1] must be a tensor"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":[["samples[0].calls[0].in[0]"]]}]}]})", + "samples[0].calls[0].in[0] must be a tensor"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":"samples[0].calls[0].in[0]"}]}]})", + "samples[0].calls[0].in must be a list"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":[]}]}]})", + "unreferenced tensor samples[0].calls[0].in[0]"}, + {R"({"version":"1.0","samples":[{"calls":[{"in":["samples[0].calls[0].in[0]"]}]}]})", + "samples[0].calls[0].method must be a non-empty string"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":"","in":["samples[0].calls[0].in[0]"]}]}]})", + "samples[0].calls[0].method must be a non-empty string"}, + {R"({"version":"1.0","samples":[{"calls":[{"method":1,"in":["samples[0].calls[0].in[0]"]}]}]})", + "samples[0].calls[0].method must be a non-empty string"}, + {R"({"version":"1.0","tol":[1],)" + one_call + "}", + "tol must be [atol, rtol]"}, + {R"({"version":"1.0","tol":[-1,0],)" + one_call + "}", + "tol must be two finite numbers"}, + {R"({"version":"1.0","tol":[true,0],)" + one_call + "}", + "tol must be two finite numbers"}, + {R"({"version":"1.0","description":1,)" + one_call + "}", + "description must be a string"}, + {R"({"version":"1.0","source":null,)" + one_call + "}", + "source must be a string"}, + {with_call(R"("out":"samples[0].calls[0].out[0]")"), + "samples[0].calls[0].out must be a list"}, + {with_call(R"("out":["samples[0].calls[0].out[0]"])"), + "no tensor named samples[0].calls[0].out[0]"}, + {with_call(R"("intermediates":[])"), + "samples[0].calls[0].intermediates must be an object"}, + {with_call( + R"("intermediates":{"a.b":"samples[0].calls[0].intermediates.a.b"})"), + "bad intermediate a.b in samples[0].calls[0]"}, + {with_call(R"("intermediates":{"h":1})"), + "samples[0].calls[0].intermediates.h must be a tensor"}, + {with_call(R"("tol":[0,-1])"), "tol must be two finite numbers"}, + }; + for (const auto& [index, message] : cases) { + SCOPED_TRACE(index); + try { + read(make_set(index, one_tensor, "a")); + ADD_FAILURE() << "expected an exception"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find(message), std::string::npos) + << error.what(); + } + } +} + +TEST(SamplesTest, RejectsAbsentOrMisnamedSets) { + const std::vector index_only = make_zip( + {{"program.ptg", {}}, + {"samples/golden/index.json", bytes_of(R"({"version":"1.0"})")}}); + + expect_error(index_only, "golden", "missing index.json or data.safetensors"); + expect_error(index_only, "other", "missing index.json or data.safetensors"); + expect_error(index_only, "gol/den", "must match [A-Za-z0-9_-]+"); +} + +TEST(SamplesTest, ChecksVersionBeforeReadingData) { + const std::vector archive = make_zip( + {{"program.ptg", {}}, + {"samples/golden/index.json", bytes_of(R"({"version":"2.0"})")}, + {"samples/golden/data.safetensors", bytes_of("not safetensors")}}); + + EXPECT_THROW( + read_sample_set(ZipReader::open(archive), "golden"), + UnsupportedVersionError); +} + +TEST(SamplesTest, PrefixesDataErrorsWithTheSet) { + const std::string index = + R"({"version":"1.0","samples":[{"calls":[{"method":"forward","in":["samples[0].calls[0].in[0]"]}]}]})"; + + expect_error( + make_set( + index, + R"({"samples[0].calls[0].in[0]":{"dtype":"C64","shape":[1],"data_offsets":[0,8]}})", + std::string(8, '\0')), + "golden", + "samples: samples/golden/: safetensors:"); +} + +TEST(SamplesTest, NewerMinorMayLeaveTensorsUnreferenced) { + const std::string header = + R"({"samples[0].calls[0].in[0]":{"dtype":"U8","shape":[1],"data_offsets":[0,1]},)" + R"("samples[0].calls[0].future":{"dtype":"U8","shape":[1],"data_offsets":[1,2]}})"; + const auto index = [](const std::string& version) { + return R"({"version":")" + version + + R"(","samples":[{"calls":[{"method":"forward","in":["samples[0].calls[0].in[0]"],"future":"samples[0].calls[0].future"}]}]})"; + }; + + EXPECT_EQ(read(make_set(index("1.1"), header, "ab")).samples.size(), 1); + expect_error( + make_set(index("1.0"), header, "ab"), + "golden", + "unreferenced tensor samples[0].calls[0].future"); +} +// cppcheck-suppress-end syntaxError + +} // namespace +} // namespace ptn