Merge pull request #486 from SYaoJun/223_emplace_back

perf: Replace push_back with emplace_back to optimize object construc…
diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644
index 0000000..d0cdc6e
--- /dev/null
+++ b/.clang-tidy
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+---
+Checks: |
+  clang-diagnostic-*,
+  clang-analyzer-*,
+  -clang-analyzer-alpha*,
+  google-*,
+  modernize-*,
+  -modernize-avoid-c-arrays,
+  -modernize-use-trailing-return-type,
+  -modernize-use-nodiscard,
+
+CheckOptions:
+  - key:             google-readability-braces-around-statements.ShortStatementLines
+    value:           '0'
+  - key:             google-readability-function-size.StatementThreshold
+    value:           '800'
+  - key:             google-readability-namespace-comments.ShortNamespaceLines
+    value:           '10'
+  - key:             google-readability-namespace-comments.SpacesBeforeComments
+    value:           '2'
\ No newline at end of file
diff --git a/.github/workflows/hardening.yml b/.github/workflows/hardening.yml
new file mode 100644
index 0000000..e264ebd
--- /dev/null
+++ b/.github/workflows/hardening.yml
@@ -0,0 +1,59 @@
+name: libc++ Hardening Tests
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+  workflow_dispatch:
+
+env:
+  BUILD_TYPE: Debug
+
+jobs:
+  hardening-test:
+    name: C++17 with libc++ Hardening Mode
+    runs-on: ubuntu-latest
+    
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          submodules: true
+          persist-credentials: false
+      
+      - name: Install LLVM and libc++
+        run: |
+          # Install LLVM/Clang with libc++
+          wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add -
+          sudo add-apt-repository "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-18 main"
+          sudo apt-get update
+          sudo apt-get install -y clang-18 libc++-18-dev libc++abi-18-dev
+          
+      - name: Configure with C++17 and libc++ hardening
+        env:
+          CC: clang-18
+          CXX: clang++-18
+        run: |
+          cmake -B build -S . \
+            -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} \
+            -DCMAKE_CXX_STANDARD=17 \
+            -DBUILD_TESTS=ON \
+            -DCMAKE_CXX_FLAGS="-stdlib=libc++ -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG" \
+            -DCMAKE_EXE_LINKER_FLAGS="-stdlib=libc++ -lc++abi"
+      
+      - name: Build hardening tests
+        run: cmake --build build --target hardening_test --config ${{ env.BUILD_TYPE }}
+      
+      - name: Run hardening tests
+        run: |
+          cd build
+          ./common/test/hardening_test "[deserialize_hardening]"
+      
+      - name: Report results
+        if: always()
+        run: |
+          echo "✅ Tests passed with libc++ hardening enabled!"
+          echo "This verifies the fix for issue #477 prevents SIGABRT."
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..262fd02
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# To use this, install the python package `pre-commit` and
+# run once `pre-commit install`. This will setup a git pre-commit-hook
+# that is executed on each commit and will report the linting problems.
+# To run all hooks on all files use `pre-commit run -a`
+
+repos:
+  - repo: https://github.com/pocc/pre-commit-hooks
+    rev: v1.3.5
+    hooks:
+      - id: clang-tidy
+        args: ['--quiet', '-p=build/compile_commands.json', '--config-file=.clang-tidy']
+        types_or: [c++, c]
\ No newline at end of file
diff --git a/common/include/serde.hpp b/common/include/serde.hpp
index ad20fe6..c4e46d7 100644
--- a/common/include/serde.hpp
+++ b/common/include/serde.hpp
@@ -132,6 +132,11 @@
 /// ItemsSketch<String> with ArrayOfStringsSerDe in Java.
 /// The length of each string is stored as a 32-bit integer (historically),
 /// which may be too wasteful. Treat this as an example.
+///
+/// This implementation treats std::string as an arbitrary byte container.
+/// It does not check whether string contents are valid UTF-8.
+///
+/// Use a UTF-8-validating SerDe when cross-language portability is required.
 template<>
 struct serde<std::string> {
   /// @copydoc serde::serialize
diff --git a/common/test/CMakeLists.txt b/common/test/CMakeLists.txt
index 7593bd0..c3e937a 100644
--- a/common/test/CMakeLists.txt
+++ b/common/test/CMakeLists.txt
@@ -75,10 +75,15 @@
 # now the integration test part
 add_executable(integration_test)
 
-target_link_libraries(integration_test count cpc density fi hll kll req sampling theta tuple common_test_lib)
+target_link_libraries(integration_test count cpc density fi hll kll req sampling theta tuple quantiles common_test_lib)
 
+# Use CMAKE_CXX_STANDARD if defined, otherwise C++11
+set(_integration_cxx_standard 11)
+if(DEFINED CMAKE_CXX_STANDARD)
+  set(_integration_cxx_standard ${CMAKE_CXX_STANDARD})
+endif()
 set_target_properties(integration_test PROPERTIES
-  CXX_STANDARD 11
+  CXX_STANDARD ${_integration_cxx_standard}
   CXX_STANDARD_REQUIRED YES
 )
 
@@ -91,3 +96,39 @@
   PRIVATE
     integration_test.cpp
 )
+
+# Separate hardening test executable (header-only, no pre-compiled libs)
+# This ensures the sketch code is compiled with C++17 + hardening
+# Always build this target - it will use CMAKE_CXX_STANDARD if set (and >= 17), otherwise C++17
+
+add_executable(hardening_test)
+target_link_libraries(hardening_test common common_test_lib)
+
+# Include directories for header-only sketch implementations
+target_include_directories(hardening_test PRIVATE
+  ${CMAKE_SOURCE_DIR}/quantiles/include
+  ${CMAKE_SOURCE_DIR}/kll/include
+  ${CMAKE_SOURCE_DIR}/req/include
+  ${CMAKE_SOURCE_DIR}/common/include
+)
+
+# Use C++17 minimum for hardening tests
+set(_hardening_cxx_standard 17)
+if(DEFINED CMAKE_CXX_STANDARD AND CMAKE_CXX_STANDARD GREATER_EQUAL 17)
+  set(_hardening_cxx_standard ${CMAKE_CXX_STANDARD})
+endif()
+set_target_properties(hardening_test PROPERTIES
+  CXX_STANDARD ${_hardening_cxx_standard}
+  CXX_STANDARD_REQUIRED YES
+)
+message(STATUS "hardening_test will use C++${_hardening_cxx_standard}")
+
+add_test(
+  NAME hardening_test
+  COMMAND hardening_test "[deserialize_hardening]"
+)
+
+target_sources(hardening_test
+  PRIVATE
+    deserialize_hardening_test.cpp
+)
diff --git a/common/test/deserialize_hardening_test.cpp b/common/test/deserialize_hardening_test.cpp
new file mode 100644
index 0000000..64e654b
--- /dev/null
+++ b/common/test/deserialize_hardening_test.cpp
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <catch2/catch.hpp>
+#include <sstream>
+#include <vector>
+
+// Include all affected sketch types
+#include <quantiles_sketch.hpp>
+#include <kll_sketch.hpp>
+#include <req_sketch.hpp>
+
+namespace datasketches {
+
+/**
+ * Test for fix of issue #477:
+ * BUG: SIGABRT in deserialize(): dereferencing empty std::optional (libc++ verbose_abort)
+ * 
+ * These tests exercise the actual deserialization code path that contained the bug.
+ * With buggy code (&*tmp on empty optional) and hardening enabled, these will SIGABRT.
+ * With fixed code (aligned_storage), these pass normally.
+ * 
+ * IMPORTANT: These tests actually call deserialize() on multi-item sketches, which
+ * exercises the buggy code path where min/max are deserialized.
+ */
+
+TEST_CASE("quantiles_sketch: deserialize multi-item sketch", "[deserialize_hardening]") {
+  // Create sketch with multiple items (so min/max are stored in serialization)
+  quantiles_sketch<double> sketch(128);
+  for (int i = 0; i < 1000; i++) {
+    sketch.update(static_cast<double>(i));
+  }
+  
+  // Serialize
+  auto bytes = sketch.serialize();
+  
+  // Deserialize - WITH BUGGY CODE AND HARDENING, THIS WILL SIGABRT HERE
+  // The bug is: sd.deserialize(is, &*tmp, 1) where tmp is empty optional
+  auto sketch2 = quantiles_sketch<double>::deserialize(bytes.data(), bytes.size());
+  
+  // Verify deserialization worked correctly
+  REQUIRE(sketch2.get_n() == sketch.get_n());
+  REQUIRE(sketch2.get_min_item() == sketch.get_min_item());
+  REQUIRE(sketch2.get_max_item() == sketch.get_max_item());
+  REQUIRE(sketch2.get_quantile(0.5) == sketch.get_quantile(0.5));
+}
+
+TEST_CASE("quantiles_sketch: deserialize from stream", "[deserialize_hardening]") {
+  quantiles_sketch<float> sketch(256);
+  for (int i = 0; i < 2000; i++) {
+    sketch.update(static_cast<float>(i) * 0.5f);
+  }
+  
+  // Serialize to stream
+  std::stringstream ss;
+  sketch.serialize(ss);
+  
+  // Deserialize from stream - exercises the buggy code path
+  auto sketch2 = quantiles_sketch<float>::deserialize(ss);
+  
+  REQUIRE(sketch2.get_n() == sketch.get_n());
+  REQUIRE(sketch2.get_min_item() == sketch.get_min_item());
+  REQUIRE(sketch2.get_max_item() == sketch.get_max_item());
+}
+
+TEST_CASE("kll_sketch: deserialize multi-item sketch", "[deserialize_hardening]") {
+  kll_sketch<float> sketch(200);
+  for (int i = 0; i < 1500; i++) {
+    sketch.update(static_cast<float>(i));
+  }
+  
+  auto bytes = sketch.serialize();
+  
+  // Deserialize - exercises buggy &*tmp code path
+  auto sketch2 = kll_sketch<float>::deserialize(bytes.data(), bytes.size());
+  
+  REQUIRE(sketch2.get_n() == sketch.get_n());
+  REQUIRE(sketch2.get_min_item() == sketch.get_min_item());
+  REQUIRE(sketch2.get_max_item() == sketch.get_max_item());
+}
+
+TEST_CASE("kll_sketch: deserialize from stream", "[deserialize_hardening]") {
+  kll_sketch<int> sketch(400);
+  for (int i = 0; i < 3000; i++) {
+    sketch.update(i);
+  }
+  
+  std::stringstream ss;
+  sketch.serialize(ss);
+  
+  // Deserialize from stream
+  auto sketch2 = kll_sketch<int>::deserialize(ss);
+  
+  REQUIRE(sketch2.get_n() == sketch.get_n());
+  REQUIRE(sketch2.get_min_item() == sketch.get_min_item());
+  REQUIRE(sketch2.get_max_item() == sketch.get_max_item());
+}
+
+TEST_CASE("req_sketch: deserialize multi-level sketch", "[deserialize_hardening]") {
+  // REQ sketch only has the bug when num_levels > 1
+  // We need to add enough items to trigger multiple levels
+  req_sketch<float> sketch(12);
+  for (int i = 0; i < 10000; i++) {
+    sketch.update(static_cast<float>(i));
+  }
+  
+  auto bytes = sketch.serialize();
+  
+  // Deserialize - exercises buggy code path when num_levels > 1
+  auto sketch2 = req_sketch<float>::deserialize(bytes.data(), bytes.size());
+  
+  REQUIRE(sketch2.get_n() == sketch.get_n());
+  REQUIRE(sketch2.get_min_item() == sketch.get_min_item());
+  REQUIRE(sketch2.get_max_item() == sketch.get_max_item());
+}
+
+TEST_CASE("req_sketch: deserialize from stream", "[deserialize_hardening]") {
+  req_sketch<double> sketch(20);
+  for (int i = 0; i < 15000; i++) {
+    sketch.update(static_cast<double>(i) * 0.1);
+  }
+  
+  std::stringstream ss;
+  sketch.serialize(ss);
+  
+  // Deserialize from stream
+  auto sketch2 = req_sketch<double>::deserialize(ss);
+  
+  REQUIRE(sketch2.get_n() == sketch.get_n());
+  REQUIRE(sketch2.get_min_item() == sketch.get_min_item());
+  REQUIRE(sketch2.get_max_item() == sketch.get_max_item());
+}
+
+TEST_CASE("multiple sketch types: stress test", "[deserialize_hardening]") {
+  SECTION("quantiles with various sizes") {
+    for (int k : {64, 128, 256}) {
+      quantiles_sketch<int> sketch(k);
+      for (int i = 0; i < 5000; i++) {
+        sketch.update(i);
+      }
+      auto bytes = sketch.serialize();
+      auto sketch2 = quantiles_sketch<int>::deserialize(bytes.data(), bytes.size());
+      REQUIRE(sketch2.get_n() == sketch.get_n());
+    }
+  }
+  
+  SECTION("kll with various sizes") {
+    for (int k : {100, 200, 400}) {
+      kll_sketch<double> sketch(k);
+      for (int i = 0; i < 4000; i++) {
+        sketch.update(static_cast<double>(i) / 10.0);
+      }
+      auto bytes = sketch.serialize();
+      auto sketch2 = kll_sketch<double>::deserialize(bytes.data(), bytes.size());
+      REQUIRE(sketch2.get_n() == sketch.get_n());
+    }
+  }
+  
+  SECTION("req with various sizes") {
+    for (int k : {12, 20}) {
+      req_sketch<float> sketch(k);
+      for (int i = 0; i < 8000; i++) {
+        sketch.update(static_cast<float>(i));
+      }
+      auto bytes = sketch.serialize();
+      auto sketch2 = req_sketch<float>::deserialize(bytes.data(), bytes.size());
+      REQUIRE(sketch2.get_n() == sketch.get_n());
+    }
+  }
+}
+
+} // namespace datasketches
diff --git a/count/include/count_min_impl.hpp b/count/include/count_min_impl.hpp
index 45376e7..2f2629f 100644
--- a/count/include/count_min_impl.hpp
+++ b/count/include/count_min_impl.hpp
@@ -39,7 +39,9 @@
 _sketch_array((num_hashes*num_buckets < 1<<30) ? num_hashes*num_buckets : 0, 0, _allocator),
 _seed(seed),
 _total_weight(0) {
-  if (num_buckets < 3) throw std::invalid_argument("Using fewer than 3 buckets incurs relative error greater than 1.");
+  if (num_buckets < 3) {
+    throw std::invalid_argument("Using fewer than 3 buckets incurs relative error greater than 1.");
+  }
 
   // This check is to ensure later compatibility with a Java implementation whose maximum size can only
   // be 2^31-1.  We check only against 2^30 for simplicity.
@@ -74,7 +76,7 @@
 
 template<typename W, typename A>
 double count_min_sketch<W,A>::get_relative_error() const {
-  return exp(1.0) / double(_num_buckets);
+  return exp(1.0) / static_cast<double>(_num_buckets);
 }
 
 template<typename W, typename A>
@@ -147,7 +149,7 @@
 
 template<typename W, typename A>
 W count_min_sketch<W,A>::get_estimate(const std::string& item) const {
-  if (item.empty()) return 0; // Empty strings are not inserted into the sketch.
+  if (item.empty()) { return 0; } // Empty strings are not inserted into the sketch.
   return get_estimate(item.c_str(), item.length());
 }
 
@@ -176,7 +178,7 @@
 
 template<typename W, typename A>
 void count_min_sketch<W,A>::update(const std::string& item, W weight) {
-  if (item.empty()) return;
+  if (item.empty()) { return; }
   update(item.c_str(), item.length(), weight);
 }
 
@@ -201,7 +203,7 @@
 
 template<typename W, typename A>
 W count_min_sketch<W,A>::get_upper_bound(const std::string& item) const {
-  if (item.empty()) return 0; // Empty strings are not inserted into the sketch.
+  if (item.empty()) { return 0; } // Empty strings are not inserted into the sketch.
   return get_upper_bound(item.c_str(), item.length());
 }
 
@@ -218,7 +220,7 @@
 
 template<typename W, typename A>
 W count_min_sketch<W,A>::get_lower_bound(const std::string& item) const {
-  if (item.empty()) return 0; // Empty strings are not inserted into the sketch.
+  if (item.empty()) { return 0; } // Empty strings are not inserted into the sketch.
   return get_lower_bound(item.c_str(), item.length());
 }
 
@@ -232,17 +234,13 @@
   /*
   * Merges this sketch into other_sketch sketch by elementwise summing of buckets
   */
-  if (this == &other_sketch) {
-    throw std::invalid_argument( "Cannot merge a sketch with itself." );
-  }
+  if (this == &other_sketch) { throw std::invalid_argument( "Cannot merge a sketch with itself." ); }
 
   bool acceptable_config =
     (get_num_hashes() == other_sketch.get_num_hashes())   &&
     (get_num_buckets() == other_sketch.get_num_buckets()) &&
     (get_seed() == other_sketch.get_seed());
-  if (!acceptable_config) {
-    throw std::invalid_argument( "Incompatible sketch configuration." );
-  }
+  if (!acceptable_config) { throw std::invalid_argument( "Incompatible sketch configuration." ); }
 
   // Merge step - iterate over the other vector and add the weights to this sketch
   auto it = _sketch_array.begin(); // This is a std::vector iterator.
@@ -290,7 +288,7 @@
   write(os, nhashes);
   write(os, seed_hash);
   write(os, unused8);
-  if (is_empty()) return; // sketch is empty, no need to write further bytes.
+  if (is_empty()) { return; } // sketch is empty, no need to write further bytes.
 
   // Long 2
   write(os, _total_weight);
@@ -327,7 +325,7 @@
   }
   count_min_sketch c(nhashes, nbuckets, seed, allocator);
   const bool is_empty = (flags_byte & (1 << flags::IS_EMPTY)) > 0;
-  if (is_empty == 1) return c; // sketch is empty, no need to read further.
+  if (is_empty == 1) { return c; } // sketch is empty, no need to read further.
 
   // Set the sketch weight and read in the sketch values
   const auto weight = read<W>(is);
@@ -373,7 +371,7 @@
   ptr += copy_to_mem(nhashes, ptr);
   ptr += copy_to_mem(seed_hash, ptr);
   ptr += copy_to_mem(null_characters_8, ptr);
-  if (is_empty()) return bytes; // sketch is empty, no need to write further bytes.
+  if (is_empty()) { return bytes; } // sketch is empty, no need to write further bytes.
 
   // Long 2
   const W t_weight = _total_weight;
@@ -423,7 +421,7 @@
   }
   count_min_sketch c(nhashes, nbuckets, seed, allocator);
   const bool is_empty = (flags_byte & (1 << flags::IS_EMPTY)) > 0;
-  if (is_empty) return c; // sketch is empty, no need to read further.
+  if (is_empty) { return c; } // sketch is empty, no need to read further.
 
   ensure_minimum_memory(size, sizeof(W) * (1 + nbuckets * nhashes));
 
@@ -449,8 +447,7 @@
   // count the number of used entries in the sketch
   uint64_t num_nonzero = 0;
   for (const auto entry: _sketch_array) {
-    if (entry != static_cast<W>(0.0))
-      ++num_nonzero;
+    if (entry != static_cast<W>(0.0)) { ++num_nonzero; }
   }
 
   // Using a temporary stream for implementation here does not comply with AllocatorAwareContainer requirements.
diff --git a/cpc/include/cpc_compressor_impl.hpp b/cpc/include/cpc_compressor_impl.hpp
index 062e2e0..0cc24b1 100644
--- a/cpc/include/cpc_compressor_impl.hpp
+++ b/cpc/include/cpc_compressor_impl.hpp
@@ -157,7 +157,7 @@
       break;
     case cpc_sketch_alloc<A>::flavor::PINNED:
       compress_pinned_flavor(source, result);
-      if (result.window_data.size() == 0) throw std::logic_error("window is not expected");
+      if (result.window_data.size() == 0) throw std::logic_error("window is expected");
       break;
     case cpc_sketch_alloc<A>::flavor::SLIDING:
       compress_sliding_flavor(source, result);
diff --git a/fi/include/frequent_items_sketch.hpp b/fi/include/frequent_items_sketch.hpp
index 0aa9514..87ee174 100644
--- a/fi/include/frequent_items_sketch.hpp
+++ b/fi/include/frequent_items_sketch.hpp
@@ -44,6 +44,11 @@
  * Based on Java implementation here:
  * https://github.com/apache/datasketches-java/blob/master/src/main/java/org/apache/datasketches/frequencies/ItemsSketch.java
  * @author Alexander Saydakov
+ *
+ * Sketch that may retain string values.
+ * For sketches containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
  */
 template<
   typename T,
@@ -74,6 +79,8 @@
 
   /**
    * Update this sketch with an item and a positive weight (frequency count).
+   * If cross-language portability is required, callers should ensure that
+   * the input string uses a compatible encoding (valid UTF-8).
    * @param item for which the weight should be increased (lvalue)
    * @param weight the amount by which the weight of the item should be increased
    * A count of zero is a no-op, and a negative count will throw an exception.
@@ -82,6 +89,8 @@
 
   /**
    * Update this sketch with an item and a positive weight (frequency count).
+   * If cross-language portability is required, callers should ensure that
+   * the input string uses a compatible encoding (valid UTF-8).
    * @param item for which the weight should be increased (rvalue)
    * @param weight the amount by which the weight of the item should be increased
    * A count of zero is a no-op, and a negative count will throw an exception.
@@ -91,6 +100,8 @@
   /**
    * This function merges the other sketch into this one.
    * The other sketch may be of a different size.
+   * If sketches contain strings, callers are responsible for ensuring that
+   * both sketches were built using compatible string encodings.
    * @param other sketch to be merged into this (lvalue)
    */
   void merge(const frequent_items_sketch& other);
@@ -98,6 +109,8 @@
   /**
    * This function merges the other sketch into this one.
    * The other sketch may be of a different size.
+   * If sketches contain strings, callers are responsible for ensuring that
+   * both sketches were built using compatible string encodings.
    * @param other sketch to be merged into this (rvalue)
    */
   void merge(frequent_items_sketch&& other);
diff --git a/kll/include/kll_sketch.hpp b/kll/include/kll_sketch.hpp
index 904587a..d672c41 100644
--- a/kll/include/kll_sketch.hpp
+++ b/kll/include/kll_sketch.hpp
@@ -46,6 +46,11 @@
  * and nearly optimal accuracy per retained item.
  * See <a href="https://arxiv.org/abs/1603.05346v2">Optimal Quantile Approximation in Streams</a>.
  *
+ * Sketch that may retain string values.
+ * For sketches containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
+ *
  * <p>This is a stochastic streaming sketch that enables near real-time analysis of the
  * approximate distribution of items from a very large stream in a single pass, requiring only
  * that the items are comparable.
@@ -56,7 +61,7 @@
  * <p>As of May 2020, this implementation produces serialized sketches which are binary-compatible
  * with the equivalent Java implementation only when template parameter T = float
  * (32-bit single precision values).
- * 
+ *
  * <p>Given an input stream of <i>N</i> items, the <i>natural rank</i> of any specific
  * item is defined as its index <i>(1 to N)</i> in inclusive mode
  * or <i>(0 to N-1)</i> in exclusive mode
@@ -225,6 +230,8 @@
 
     /**
      * Updates this sketch with the given data item.
+     * If cross-language portability is required, callers should ensure that
+     * the input string uses a compatible encoding (valid UTF-8).
      * @param item from a stream of items
      */
     template<typename FwdT>
@@ -232,6 +239,8 @@
 
     /**
      * Merges another sketch into this one.
+     * If sketches contain strings, callers are responsible for ensuring that
+     * both sketches were built using compatible string encodings.
      * @param other sketch to merge into this one
      */
     template<typename FwdSk>
diff --git a/kll/include/kll_sketch_impl.hpp b/kll/include/kll_sketch_impl.hpp
index fde0a31..44fe6a1 100644
--- a/kll/include/kll_sketch_impl.hpp
+++ b/kll/include/kll_sketch_impl.hpp
@@ -24,6 +24,7 @@
 #include <iomanip>
 #include <sstream>
 #include <stdexcept>
+#include <type_traits>
 
 #include "conditional_forward.hpp"
 #include "count_zeros.hpp"
@@ -481,18 +482,22 @@
     read(is, levels.data(), sizeof(levels[0]) * num_levels);
   }
   levels[num_levels] = capacity;
-  optional<T> tmp; // space to deserialize min and max
   optional<T> min_item;
   optional<T> max_item;
   if (!is_single_item) {
-    sd.deserialize(is, &*tmp, 1);
+    // Space to deserialize min and max.
+    // serde::deserialize expects allocated but not initialized storage.
+    typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+    T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+    sd.deserialize(is, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    min_item.emplace(*tmp);
-    (*tmp).~T();
-    sd.deserialize(is, &*tmp, 1);
+    min_item.emplace(std::move(*tmp));
+    tmp->~T();
+    sd.deserialize(is, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    max_item.emplace(*tmp);
-    (*tmp).~T();
+    max_item.emplace(std::move(*tmp));
+    tmp->~T();
   }
   A alloc(allocator);
   auto items_buffer_deleter = [capacity, &alloc](T* ptr) { alloc.deallocate(ptr, capacity); };
@@ -565,18 +570,22 @@
     ptr += copy_from_mem(ptr, levels.data(), sizeof(levels[0]) * num_levels);
   }
   levels[num_levels] = capacity;
-  optional<T> tmp; // space to deserialize min and max
   optional<T> min_item;
   optional<T> max_item;
   if (!is_single_item) {
-    ptr += sd.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+    // Space to deserialize min and max.
+    // serde::deserialize expects allocated but not initialized storage.
+    typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+    T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+    ptr += sd.deserialize(ptr, end_ptr - ptr, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    min_item.emplace(*tmp);
-    (*tmp).~T();
-    ptr += sd.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+    min_item.emplace(std::move(*tmp));
+    tmp->~T();
+    ptr += sd.deserialize(ptr, end_ptr - ptr, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    max_item.emplace(*tmp);
-    (*tmp).~T();
+    max_item.emplace(std::move(*tmp));
+    tmp->~T();
   }
   A alloc(allocator);
   auto items_buffer_deleter = [capacity, &alloc](T* ptr) { alloc.deallocate(ptr, capacity); };
diff --git a/kll/test/kll_sketch_deserialize_from_java_test.cpp b/kll/test/kll_sketch_deserialize_from_java_test.cpp
index 795486a..65efc3e 100644
--- a/kll/test/kll_sketch_deserialize_from_java_test.cpp
+++ b/kll/test/kll_sketch_deserialize_from_java_test.cpp
@@ -100,4 +100,28 @@
   }
 }
 
+TEST_CASE("kll long", "[serde_compat]") {
+  const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+  for (const unsigned n: n_arr) {
+    std::ifstream is;
+    is.exceptions(std::ios::failbit | std::ios::badbit);
+    is.open(testBinaryInputPath + "kll_long_n" + std::to_string(n) + "_java.sk", std::ios::binary);
+    const auto sketch = kll_sketch<long>::deserialize(is);
+    REQUIRE(sketch.is_empty() == (n == 0));
+    REQUIRE(sketch.is_estimation_mode() == (n > kll_constants::DEFAULT_K));
+    REQUIRE(sketch.get_n() == n);
+    if (n > 0) {
+      REQUIRE(sketch.get_min_item() == 1);
+      REQUIRE(sketch.get_max_item() == static_cast<long>(n));
+      uint64_t weight = 0;
+      for (const auto pair: sketch) {
+        REQUIRE(pair.first >= sketch.get_min_item());
+        REQUIRE(pair.first <= sketch.get_max_item());
+        weight += pair.second;
+      }
+      REQUIRE(weight == sketch.get_n());
+    }
+  }
+}
+
 } /* namespace datasketches */
diff --git a/kll/test/kll_sketch_serialize_for_java.cpp b/kll/test/kll_sketch_serialize_for_java.cpp
index 00b8913..22b7577 100644
--- a/kll/test/kll_sketch_serialize_for_java.cpp
+++ b/kll/test/kll_sketch_serialize_for_java.cpp
@@ -43,6 +43,16 @@
   }
 }
 
+TEST_CASE("kll sketch long generate", "[serialize_for_java]") {
+  const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+  for (const unsigned n: n_arr) {
+    kll_sketch<long> sketch;
+    for (unsigned i = 1; i <= n; ++i) sketch.update(i);
+    std::ofstream os("kll_long_n" + std::to_string(n) + "_cpp.sk", std::ios::binary);
+    sketch.serialize(os);
+  }
+}
+
 struct compare_as_number {
   bool operator()(const std::string& a, const std::string& b) const {
     return std::stoi(a) < std::stoi(b);
diff --git a/quantiles/include/quantiles_sketch.hpp b/quantiles/include/quantiles_sketch.hpp
index b1e2e3c..e995e3e 100644
--- a/quantiles/include/quantiles_sketch.hpp
+++ b/quantiles/include/quantiles_sketch.hpp
@@ -47,6 +47,11 @@
  * The analysis is obtained using get_rank() and get_quantile() functions,
  * the Probability Mass Function from get_PMF() and the Cumulative Distribution Function from get_CDF().
  *
+ * Sketch that may retain string values.
+ * For sketches containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
+ *
  * <p>Consider a large stream of one million values such as packet sizes coming into a network node.
  * The natural rank of any specific size value is its index in the hypothetical sorted
  * array of values.
@@ -206,6 +211,8 @@
 
   /**
    * Updates this sketch with the given data item.
+   * If cross-language portability is required, callers should ensure that
+   * the input string uses a compatible encoding (valid UTF-8).
    * @param item from a stream of items
    */
   template<typename FwdT>
@@ -213,6 +220,8 @@
 
   /**
    * Merges another sketch into this one.
+   * If sketches contain strings, callers are responsible for ensuring that
+   * both sketches were built using compatible string encodings.
    * @param other sketch to merge into this one
    */
   template<typename FwdSk>
diff --git a/quantiles/include/quantiles_sketch_impl.hpp b/quantiles/include/quantiles_sketch_impl.hpp
index 50c82c1..2dacf21 100644
--- a/quantiles/include/quantiles_sketch_impl.hpp
+++ b/quantiles/include/quantiles_sketch_impl.hpp
@@ -25,6 +25,7 @@
 #include <stdexcept>
 #include <iomanip>
 #include <sstream>
+#include <type_traits>
 
 #include "count_zeros.hpp"
 #include "conditional_forward.hpp"
@@ -393,18 +394,22 @@
   const bool is_compact = (serial_version == 2) | ((flags_byte & (1 << flags::IS_COMPACT)) > 0);
   const bool is_sorted = (flags_byte & (1 << flags::IS_SORTED)) > 0;
 
-  optional<T> tmp; // space to deserialize min and max
   optional<T> min_item;
   optional<T> max_item;
 
-  serde.deserialize(is, &*tmp, 1);
+  // Space to deserialize min and max.
+  // serde::deserialize expects allocated but not initialized storage.
+  typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+  T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+  serde.deserialize(is, tmp, 1);
   // serde call did not throw, repackage and cleanup
-  min_item.emplace(*tmp);
-  (*tmp).~T();
-  serde.deserialize(is, &*tmp, 1);
+  min_item.emplace(std::move(*tmp));
+  tmp->~T();
+  serde.deserialize(is, tmp, 1);
   // serde call did not throw, repackage and cleanup
-  max_item.emplace(*tmp);
-  (*tmp).~T();
+  max_item.emplace(std::move(*tmp));
+  tmp->~T();
 
   if (serial_version == 1) {
     read<uint64_t>(is); // no longer used
@@ -507,18 +512,22 @@
   const bool is_compact = (serial_version == 2) | ((flags_byte & (1 << flags::IS_COMPACT)) > 0);
   const bool is_sorted = (flags_byte & (1 << flags::IS_SORTED)) > 0;
 
-  optional<T> tmp; // space to deserialize min and max
   optional<T> min_item;
   optional<T> max_item;
 
-  ptr += serde.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+  // Space to deserialize min and max.
+  // serde::deserialize expects allocated but not initialized storage.
+  typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+  T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+  ptr += serde.deserialize(ptr, end_ptr - ptr, tmp, 1);
   // serde call did not throw, repackage and cleanup
-  min_item.emplace(*tmp);
-  (*tmp).~T();
-  ptr += serde.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+  min_item.emplace(std::move(*tmp));
+  tmp->~T();
+  ptr += serde.deserialize(ptr, end_ptr - ptr, tmp, 1);
   // serde call did not throw, repackage and cleanup
-  max_item.emplace(*tmp);
-  (*tmp).~T();
+  max_item.emplace(std::move(*tmp));
+  tmp->~T();
 
   if (serial_version == 1) {
     uint64_t unused_long;
diff --git a/req/include/req_sketch.hpp b/req/include/req_sketch.hpp
index 21ccac0..52295bd 100755
--- a/req/include/req_sketch.hpp
+++ b/req/include/req_sketch.hpp
@@ -35,6 +35,11 @@
  * "Relative Error Streaming Quantiles" by Graham Cormode, Zohar Karnin, Edo Liberty,
  * Justin Thaler, Pavel Veselý, and loosely derived from a Python prototype written by Pavel Veselý.
  *
+ * Sketch that may retain string values.
+ * For sketches containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
+ *
  * <p>Reference: https://arxiv.org/abs/2004.01668</p>
  *
  * <p>This implementation differs from the algorithm described in the paper in the following:</p>
@@ -179,6 +184,8 @@
 
   /**
    * Updates this sketch with the given data item.
+   * If cross-language portability is required, callers should ensure that
+   * the input string uses a compatible encoding (valid UTF-8).
    * @param item from a stream of items
    */
   template<typename FwdT>
@@ -186,6 +193,8 @@
 
   /**
    * Merges another sketch into this one.
+   * If sketches contain strings, callers are responsible for ensuring that
+   * both sketches were built using compatible string encodings.
    * @param other sketch to merge into this one
    */
   template<typename FwdSk>
diff --git a/req/include/req_sketch_impl.hpp b/req/include/req_sketch_impl.hpp
index a28e74e..3c1c2fc 100755
--- a/req/include/req_sketch_impl.hpp
+++ b/req/include/req_sketch_impl.hpp
@@ -22,6 +22,7 @@
 
 #include <sstream>
 #include <stdexcept>
+#include <type_traits>
 
 namespace datasketches {
 
@@ -461,7 +462,6 @@
   const bool hra = flags_byte & (1 << flags::IS_HIGH_RANK);
   if (is_empty) return req_sketch(k, hra, comparator, allocator);
 
-  optional<T> tmp; // space to deserialize min and max
   optional<T> min_item;
   optional<T> max_item;
 
@@ -472,14 +472,19 @@
   uint64_t n = 1;
   if (num_levels > 1) {
     n = read<uint64_t>(is);
-    sd.deserialize(is, &*tmp, 1);
+    // Space to deserialize min and max.
+    // serde::deserialize expects allocated but not initialized storage.
+    typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+    T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+    sd.deserialize(is, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    min_item.emplace(*tmp);
-    (*tmp).~T();
-    sd.deserialize(is, &*tmp, 1);
+    min_item.emplace(std::move(*tmp));
+    tmp->~T();
+    sd.deserialize(is, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    max_item.emplace(*tmp);
-    (*tmp).~T();
+    max_item.emplace(std::move(*tmp));
+    tmp->~T();
   }
 
   if (raw_items) {
@@ -537,7 +542,6 @@
   const bool hra = flags_byte & (1 << flags::IS_HIGH_RANK);
   if (is_empty) return req_sketch(k, hra, comparator, allocator);
 
-  optional<T> tmp; // space to deserialize min and max
   optional<T> min_item;
   optional<T> max_item;
 
@@ -549,14 +553,19 @@
   if (num_levels > 1) {
     ensure_minimum_memory(end_ptr - ptr, sizeof(n));
     ptr += copy_from_mem(ptr, n);
-    ptr += sd.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+    // Space to deserialize min and max.
+    // serde::deserialize expects allocated but not initialized storage.
+    typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+    T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+    ptr += sd.deserialize(ptr, end_ptr - ptr, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    min_item.emplace(*tmp);
-    (*tmp).~T();
-    ptr += sd.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+    min_item.emplace(std::move(*tmp));
+    tmp->~T();
+    ptr += sd.deserialize(ptr, end_ptr - ptr, tmp, 1);
     // serde call did not throw, repackage and cleanup
-    max_item.emplace(*tmp);
-    (*tmp).~T();
+    max_item.emplace(std::move(*tmp));
+    tmp->~T();
   }
 
   if (raw_items) {
diff --git a/sampling/include/ebpps_sample_impl.hpp b/sampling/include/ebpps_sample_impl.hpp
index 88a86ae..c48b32a 100644
--- a/sampling/include/ebpps_sample_impl.hpp
+++ b/sampling/include/ebpps_sample_impl.hpp
@@ -28,6 +28,7 @@
 #include <cmath>
 #include <string>
 #include <sstream>
+#include <type_traits>
 
 namespace datasketches {
 
@@ -365,11 +366,15 @@
 
   optional<T> partial_item;
   if (has_partial) {
-    optional<T> tmp; // space to deserialize
-    ptr += sd.deserialize(ptr, end_ptr - ptr, &*tmp, 1);
+    // Space to deserialize.
+    // serde::deserialize expects allocated but not initialized storage.
+    typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+    T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+    ptr += sd.deserialize(ptr, end_ptr - ptr, tmp, 1);
     // serde did not throw so place item and clean up
-    partial_item.emplace(*tmp);
-    (*tmp).~T();
+    partial_item.emplace(std::move(*tmp));
+    tmp->~T();
   }
 
   return std::pair<ebpps_sample<T,A>, size_t>(
@@ -400,11 +405,15 @@
 
   optional<T> partial_item;
   if (has_partial) {
-    optional<T> tmp; // space to deserialize
-    sd.deserialize(is, &*tmp, 1);
+    // Space to deserialize.
+    // serde::deserialize expects allocated but not initialized storage.
+    typename std::aligned_storage<sizeof(T), alignof(T)>::type tmp_storage;
+    T* tmp = reinterpret_cast<T*>(&tmp_storage);
+
+    sd.deserialize(is, tmp, 1);
     // serde did not throw so place item and clean up
-    partial_item.emplace(*tmp);
-    (*tmp).~T();
+    partial_item.emplace(std::move(*tmp));
+    tmp->~T();
   }
 
   if (!is.good()) throw std::runtime_error("error reading from std::istream");
diff --git a/sampling/include/ebpps_sketch.hpp b/sampling/include/ebpps_sketch.hpp
index 038b5a3..615d37b 100644
--- a/sampling/include/ebpps_sketch.hpp
+++ b/sampling/include/ebpps_sketch.hpp
@@ -50,6 +50,11 @@
  * The sample may be smaller than k and the resulting size of the sample potentially includes
  * a probabilistic component, meaning the resulting sample size is not always constant.
  *
+ * Sketch that may retain string values.
+ * For sketches containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
+ *
  * @author Jon Malkin
  */
 template<
@@ -71,6 +76,8 @@
     /**
      * Updates this sketch with the given data item with the given weight.
      * This method takes an lvalue.
+     * If cross-language portability is required, callers should ensure that
+     * the input string uses a compatible encoding (valid UTF-8).
      * @param item an item from a stream of items
      * @param weight the weight of the item
      */
@@ -79,6 +86,8 @@
     /**
      * Updates this sketch with the given data item with the given weight.
      * This method takes an rvalue.
+     * If cross-language portability is required, callers should ensure that
+     * the input string uses a compatible encoding (valid UTF-8).
      * @param item an item from a stream of items
      * @param weight the weight of the item
      */
@@ -87,6 +96,8 @@
     /**
      * Merges the provided sketch into the current one.
      * This method takes an lvalue.
+     * If sketches contain strings, callers are responsible for ensuring that
+     * both sketches were built using compatible string encodings.
      * @param sketch the sketch to merge into the current object
      */
     void merge(const ebpps_sketch<T, A>& sketch);
@@ -94,6 +105,8 @@
     /**
      * Merges the provided sketch into the current one.
      * This method takes an rvalue.
+     * If sketches contain strings, callers are responsible for ensuring that
+     * both sketches were built using compatible string encodings.
      * @param sketch the sketch to merge into the current object
      */
     void merge(ebpps_sketch<T, A>&& sketch);
diff --git a/sampling/include/var_opt_sketch.hpp b/sampling/include/var_opt_sketch.hpp
index 1324883..6b157ca 100644
--- a/sampling/include/var_opt_sketch.hpp
+++ b/sampling/include/var_opt_sketch.hpp
@@ -57,6 +57,11 @@
  * optimal (varopt) sampling is related to reservoir sampling, with improved error bounds for
  * subset sum estimation.
  *
+ * Sketch that may retain string values.
+ * For sketches containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
+ *
  * author Kevin Lang
  * author Jon Malkin
  */
@@ -111,6 +116,8 @@
     /**
      * Updates this sketch with the given data item with the given weight.
      * This method takes an lvalue.
+     * If cross-language portability is required, callers should ensure that
+     * the input string uses a compatible encoding (valid UTF-8).
      * @param item an item from a stream of items
      * @param weight the weight of the item
      */
@@ -119,6 +126,8 @@
     /**
      * Updates this sketch with the given data item with the given weight.
      * This method takes an rvalue.
+     * If cross-language portability is required, callers should ensure that
+     * the input string uses a compatible encoding (valid UTF-8).
      * @param item an item from a stream of items
      * @param weight the weight of the item
      */
diff --git a/sampling/include/var_opt_union.hpp b/sampling/include/var_opt_union.hpp
index 0e4f76d..68d1ac4 100644
--- a/sampling/include/var_opt_union.hpp
+++ b/sampling/include/var_opt_union.hpp
@@ -65,13 +65,17 @@
   /**
    * Updates this union with the given sketch
    * This method takes an lvalue.
+   * If sketches contain strings, callers are responsible for ensuring that
+   * both sketches were built using compatible string encodings.
    * @param sk a sketch to add to the union
    */
   void update(const var_opt_sketch<T, A>& sk);
-  
+
   /**
    * Updates this union with the given sketch
    * This method takes an rvalue.
+   * If sketches contain strings, callers are responsible for ensuring that
+   * both sketches were built using compatible string encodings.
    * @param sk a sketch to add to the union
    */
   void update(var_opt_sketch<T, A>&& sk);
diff --git a/tuple/CMakeLists.txt b/tuple/CMakeLists.txt
index 4b0a48c..54df11e 100644
--- a/tuple/CMakeLists.txt
+++ b/tuple/CMakeLists.txt
@@ -54,4 +54,6 @@
 		include/array_tuple_intersection_impl.hpp
 		include/array_tuple_a_not_b.hpp
 		include/array_tuple_a_not_b_impl.hpp
+		include/array_of_strings_sketch.hpp
+		include/array_of_strings_sketch_impl.hpp
   DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/DataSketches")
diff --git a/tuple/include/array_of_strings_sketch.hpp b/tuple/include/array_of_strings_sketch.hpp
new file mode 100644
index 0000000..296c0a8
--- /dev/null
+++ b/tuple/include/array_of_strings_sketch.hpp
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#ifndef ARRAY_OF_STRINGS_SKETCH_HPP_
+#define ARRAY_OF_STRINGS_SKETCH_HPP_
+
+#include <memory>
+#include <string>
+
+#include "array_tuple_sketch.hpp"
+#include "xxhash64.h"
+
+namespace datasketches {
+
+using array_of_strings = array<std::string>;
+
+// default update policy for an array of strings
+class default_array_of_strings_update_policy {
+public:
+  default_array_of_strings_update_policy() = default;
+
+  array_of_strings create() const;
+
+  void update(array_of_strings& array, const array_of_strings& input) const;
+
+  void update(array_of_strings& array, const array_of_strings* input) const;
+};
+
+/**
+ * Serializer/deserializer for an array of strings.
+ *
+ * Requirements:
+ * - Array size must be <= 127.
+ *
+ * This serde does not perform UTF-8 validation. Callers must ensure strings
+ * are valid UTF-8 before serialization to guarantee interoperability with
+ * Java, Go, and Rust implementations.
+ */
+template<typename Allocator = std::allocator<array_of_strings>>
+struct default_array_of_strings_serde {
+  using summary_allocator = typename std::allocator_traits<Allocator>::template rebind_alloc<array_of_strings>;
+
+  explicit default_array_of_strings_serde(const Allocator& allocator = Allocator());
+
+  void serialize(std::ostream& os, const array_of_strings* items, unsigned num) const;
+  void deserialize(std::istream& is, array_of_strings* items, unsigned num) const;
+  size_t serialize(void* ptr, size_t capacity, const array_of_strings* items, unsigned num) const;
+  size_t deserialize(const void* ptr, size_t capacity, array_of_strings* items, unsigned num) const;
+  size_t size_of_item(const array_of_strings& item) const;
+
+private:
+  summary_allocator summary_allocator_;
+  static void check_num_nodes(uint8_t num_nodes);
+  static uint32_t compute_total_bytes(const array_of_strings& item);
+};
+
+/**
+ * Hashes an array of strings using ArrayOfStrings-compatible hashing.
+ */
+uint64_t hash_array_of_strings_key(const array_of_strings& key);
+
+/**
+ * Extended class of compact_tuple_sketch for array of strings.
+ *
+ * Requirements:
+ * - Array size must be <= 127.
+ *
+ * UTF-8 compatibility:
+ * Serialized sketches are intended to be language and platform independent.
+ * Other implementations (Java, Go, Rust) enforce UTF-8 encoding for strings.
+ * This C++ implementation does not validate UTF-8; it is the caller's
+ * responsibility to ensure all strings are valid UTF-8 before calling update().
+ * Non-UTF-8 strings may serialize successfully but will fail to deserialize
+ * in other language implementations.
+ */
+template<typename Allocator = std::allocator<array_of_strings>>
+class compact_array_of_strings_tuple_sketch:
+  public compact_tuple_sketch<array_of_strings, Allocator> {
+public:
+  using Base = compact_tuple_sketch<array_of_strings, Allocator>;
+  using vector_bytes = typename Base::vector_bytes;
+  using Base::serialize;
+
+  /**
+   * Copy constructor.
+   * Constructs a compact sketch from another sketch (update or compact)
+   * @param other sketch to be constructed from
+   * @param ordered if true make the resulting sketch ordered
+   */
+  template<typename Sketch>
+  compact_array_of_strings_tuple_sketch(const Sketch& sketch, bool ordered = true);
+
+  /**
+   * This method deserializes a sketch from a given stream.
+   * @param is input stream
+   * @param seed the seed for the hash function that was used to create the sketch
+   * @param sd instance of a SerDe
+   * @param allocator instance of an Allocator
+   * @return an instance of the sketch
+   */
+  template<typename SerDe = default_array_of_strings_serde<Allocator>>
+  static compact_array_of_strings_tuple_sketch deserialize(std::istream& is, uint64_t seed = DEFAULT_SEED,
+      const SerDe& sd = SerDe(), const Allocator& allocator = Allocator());
+
+  /**
+   * This method deserializes a sketch from a given array of bytes.
+   * @param bytes pointer to the array of bytes
+   * @param size the size of the array
+   * @param seed the seed for the hash function that was used to create the sketch
+   * @param sd instance of a SerDe
+   * @param allocator instance of an Allocator
+   * @return an instance of the sketch
+   */
+  template<typename SerDe = default_array_of_strings_serde<Allocator>>
+  static compact_array_of_strings_tuple_sketch deserialize(const void* bytes, size_t size, uint64_t seed = DEFAULT_SEED,
+      const SerDe& sd = SerDe(), const Allocator& allocator = Allocator());
+
+private:
+  explicit compact_array_of_strings_tuple_sketch(Base&& base);
+};
+
+/**
+ * Convenience alias for update_tuple_sketch for array of strings
+ */
+template<typename Allocator = std::allocator<array_of_strings>,
+         typename Policy = default_array_of_strings_update_policy>
+using update_array_of_strings_tuple_sketch = update_tuple_sketch<
+  array_of_strings,
+  array_of_strings,
+  Policy,
+  Allocator
+>;
+
+/**
+ * Converts an array of strings tuple sketch to a compact sketch (ordered or unordered).
+ * @param sketch input sketch
+ * @param ordered optional flag to specify if an ordered sketch should be produced
+ * @return compact array of strings sketch
+ */
+template<typename Allocator = std::allocator<array_of_strings>, typename Policy = default_array_of_strings_update_policy>
+compact_array_of_strings_tuple_sketch<Allocator> compact_array_of_strings_sketch(
+  const update_array_of_strings_tuple_sketch<Allocator, Policy>& sketch, bool ordered = true);
+
+} /* namespace datasketches */
+
+#include "array_of_strings_sketch_impl.hpp"
+
+#endif
diff --git a/tuple/include/array_of_strings_sketch_impl.hpp b/tuple/include/array_of_strings_sketch_impl.hpp
new file mode 100644
index 0000000..400df47
--- /dev/null
+++ b/tuple/include/array_of_strings_sketch_impl.hpp
@@ -0,0 +1,274 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#ifndef ARRAY_OF_STRINGS_SKETCH_IMPL_HPP_
+#define ARRAY_OF_STRINGS_SKETCH_IMPL_HPP_
+
+#include <stdexcept>
+
+#include "common_defs.hpp"
+
+namespace datasketches {
+
+inline array_of_strings default_array_of_strings_update_policy::create() const {
+  return array_of_strings(0, "");
+}
+
+inline void default_array_of_strings_update_policy::update(
+  array_of_strings& array, const array_of_strings& input
+) const {
+  const auto length = static_cast<size_t>(input.size());
+  array = array_of_strings(static_cast<uint8_t>(length), "");
+  for (size_t i = 0; i < length; ++i) array[i] = input[i];
+}
+
+inline void default_array_of_strings_update_policy::update(
+  array_of_strings& array, const array_of_strings* input
+) const {
+  if (input == nullptr) {
+    array = array_of_strings(0, "");
+    return;
+  }
+  const auto length = static_cast<size_t>(input->size());
+  array = array_of_strings(static_cast<uint8_t>(length), "");
+  for (size_t i = 0; i < length; ++i) array[i] = (*input)[i];
+}
+
+inline uint64_t hash_array_of_strings_key(const array_of_strings& key) {
+  // Matches Java Util.PRIME for ArrayOfStrings key hashing.
+  static constexpr uint64_t STRING_ARR_HASH_SEED = 0x7A3CCA71ULL;
+  XXHash64 hasher(STRING_ARR_HASH_SEED);
+  const auto size = static_cast<size_t>(key.size());
+  for (size_t i = 0; i < size; ++i) {
+    const auto& entry = key[i];
+    hasher.add(entry.data(), entry.size());
+    if (i + 1 < size) hasher.add(",", 1);
+  }
+  return hasher.hash();
+}
+
+template<typename Allocator, typename Policy>
+compact_array_of_strings_tuple_sketch<Allocator> compact_array_of_strings_sketch(
+  const update_array_of_strings_tuple_sketch<Allocator, Policy>& sketch, bool ordered
+) {
+  return compact_array_of_strings_tuple_sketch<Allocator>(sketch, ordered);
+}
+
+template<typename Allocator>
+template<typename Sketch>
+compact_array_of_strings_tuple_sketch<Allocator>::compact_array_of_strings_tuple_sketch(
+  const Sketch& sketch, bool ordered
+): Base(sketch, ordered) {}
+
+template<typename Allocator>
+compact_array_of_strings_tuple_sketch<Allocator>::compact_array_of_strings_tuple_sketch(
+  Base&& base
+): Base(std::move(base)) {}
+
+template<typename Allocator>
+template<typename SerDe>
+auto compact_array_of_strings_tuple_sketch<Allocator>::deserialize(
+  std::istream& is, uint64_t seed, const SerDe& sd, const Allocator& allocator
+) -> compact_array_of_strings_tuple_sketch {
+  auto base = Base::deserialize(is, seed, sd, allocator);
+  return compact_array_of_strings_tuple_sketch(std::move(base));
+}
+
+template<typename Allocator>
+template<typename SerDe>
+auto compact_array_of_strings_tuple_sketch<Allocator>::deserialize(
+  const void* bytes, size_t size, uint64_t seed, const SerDe& sd, const Allocator& allocator
+) -> compact_array_of_strings_tuple_sketch {
+  auto base = Base::deserialize(bytes, size, seed, sd, allocator);
+  return compact_array_of_strings_tuple_sketch(std::move(base));
+}
+
+template<typename Allocator>
+default_array_of_strings_serde<Allocator>::default_array_of_strings_serde(const Allocator& allocator):
+  summary_allocator_(allocator) {}
+
+template<typename Allocator>
+void default_array_of_strings_serde<Allocator>::serialize(
+  std::ostream& os, const array_of_strings* items, unsigned num
+) const {
+  unsigned i = 0;
+  try {
+    for (; i < num; ++i) {
+      const uint32_t total_bytes = compute_total_bytes(items[i]);
+      const uint8_t num_nodes = static_cast<uint8_t>(items[i].size());
+      write(os, total_bytes);
+      write(os, num_nodes);
+      const std::string* data = items[i].data();
+      for (uint8_t j = 0; j < num_nodes; ++j) {
+        const uint32_t length = static_cast<uint32_t>(data[j].size());
+        write(os, length);
+        os.write(data[j].data(), length);
+      }
+    }
+  } catch (std::runtime_error& e) {
+    if (std::string(e.what()).find("size exceeds 127") != std::string::npos) throw;
+    throw std::runtime_error("array_of_strings stream write failed at item " + std::to_string(i));
+  }
+}
+
+template<typename Allocator>
+void default_array_of_strings_serde<Allocator>::deserialize(
+  std::istream& is, array_of_strings* items, unsigned num
+) const {
+  unsigned i = 0;
+  bool failure = false;
+  try {
+    for (; i < num; ++i) {
+      read<uint32_t>(is); // total_bytes
+      if (!is) { failure = true; break; }
+      const uint8_t num_nodes = read<uint8_t>(is);
+      if (!is) { failure = true; break; }
+      check_num_nodes(num_nodes);
+      array_of_strings array(num_nodes, "");
+      for (uint8_t j = 0; j < num_nodes; ++j) {
+        const uint32_t length = read<uint32_t>(is);
+        if (!is) { failure = true; break; }
+        std::string value(length, '\0');
+        if (length != 0) {
+          is.read(&value[0], length);
+          if (!is) { failure = true; break; }
+        }
+        array[j] = std::move(value);
+      }
+      if (failure) break;
+      summary_allocator alloc(summary_allocator_);
+      std::allocator_traits<summary_allocator>::construct(alloc, &items[i], std::move(array));
+    }
+  } catch (std::exception& e) {
+    summary_allocator alloc(summary_allocator_);
+    for (unsigned j = 0; j < i; ++j) {
+      std::allocator_traits<summary_allocator>::destroy(alloc, &items[j]);
+    }
+    if (std::string(e.what()).find("size exceeds 127") != std::string::npos) throw;
+    throw std::runtime_error("array_of_strings stream read failed at item " + std::to_string(i));
+  }
+  if (failure) {
+    summary_allocator alloc(summary_allocator_);
+    for (unsigned j = 0; j < i; ++j) {
+      std::allocator_traits<summary_allocator>::destroy(alloc, &items[j]);
+    }
+    throw std::runtime_error("array_of_strings stream read failed at item " + std::to_string(i));
+  }
+}
+
+template<typename Allocator>
+size_t default_array_of_strings_serde<Allocator>::serialize(
+  void* ptr, size_t capacity, const array_of_strings* items, unsigned num
+) const {
+  uint8_t* ptr8 = static_cast<uint8_t*>(ptr);
+  size_t bytes_written = 0;
+
+  for (unsigned i = 0; i < num; ++i) {
+    const uint32_t total_bytes = compute_total_bytes(items[i]);
+    const uint8_t num_nodes = static_cast<uint8_t>(items[i].size());
+    check_memory_size(bytes_written + total_bytes, capacity);
+    bytes_written += copy_to_mem(total_bytes, ptr8 + bytes_written);
+    bytes_written += copy_to_mem(num_nodes, ptr8 + bytes_written);
+    const std::string* data = items[i].data();
+    for (uint8_t j = 0; j < num_nodes; ++j) {
+      const uint32_t length = static_cast<uint32_t>(data[j].size());
+
+      bytes_written += copy_to_mem(length, ptr8 + bytes_written);
+      bytes_written += copy_to_mem(data[j].data(), ptr8 + bytes_written, length);
+    }
+  }
+  return bytes_written;
+}
+
+template<typename Allocator>
+size_t default_array_of_strings_serde<Allocator>::deserialize(
+  const void* ptr, size_t capacity, array_of_strings* items, unsigned num
+) const {
+  const uint8_t* ptr8 = static_cast<const uint8_t*>(ptr);
+  size_t bytes_read = 0;
+
+  unsigned i = 0;
+
+  try {
+    for (; i < num; ++i) {
+      check_memory_size(bytes_read + sizeof(uint32_t), capacity);
+      const size_t item_start = bytes_read;
+      uint32_t total_bytes;
+      bytes_read += copy_from_mem(ptr8 + bytes_read, total_bytes);
+      check_memory_size(item_start + total_bytes, capacity);
+
+      check_memory_size(bytes_read + sizeof(uint8_t), capacity);
+      uint8_t num_nodes;
+      bytes_read += copy_from_mem(ptr8 + bytes_read, num_nodes);
+      check_num_nodes(num_nodes);
+
+      array_of_strings array(num_nodes, "");
+      for (uint8_t j = 0; j < num_nodes; ++j) {
+        check_memory_size(bytes_read + sizeof(uint32_t), capacity);
+        uint32_t length;
+        bytes_read += copy_from_mem(ptr8 + bytes_read, length);
+
+        check_memory_size(bytes_read + length, capacity);
+        std::string value(length, '\0');
+        if (length != 0) {
+          bytes_read += copy_from_mem(ptr8 + bytes_read, &value[0], length);
+        }
+        array[j] = std::move(value);
+      }
+      summary_allocator alloc(summary_allocator_);
+      std::allocator_traits<summary_allocator>::construct(alloc, &items[i], std::move(array));
+    }
+  } catch (std::exception& e) {
+    summary_allocator alloc(summary_allocator_);
+    for (unsigned j = 0; j < i; ++j) {
+      std::allocator_traits<summary_allocator>::destroy(alloc, &items[j]);
+    }
+    if (std::string(e.what()).find("size exceeds 127") != std::string::npos) throw;
+    throw std::runtime_error("array_of_strings bytes read failed at item " + std::to_string(i));
+  }
+  return bytes_read;
+}
+
+template<typename Allocator>
+size_t default_array_of_strings_serde<Allocator>::size_of_item(const array_of_strings& item) const {
+  return compute_total_bytes(item);
+}
+
+template<typename Allocator>
+void default_array_of_strings_serde<Allocator>::check_num_nodes(uint8_t num_nodes) {
+  if (num_nodes > 127) {
+    throw std::runtime_error("array_of_strings size exceeds 127");
+  }
+}
+
+template<typename Allocator>
+uint32_t default_array_of_strings_serde<Allocator>::compute_total_bytes(const array_of_strings& item) {
+  const auto count = item.size();
+  check_num_nodes(static_cast<uint8_t>(count));
+  size_t total = sizeof(uint32_t) + sizeof(uint8_t) + count * sizeof(uint32_t);
+  const std::string* data = item.data();
+  for (uint32_t j = 0; j < count; ++j) {
+    total += data[j].size();
+  }
+  return static_cast<uint32_t>(total);
+}
+
+} /* namespace datasketches */
+
+#endif
diff --git a/tuple/include/array_tuple_sketch.hpp b/tuple/include/array_tuple_sketch.hpp
index 547b240..d331f8b 100644
--- a/tuple/include/array_tuple_sketch.hpp
+++ b/tuple/include/array_tuple_sketch.hpp
@@ -22,6 +22,8 @@
 
 #include <vector>
 #include <memory>
+#include <type_traits>
+#include <algorithm>
 
 #include "serde.hpp"
 #include "tuple_sketch.hpp"
@@ -34,17 +36,18 @@
 public:
   using value_type = T;
   using allocator_type = Allocator;
+  using alloc_traits = std::allocator_traits<Allocator>;
 
-  explicit array(uint8_t size, T value, const Allocator& allocator = Allocator()):
+  explicit array(uint8_t size, const T& value, const Allocator& allocator = Allocator()):
   allocator_(allocator), size_(size), array_(allocator_.allocate(size_)) {
-    std::fill(array_, array_ + size_, value);
+    init_values(value, std::is_trivially_copyable<T>());
   }
   array(const array& other):
     allocator_(other.allocator_),
     size_(other.size_),
     array_(allocator_.allocate(size_))
   {
-    std::copy(other.array_, other.array_ + size_, array_);
+    copy_from(other, std::is_trivially_copyable<T>());
   }
   array(array&& other) noexcept:
     allocator_(std::move(other.allocator_)),
@@ -52,9 +55,13 @@
     array_(other.array_)
   {
     other.array_ = nullptr;
+    other.size_ = 0;
   }
   ~array() {
-    if (array_ != nullptr) allocator_.deallocate(array_, size_);
+    if (array_ != nullptr) {
+      destroy_values(std::is_trivially_destructible<T>());
+      allocator_.deallocate(array_, size_);
+    }
   }
   array& operator=(const array& other) {
     array copy(other);
@@ -75,10 +82,34 @@
   T* data() { return array_; }
   const T* data() const { return array_; }
   bool operator==(const array& other) const {
+    if (size_ != other.size_) return false;
     for (uint8_t i = 0; i < size_; ++i) if (array_[i] != other.array_[i]) return false;
     return true;
   }
 private:
+  void init_values(const T& value, std::true_type) {
+    std::fill(array_, array_ + size_, value);
+  }
+  void init_values(const T& value, std::false_type) {
+    for (uint8_t i = 0; i < size_; ++i) {
+      alloc_traits::construct(allocator_, array_ + i, value);
+    }
+  }
+  void copy_from(const array& other, std::true_type) {
+    std::copy(other.array_, other.array_ + size_, array_);
+  }
+  void copy_from(const array& other, std::false_type) {
+    for (uint8_t i = 0; i < size_; ++i) {
+      alloc_traits::construct(allocator_, array_ + i, other.array_[i]);
+    }
+  }
+  void destroy_values(std::true_type) {}
+  void destroy_values(std::false_type) {
+    for (uint8_t i = 0; i < size_; ++i) {
+      alloc_traits::destroy(allocator_, array_ + i);
+    }
+  }
+
   Allocator allocator_;
   uint8_t size_;
   T* array_;
diff --git a/tuple/include/tuple_sketch.hpp b/tuple/include/tuple_sketch.hpp
index cbfd9f1..7b636a7 100644
--- a/tuple/include/tuple_sketch.hpp
+++ b/tuple/include/tuple_sketch.hpp
@@ -46,6 +46,11 @@
 /**
  * Base class for Tuple sketch.
  * This is an extension of Theta sketch that allows keeping arbitrary Summary associated with each retained key.
+ *
+ * Summary that may retain string values.
+ * For Summary containing strings, cross-language portability depends on
+ * using compatible string encodings. This class does not by itself enforce
+ * UTF-8 validity for all string inputs.
  */
 template<
   typename Summary,
@@ -253,6 +258,9 @@
 
   /**
    * Update this sketch with a given string.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * @param key string to update the sketch with
    * @param value to update the sketch with
    */
@@ -261,6 +269,9 @@
 
   /**
    * Update this sketch with a given unsigned 64-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * @param key uint64_t to update the sketch with
    * @param value to update the sketch with
    */
@@ -269,6 +280,9 @@
 
   /**
    * Update this sketch with a given signed 64-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * @param key int64_t to update the sketch with
    * @param value to update the sketch with
    */
@@ -277,6 +291,9 @@
 
   /**
    * Update this sketch with a given unsigned 32-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key uint32_t to update the sketch with
    * @param value to update the sketch with
@@ -286,6 +303,9 @@
 
   /**
    * Update this sketch with a given signed 32-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key int32_t to update the sketch with
    * @param value to update the sketch with
@@ -295,6 +315,9 @@
 
   /**
    * Update this sketch with a given unsigned 16-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key uint16_t to update the sketch with
    * @param value to update the sketch with
@@ -304,6 +327,9 @@
 
   /**
    * Update this sketch with a given signed 16-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key int16_t to update the sketch with
    * @param value to update the sketch with
@@ -313,6 +339,9 @@
 
   /**
    * Update this sketch with a given unsigned 8-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key uint8_t to update the sketch with
    * @param value to update the sketch with
@@ -322,6 +351,9 @@
 
   /**
    * Update this sketch with a given signed 8-bit integer.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key int8_t to update the sketch with
    * @param value to update the sketch with
@@ -331,6 +363,9 @@
 
   /**
    * Update this sketch with a given double-precision floating point value.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key double to update the sketch with
    * @param value to update the sketch with
@@ -340,6 +375,9 @@
 
   /**
    * Update this sketch with a given floating point value.
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * For compatibility with Java implementation.
    * @param key float to update the sketch with
    * @param value to update the sketch with
@@ -357,6 +395,9 @@
    * Otherwise two sketches that should represent overlapping sets will be disjoint
    * For instance, for signed 32-bit values call update(int32_t) method above,
    * which does widening conversion to int64_t, if compatibility with Java is expected
+   * If the summary contains strings and cross-language portability is required,
+   * callers should ensure that any strings in the summary
+   * use a compatible encoding (valid UTF-8).
    * @param key pointer to the data
    * @param length of the data in bytes
    * @param value to update the sketch with
diff --git a/tuple/test/CMakeLists.txt b/tuple/test/CMakeLists.txt
index 4ca6a50..3d7ccca 100644
--- a/tuple/test/CMakeLists.txt
+++ b/tuple/test/CMakeLists.txt
@@ -44,6 +44,7 @@
     tuple_a_not_b_test.cpp
     tuple_jaccard_similarity_test.cpp
     array_of_doubles_sketch_test.cpp
+    array_of_strings_sketch_test.cpp
     engagement_test.cpp
 )
 
@@ -52,6 +53,7 @@
   PRIVATE
     aod_sketch_deserialize_from_java_test.cpp
     tuple_sketch_deserialize_from_java_test.cpp
+    aos_sketch_deserialize_from_java_test.cpp
 )
 endif()
 
@@ -60,5 +62,6 @@
   PRIVATE
     aod_sketch_serialize_for_java.cpp
     tuple_sketch_serialize_for_java.cpp
+    aos_sketch_serialize_for_java.cpp
 )
 endif()
diff --git a/tuple/test/aos_sketch_deserialize_from_java_test.cpp b/tuple/test/aos_sketch_deserialize_from_java_test.cpp
new file mode 100644
index 0000000..af37d6c
--- /dev/null
+++ b/tuple/test/aos_sketch_deserialize_from_java_test.cpp
@@ -0,0 +1,283 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <catch2/catch.hpp>
+#include <fstream>
+#include <vector>
+
+#include "array_of_strings_sketch.hpp"
+
+namespace datasketches {
+  // assume the binary sketches for this test have been generated by datasketches-java code
+  // in the subdirectory called "java" in the root directory of this project
+  static std::string testBinaryInputPath = std::string(TEST_BINARY_INPUT_PATH) + "../../java/";
+
+  static std::vector<uint8_t> read_binary_file(const std::string& path) {
+    std::ifstream is;
+    is.exceptions(std::ios::failbit | std::ios::badbit);
+    is.open(path, std::ios::binary);
+    is.seekg(0, std::ios::end);
+    const auto size = static_cast<size_t>(is.tellg());
+    is.seekg(0, std::ios::beg);
+    std::vector<uint8_t> bytes(size);
+    if (size != 0) {
+      is.read(reinterpret_cast<char*>(bytes.data()), size);
+    }
+    return bytes;
+  }
+
+  TEST_CASE("aos sketch one value", "[serde_compat]") {
+    const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+    for (const unsigned n: n_arr) {
+      const auto path = testBinaryInputPath + "aos_1_n" + std::to_string(n) + "_java.sk";
+      SECTION("stream") {
+        std::ifstream is;
+        is.exceptions(std::ios::failbit | std::ios::badbit);
+        is.open(path, std::ios::binary);
+        const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+          is, DEFAULT_SEED, default_array_of_strings_serde<>()
+        );
+        REQUIRE(sketch.is_empty() == (n == 0));
+        REQUIRE(sketch.is_estimation_mode() == (n > 1000));
+        REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+        for (const auto& entry: sketch) {
+          REQUIRE(entry.first < sketch.get_theta64());
+          REQUIRE(entry.second.size() == 1);
+        }
+      }
+      SECTION("bytes") {
+        const auto bytes = read_binary_file(path);
+        const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+          bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+        );
+        REQUIRE(sketch.is_empty() == (n == 0));
+        REQUIRE(sketch.is_estimation_mode() == (n > 1000));
+        REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+        for (const auto& entry: sketch) {
+          REQUIRE(entry.first < sketch.get_theta64());
+          REQUIRE(entry.second.size() == 1);
+        }
+      }
+    }
+  }
+
+  TEST_CASE("aos sketch three values", "[serde_compat]") {
+    const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+    for (const unsigned n: n_arr) {
+      const auto path = testBinaryInputPath + "aos_3_n" + std::to_string(n) + "_java.sk";
+      SECTION("stream") {
+        std::ifstream is;
+        is.exceptions(std::ios::failbit | std::ios::badbit);
+        is.open(path, std::ios::binary);
+        const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+          is, DEFAULT_SEED, default_array_of_strings_serde<>()
+        );
+        REQUIRE(sketch.is_empty() == (n == 0));
+        REQUIRE(sketch.is_estimation_mode() == (n > 1000));
+        REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+        for (const auto& entry: sketch) {
+          REQUIRE(entry.first < sketch.get_theta64());
+          REQUIRE(entry.second.size() == 3);
+        }
+      }
+      SECTION("bytes") {
+        const auto bytes = read_binary_file(path);
+        const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+          bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+        );
+        REQUIRE(sketch.is_empty() == (n == 0));
+        REQUIRE(sketch.is_estimation_mode() == (n > 1000));
+        REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+        for (const auto& entry: sketch) {
+          REQUIRE(entry.first < sketch.get_theta64());
+          REQUIRE(entry.second.size() == 3);
+        }
+      }
+    }
+  }
+
+  TEST_CASE("aos sketch non-empty no entries", "[serde_compat]") {
+    const auto path = testBinaryInputPath + "aos_1_non_empty_no_entries_java.sk";
+    SECTION("stream") {
+      std::ifstream is;
+      is.exceptions(std::ios::failbit | std::ios::badbit);
+      is.open(path, std::ios::binary);
+      const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+        is, DEFAULT_SEED, default_array_of_strings_serde<>()
+      );
+      REQUIRE_FALSE(sketch.is_empty());
+      REQUIRE(sketch.get_num_retained() == 0);
+    }
+    SECTION("bytes") {
+      const auto bytes = read_binary_file(path);
+      const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+        bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+      );
+      REQUIRE_FALSE(sketch.is_empty());
+      REQUIRE(sketch.get_num_retained() == 0);
+    }
+  }
+
+  TEST_CASE("aos sketch multi keys strings", "[serde_compat]") {
+    const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+    for (const unsigned n: n_arr) {
+      const auto path = testBinaryInputPath + "aos_multikey_n" + std::to_string(n) + "_java.sk";
+      SECTION("stream") {
+        std::ifstream is;
+        is.exceptions(std::ios::failbit | std::ios::badbit);
+        is.open(path, std::ios::binary);
+        const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+          is, DEFAULT_SEED, default_array_of_strings_serde<>()
+        );
+        REQUIRE(sketch.is_empty() == (n == 0));
+        REQUIRE(sketch.is_estimation_mode() == (n > 1000));
+        REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+        for (const auto& entry: sketch) {
+          REQUIRE(entry.first < sketch.get_theta64());
+          REQUIRE(entry.second.size() == 1);
+        }
+      }
+      SECTION("bytes") {
+        const auto bytes = read_binary_file(path);
+        const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+          bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+        );
+        REQUIRE(sketch.is_empty() == (n == 0));
+        REQUIRE(sketch.is_estimation_mode() == (n > 1000));
+        REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+        for (const auto& entry: sketch) {
+          REQUIRE(entry.first < sketch.get_theta64());
+          REQUIRE(entry.second.size() == 1);
+        }
+      }
+    }
+  }
+
+  TEST_CASE("aos sketch unicode strings", "[serde_compat]") {
+    const auto path = testBinaryInputPath + "aos_unicode_java.sk";
+    auto check = [](const compact_array_of_strings_tuple_sketch<>& sketch) {
+      REQUIRE_FALSE(sketch.is_empty());
+      REQUIRE_FALSE(sketch.is_estimation_mode());
+      REQUIRE(sketch.get_num_retained() == 3);
+
+      const std::vector<std::vector<std::string>> expected_values = {
+        {"밸류", "값"},
+        {"📦", "🎁"},
+        {"ценить1", "ценить2"}
+      };
+      std::vector<bool> matched(expected_values.size(), false);
+      for (const auto& entry: sketch) {
+        REQUIRE(entry.first < sketch.get_theta64());
+        REQUIRE(entry.second.size() == 2);
+
+        bool found = false;
+        for (size_t i = 0; i < expected_values.size(); ++i) {
+          if (matched[i]) continue;
+          const auto& expected = expected_values[i];
+          if (entry.second.size() != expected.size()) continue;
+          bool equal = true;
+          for (size_t j = 0; j < expected.size(); ++j) {
+            if (entry.second[j] != expected[j]) {
+              equal = false;
+              break;
+            }
+          }
+          if (equal) {
+            matched[i] = true;
+            found = true;
+            break;
+          }
+        }
+        REQUIRE(found);
+      }
+      for (bool found: matched) REQUIRE(found);
+    };
+    SECTION("stream") {
+      std::ifstream is;
+      is.exceptions(std::ios::failbit | std::ios::badbit);
+      is.open(path, std::ios::binary);
+      const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+        is, DEFAULT_SEED, default_array_of_strings_serde<>()
+      );
+      check(sketch);
+    }
+    SECTION("bytes") {
+      const auto bytes = read_binary_file(path);
+      const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+        bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+      );
+      check(sketch);
+    }
+  }
+
+  TEST_CASE("aos sketch empty strings", "[serde_compat]") {
+    const auto path = testBinaryInputPath + "aos_empty_strings_java.sk";
+    auto check = [](const compact_array_of_strings_tuple_sketch<>& sketch) {
+      REQUIRE_FALSE(sketch.is_empty());
+      REQUIRE_FALSE(sketch.is_estimation_mode());
+      REQUIRE(sketch.get_num_retained() == 3);
+      const std::vector<std::vector<std::string>> expected_values = {
+        {"empty_key_value"},
+        {""},
+        {"", ""}
+      };
+      std::vector<bool> matched(expected_values.size(), false);
+      for (const auto& entry: sketch) {
+        REQUIRE(entry.first < sketch.get_theta64());
+
+        bool found = false;
+        for (size_t i = 0; i < expected_values.size(); ++i) {
+          if (matched[i]) continue;
+          const auto& expected = expected_values[i];
+          if (entry.second.size() != expected.size()) continue;
+          bool equal = true;
+          for (size_t j = 0; j < expected.size(); ++j) {
+            if (entry.second[j] != expected[j]) {
+              equal = false;
+              break;
+            }
+          }
+          if (equal) {
+            matched[i] = true;
+            found = true;
+            break;
+          }
+        }
+        REQUIRE(found);
+      }
+      for (bool found: matched) REQUIRE(found);
+    };
+    SECTION("stream") {
+      std::ifstream is;
+      is.exceptions(std::ios::failbit | std::ios::badbit);
+      is.open(path, std::ios::binary);
+      const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+        is, DEFAULT_SEED, default_array_of_strings_serde<>()
+      );
+      check(sketch);
+    }
+    SECTION("bytes") {
+      const auto bytes = read_binary_file(path);
+      const auto sketch = compact_array_of_strings_tuple_sketch<>::deserialize(
+        bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+      );
+      check(sketch);
+    }
+  }
+}
diff --git a/tuple/test/aos_sketch_serialize_for_java.cpp b/tuple/test/aos_sketch_serialize_for_java.cpp
new file mode 100644
index 0000000..c6eb0df
--- /dev/null
+++ b/tuple/test/aos_sketch_serialize_for_java.cpp
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <catch2/catch.hpp>
+#include <fstream>
+#include <initializer_list>
+
+#include "array_of_strings_sketch.hpp"
+
+namespace datasketches {
+
+using aos_sketch = update_array_of_strings_tuple_sketch<>;
+using array_of_strings = array<std::string>;
+
+static array_of_strings make_array(std::initializer_list<std::string> items) {
+  array_of_strings array(static_cast<uint8_t>(items.size()), "");
+  size_t i = 0;
+  for (const auto& item: items) {
+    array[static_cast<uint8_t>(i)] = item;
+    ++i;
+  }
+  return array;
+}
+
+TEST_CASE("aos sketch generate one value", "[serialize_for_java]") {
+  const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+  for (const unsigned n: n_arr) {
+    auto sketch = aos_sketch::builder().build();
+    for (unsigned i = 0; i < n; ++i) {
+      array_of_strings key(1, "");
+      key[0] = std::to_string(i);
+      array_of_strings value(1, "");
+      value[0] = "value" + std::to_string(i);
+      sketch.update(hash_array_of_strings_key(key), value);
+    }
+    REQUIRE(sketch.is_empty() == (n == 0));
+    REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+    std::ofstream os("aos_1_n" + std::to_string(n) + "_cpp.sk", std::ios::binary);
+    compact_array_of_strings_sketch(sketch).serialize(os, default_array_of_strings_serde<>());
+  }
+}
+
+TEST_CASE("aos sketch generate three values", "[serialize_for_java]") {
+  const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+  for (const unsigned n: n_arr) {
+    auto sketch = aos_sketch::builder().build();
+    for (unsigned i = 0; i < n; ++i) {
+      array_of_strings key(1, "");
+      key[0] = std::to_string(i);
+      array_of_strings value(3, "");
+      value[0] = "a" + std::to_string(i);
+      value[1] = "b" + std::to_string(i);
+      value[2] = "c" + std::to_string(i);
+      sketch.update(hash_array_of_strings_key(key), value);
+    }
+    REQUIRE(sketch.is_empty() == (n == 0));
+    REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+    std::ofstream os("aos_3_n" + std::to_string(n) + "_cpp.sk", std::ios::binary);
+    compact_array_of_strings_sketch(sketch).serialize(os, default_array_of_strings_serde<>());
+  }
+}
+
+TEST_CASE("aos sketch generate non-empty no entries", "[serialize_for_java]") {
+  auto sketch = aos_sketch::builder()
+    .set_lg_k(12)
+    .set_resize_factor(resize_factor::X8)
+    .set_p(0.01f)
+    .build();
+  array_of_strings key(1, "");
+  key[0] = "key1";
+  array_of_strings value(1, "");
+  value[0] = "value1";
+  sketch.update(hash_array_of_strings_key(key), value);
+  REQUIRE_FALSE(sketch.is_empty());
+  REQUIRE(sketch.get_num_retained() == 0);
+  std::ofstream os("aos_1_non_empty_no_entries_cpp.sk", std::ios::binary);
+  compact_array_of_strings_sketch(sketch).serialize(os, default_array_of_strings_serde<>());
+}
+
+TEST_CASE("aos sketch generate multi key strings", "[serialize_for_java]") {
+  const unsigned n_arr[] = {0, 1, 10, 100, 1000, 10000, 100000, 1000000};
+  for (const unsigned n: n_arr) {
+    auto sketch = aos_sketch::builder().build();
+    for (unsigned i = 0; i < n; ++i) {
+      array_of_strings key(2, "");
+      key[0] = "key" + std::to_string(i);
+      key[1] = "subkey" + std::to_string(i % 10);
+      array_of_strings value(1, "");
+      value[0] = "value" + std::to_string(i);
+      sketch.update(hash_array_of_strings_key(key), value);
+    }
+    REQUIRE(sketch.is_empty() == (n == 0));
+    REQUIRE(sketch.get_estimate() == Approx(n).margin(n * 0.03));
+    std::ofstream os("aos_multikey_n" + std::to_string(n) + "_cpp.sk", std::ios::binary);
+    compact_array_of_strings_sketch(sketch).serialize(os, default_array_of_strings_serde<>());
+  }
+}
+
+TEST_CASE("aos sketch generate unicode strings", "[serialize_for_java]") {
+  auto sketch = aos_sketch::builder().build();
+  sketch.update(
+    hash_array_of_strings_key(make_array({u8"키", u8"열쇠"})),
+    make_array({u8"밸류", u8"값"})
+  );
+  sketch.update(
+    hash_array_of_strings_key(make_array({u8"🔑", u8"🗝️"})),
+    make_array({u8"📦", u8"🎁"})
+  );
+  sketch.update(
+    hash_array_of_strings_key(make_array({u8"ключ1", u8"ключ2"})),
+    make_array({u8"ценить1", u8"ценить2"})
+  );
+  REQUIRE_FALSE(sketch.is_empty());
+  REQUIRE(sketch.get_num_retained() == 3);
+  std::ofstream os("aos_unicode_cpp.sk", std::ios::binary);
+  compact_array_of_strings_sketch(sketch).serialize(os, default_array_of_strings_serde<>());
+}
+
+TEST_CASE("aos sketch generate empty strings", "[serialize_for_java]") {
+  auto sketch = aos_sketch::builder().build();
+  sketch.update(hash_array_of_strings_key(make_array({""})), make_array({"empty_key_value"}));
+  sketch.update(hash_array_of_strings_key(make_array({"empty_value_key"})), make_array({""}));
+  sketch.update(hash_array_of_strings_key(make_array({"", ""})), make_array({"", ""}));
+  REQUIRE_FALSE(sketch.is_empty());
+  REQUIRE(sketch.get_num_retained() == 3);
+  std::ofstream os("aos_empty_strings_cpp.sk", std::ios::binary);
+  compact_array_of_strings_sketch(sketch).serialize(os, default_array_of_strings_serde<>());
+}
+
+} /* namespace datasketches */
diff --git a/tuple/test/array_of_strings_sketch_test.cpp b/tuple/test/array_of_strings_sketch_test.cpp
new file mode 100644
index 0000000..5507c07
--- /dev/null
+++ b/tuple/test/array_of_strings_sketch_test.cpp
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <algorithm>
+#include <fstream>
+#include <sstream>
+#include <string>
+#include <vector>
+
+#include <catch2/catch.hpp>
+
+#include "array_of_strings_sketch.hpp"
+
+namespace datasketches {
+
+TEST_CASE("aos update policy", "[tuple_sketch]") {
+  default_array_of_strings_update_policy policy;
+
+  SECTION("create empty") {
+    auto values = policy.create();
+    REQUIRE(values.size() == 0);
+  }
+
+  SECTION("replace array") {
+    auto values = policy.create();
+
+    array_of_strings input(2, "", std::allocator<std::string>());
+    input[0] = "alpha";
+    input[1] = "beta";
+    policy.update(values, input);
+    REQUIRE(values.size() == 2);
+    REQUIRE(values[0] == "alpha");
+    REQUIRE(values[1] == "beta");
+    input[0] = "changed";
+    REQUIRE(values[0] == "alpha");
+
+    array_of_strings input2(1, "", std::allocator<std::string>());
+    input2[0] = "gamma";
+    policy.update(values, input2);
+    REQUIRE(values.size() == 1);
+    REQUIRE(values[0] == "gamma");
+  }
+
+  SECTION("nullptr clears") {
+    array_of_strings values(2, "", std::allocator<std::string>());
+    values[0] = "one";
+    values[1] = "two";
+
+    policy.update(values, nullptr);
+    REQUIRE(values.size() == 0);
+  }
+
+  SECTION("pointer input copies") {
+    auto values = policy.create();
+
+    array_of_strings input(2, "", std::allocator<std::string>());
+    input[0] = "first";
+    input[1] = "second";
+    policy.update(values, &input);
+    REQUIRE(values.size() == 2);
+    REQUIRE(values[1] == "second");
+    input[1] = "changed";
+    REQUIRE(values[1] == "second");
+  }
+}
+
+TEST_CASE("aos sketch update", "[tuple_sketch]") {
+  auto make_array = [](std::initializer_list<const char*> entries) {
+    array_of_strings array(static_cast<uint8_t>(entries.size()), "", std::allocator<std::string>());
+    uint8_t i = 0;
+    for (const auto* entry: entries) array[i++] = entry;
+    return array;
+  };
+
+  SECTION("same key replaces summary") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().build();
+
+    sketch.update(
+      hash_array_of_strings_key(make_array({"alpha", "beta"})),
+      make_array({"first"})
+    );
+    sketch.update(
+      hash_array_of_strings_key(make_array({"alpha", "beta"})),
+      make_array({"second", "third"})
+    );
+
+    REQUIRE(sketch.get_num_retained() == 1);
+
+    auto it = sketch.begin();
+    REQUIRE(it != sketch.end());
+    REQUIRE(it->second.size() == 2);
+    REQUIRE(it->second[0] == "second");
+    REQUIRE(it->second[1] == "third");
+  }
+
+  SECTION("distinct keys retain multiple entries") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().build();
+
+    sketch.update(
+      hash_array_of_strings_key(make_array({"a", "bc"})),
+      make_array({"one"})
+    );
+    sketch.update(
+      hash_array_of_strings_key(make_array({"ab", "c"})),
+      make_array({"two"})
+    );
+
+    REQUIRE(sketch.get_num_retained() == 2);
+
+    bool saw_one = false;
+    bool saw_two = false;
+    for (const auto& entry: sketch) {
+      REQUIRE(entry.second.size() == 1);
+      if (entry.second[0] == "one") saw_one = true;
+      if (entry.second[0] == "two") saw_two = true;
+    }
+    REQUIRE(saw_one);
+    REQUIRE(saw_two);
+  }
+
+  SECTION("empty key") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().build();
+
+    sketch.update(hash_array_of_strings_key(make_array({})), make_array({"value"}));
+    REQUIRE(sketch.get_num_retained() == 1);
+
+    auto it = sketch.begin();
+    REQUIRE(it != sketch.end());
+    REQUIRE(it->second.size() == 1);
+    REQUIRE(it->second[0] == "value");
+  }
+}
+
+TEST_CASE("aos sketch: serialize deserialize", "[tuple_sketch]") {
+  auto make_array = [](std::initializer_list<std::string> entries) {
+    array_of_strings array(static_cast<uint8_t>(entries.size()), "", std::allocator<std::string>());
+    uint8_t i = 0;
+    for (const auto& entry: entries) array[i++] = entry;
+    return array;
+  };
+
+  auto collect_entries = [](const compact_array_of_strings_tuple_sketch<>& sketch) {
+    typedef std::pair<uint64_t, array_of_strings> entry_type;
+    std::vector<entry_type> entries;
+    for (const auto& entry: sketch) entries.push_back(entry);
+    struct entry_less {
+      bool operator()(const entry_type& lhs, const entry_type& rhs) const {
+        return lhs.first < rhs.first;
+      }
+    };
+    std::sort(entries.begin(), entries.end(), entry_less());
+    return entries;
+  };
+
+  auto check_round_trip = [&](const compact_array_of_strings_tuple_sketch<>& compact_sketch) {
+    std::stringstream ss;
+    ss.exceptions(std::ios::failbit | std::ios::badbit);
+    compact_sketch.serialize(ss, default_array_of_strings_serde<>());
+    auto deserialized_stream = compact_array_of_strings_tuple_sketch<>::deserialize(
+      ss, DEFAULT_SEED, default_array_of_strings_serde<>()
+    );
+
+    auto bytes = compact_sketch.serialize(0, default_array_of_strings_serde<>());
+    auto deserialized_bytes = compact_array_of_strings_tuple_sketch<>::deserialize(
+      bytes.data(), bytes.size(), DEFAULT_SEED, default_array_of_strings_serde<>()
+    );
+
+    const compact_array_of_strings_tuple_sketch<>* deserialized_list[2] = {
+      &deserialized_stream,
+      &deserialized_bytes
+    };
+    for (int list_index = 0; list_index < 2; ++list_index) {
+      const compact_array_of_strings_tuple_sketch<>* deserialized = deserialized_list[list_index];
+      REQUIRE(compact_sketch.is_empty() == deserialized->is_empty());
+      REQUIRE(compact_sketch.is_estimation_mode() == deserialized->is_estimation_mode());
+      REQUIRE(compact_sketch.is_ordered() == deserialized->is_ordered());
+      REQUIRE(compact_sketch.get_num_retained() == deserialized->get_num_retained());
+      REQUIRE(compact_sketch.get_theta() == Approx(deserialized->get_theta()).margin(1e-10));
+      REQUIRE(compact_sketch.get_estimate() == Approx(deserialized->get_estimate()).margin(1e-10));
+      REQUIRE(compact_sketch.get_lower_bound(1) == Approx(deserialized->get_lower_bound(1)).margin(1e-10));
+      REQUIRE(compact_sketch.get_upper_bound(1) == Approx(deserialized->get_upper_bound(1)).margin(1e-10));
+
+      auto original_entries = collect_entries(compact_sketch);
+      auto round_trip_entries = collect_entries(*deserialized);
+      REQUIRE(original_entries.size() == round_trip_entries.size());
+      for (size_t i = 0; i < original_entries.size(); ++i) {
+        REQUIRE(original_entries[i].first == round_trip_entries[i].first);
+        REQUIRE(original_entries[i].second.size() == round_trip_entries[i].second.size());
+        for (size_t j = 0; j < original_entries[i].second.size(); ++j) {
+          REQUIRE(original_entries[i].second[static_cast<uint8_t>(j)] ==
+            round_trip_entries[i].second[static_cast<uint8_t>(j)]);
+        }
+      }
+    }
+  };
+
+  auto run_tests = [&](const update_array_of_strings_tuple_sketch<>& sketch) {
+    auto ordered = compact_array_of_strings_sketch(sketch, true);
+    auto unordered = compact_array_of_strings_sketch(sketch, false);
+    check_round_trip(ordered);
+    check_round_trip(unordered);
+  };
+
+  SECTION("empty sketch") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().build();
+    run_tests(sketch);
+  }
+
+  SECTION("single entry sketch") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().build();
+    sketch.update(hash_array_of_strings_key(make_array({"key"})), make_array({"value"}));
+    run_tests(sketch);
+  }
+
+  SECTION("multiple entries exact mode") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().set_lg_k(8).build();
+    for (int i = 0; i < 50; ++i) {
+      sketch.update(
+        hash_array_of_strings_key(make_array({std::string("key-") + std::to_string(i)})),
+        make_array({std::string("value-") + std::to_string(i), "extra"})
+      );
+    }
+    REQUIRE_FALSE(sketch.is_estimation_mode());
+    run_tests(sketch);
+  }
+
+  SECTION("multiple entries estimation mode") {
+    auto sketch = update_array_of_strings_tuple_sketch<>::builder().build();
+    for (int i = 0; i < 10000; ++i) {
+      sketch.update(
+        hash_array_of_strings_key(make_array({std::string("key-") + std::to_string(i)})),
+        make_array({std::string("value-") + std::to_string(i)})
+      );
+    }
+    REQUIRE(sketch.is_estimation_mode());
+    run_tests(sketch);
+  }
+}
+
+TEST_CASE("aos serde validation", "[tuple_sketch]") {
+  default_array_of_strings_serde<> serde;
+
+  SECTION("too many nodes rejected") {
+    array_of_strings array(128, "", std::allocator<std::string>());
+    std::stringstream ss;
+    ss.exceptions(std::ios::failbit | std::ios::badbit);
+    REQUIRE_THROWS_WITH(
+      serde.serialize(ss, &array, 1),
+      Catch::Matchers::Contains("size exceeds 127")
+    );
+  }
+}
+
+} /* namespace datasketches */