Support transparent id in composite id (#12238)

Co-authored-by: Geoffrey Martin-Noble <gcmn@google.com>
diff --git a/build_tools/python/e2e_test_framework/CMakeLists.txt b/build_tools/python/e2e_test_framework/CMakeLists.txt
index 62541ac..2abba74 100644
--- a/build_tools/python/e2e_test_framework/CMakeLists.txt
+++ b/build_tools/python/e2e_test_framework/CMakeLists.txt
@@ -11,4 +11,11 @@
     "serialization_test.py"
 )
 
+iree_build_tools_py_test(
+  NAME
+    unique_ids_test
+  SRC
+    "unique_ids_test.py"
+)
+
 add_subdirectory(device_specs)
diff --git a/build_tools/python/e2e_test_framework/definitions/iree_definitions.py b/build_tools/python/e2e_test_framework/definitions/iree_definitions.py
index 52b933b..b16c921 100644
--- a/build_tools/python/e2e_test_framework/definitions/iree_definitions.py
+++ b/build_tools/python/e2e_test_framework/definitions/iree_definitions.py
@@ -15,22 +15,6 @@
 from e2e_test_framework import serialization, unique_ids
 
 
-def _hash_composite_id(keys: Sequence[str]) -> str:
-  """Computes the composite hash id from string keys.
-
-  String keys are the component ids that compose this composite object. We hash
-  the composite id since the id isn't designed to be inspected and insufficient
-  to reconstruct the original composite object.
-
-  Args:
-    keys: list of string keys.
-
-  Returns:
-    Unique hash id.
-  """
-  return hashlib.sha256(":".join(keys).encode("utf-8")).hexdigest()
-
-
 class TargetBackend(Enum):
   """IREE target backend."""
   LLVM_CPU = "llvm-cpu"
@@ -185,7 +169,7 @@
   import_config: ImportConfig
 
   def composite_id(self):
-    return _hash_composite_id([self.model.id, self.import_config.id])
+    return unique_ids.hash_composite_id([self.model.id, self.import_config.id])
 
   @staticmethod
   def from_model(model: common_definitions.Model):
@@ -204,7 +188,7 @@
   compile_config: CompileConfig
 
   def composite_id(self):
-    return _hash_composite_id(
+    return unique_ids.hash_composite_id(
         [self.imported_model.composite_id(), self.compile_config.id])
 
 
@@ -218,7 +202,7 @@
   input_data: common_definitions.ModelInputData
 
   def composite_id(self):
-    return _hash_composite_id([
+    return unique_ids.hash_composite_id([
         self.module_generation_config.composite_id(),
         self.module_execution_config.id, self.target_device_spec.id,
         self.input_data.id
diff --git a/build_tools/python/e2e_test_framework/unique_ids.py b/build_tools/python/e2e_test_framework/unique_ids.py
index e26ea00..b9f80c9 100644
--- a/build_tools/python/e2e_test_framework/unique_ids.py
+++ b/build_tools/python/e2e_test_framework/unique_ids.py
@@ -3,11 +3,63 @@
 # Licensed under the Apache License v2.0 with LLVM Exceptions.
 # See https://llvm.org/LICENSE.txt for license information.
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-"""List of unique random IDs in the benchmark suites.
+"""List of unique random IDs in the framework and id utilities.
 
 Each ID should be generated from uuid.uuid4().
 """
 
+import hashlib
+from typing import Sequence
+
+# Special id which will be ignored when calculating the composite id.
+#
+# It should only be used when adding a new field to a composite object while
+# we want to maintain the same id on the existing composite objects.
+#
+# In such case, you need to create a "default config" for the new field with
+# this id and populate that config to the fields of the existing objects. The
+# composite id computing function will ignore this id and keep the output
+# unchanged.
+TRANSPARENT_ID = "00000000-0000-0000-0000-000000000000"
+
+
+def hash_composite_id(keys: Sequence[str]) -> str:
+  """Computes the composite hash id from string keys.
+
+  String keys are the component ids that compose this composite object. We hash
+  the composite id since the id isn't designed to be inspected and insufficient
+  to reconstruct the original composite object.
+
+  Note that the output is sensitive to the order of the keys, and any key ==
+  TRANSPARENT_ID will be skipped. When adding a new key to the keys, the new key
+  should be always appended to the end. In this way, the composite id can be
+  unchanged for the existing composite object if they use TRANSPARENT_ID on the
+  new keyed field.
+
+  The composite id is computed in the following steps:
+  1. Index each key with its position in the list from 0.
+  2. Remove any key == TRANSPARENT_ID
+  3. Get the SHA256 hex digest of "0-key_0:1-key_1:..."
+
+  Step 1 is needed to avoid the ambiguity between:
+  ["key_abc", TRANSPARENT_ID] and [TRANSPARENT_ID, "key_abc"]
+  since after removing TRANSPARENT_ID, they both become ["key_abc"] without the
+  position index.
+
+  Args:
+    keys: list of string keys.
+
+  Returns:
+    Unique composite id.
+  """
+  trimmed_indexed_key = [
+      f"{index}-{key}" for index, key in enumerate(keys)
+      if key != TRANSPARENT_ID
+  ]
+  return hashlib.sha256(
+      ":".join(trimmed_indexed_key).encode("utf-8")).hexdigest()
+
+
 # Models
 MODEL_DEEPLABV3_FP32 = "c36c63b0-220a-4d78-8ade-c45ce47d89d3"
 MODEL_MOBILESSD_FP32 = "0e466f69-91d6-4e50-b62b-a82b6213a231"
diff --git a/build_tools/python/e2e_test_framework/unique_ids_test.py b/build_tools/python/e2e_test_framework/unique_ids_test.py
new file mode 100644
index 0000000..2fdf8fc
--- /dev/null
+++ b/build_tools/python/e2e_test_framework/unique_ids_test.py
@@ -0,0 +1,52 @@
+# Copyright 2023 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+import hashlib
+import unittest
+
+import unique_ids
+
+
+class UniqueIdsTest(unittest.TestCase):
+
+  def test_hash_composite_id(self):
+    output = unique_ids.hash_composite_id(["abc", "123"])
+
+    self.assertEquals(
+        output,
+        hashlib.sha256(f"0-abc:1-123".encode("utf-8")).hexdigest())
+
+  def test_hash_composite_id_diff_keys(self):
+    ids = [
+        unique_ids.hash_composite_id([]),
+        unique_ids.hash_composite_id(["abc", "123"]),
+        unique_ids.hash_composite_id(["123", "abc"]),
+        unique_ids.hash_composite_id(["123", unique_ids.TRANSPARENT_ID]),
+        unique_ids.hash_composite_id(["123", "abc", "xyz"]),
+        unique_ids.hash_composite_id(["123", unique_ids.TRANSPARENT_ID, "xyz"])
+    ]
+
+    # Check if they are all distinct.
+    self.assertCountEqual(set(ids), ids)
+
+  def test_hash_composite_id_unchanged_with_transparent_id(self):
+    existing_id = unique_ids.hash_composite_id(["abc"])
+    new_id_a = unique_ids.hash_composite_id(["abc", unique_ids.TRANSPARENT_ID])
+    new_id_b = unique_ids.hash_composite_id(
+        ["abc", unique_ids.TRANSPARENT_ID, unique_ids.TRANSPARENT_ID])
+
+    self.assertEquals(existing_id, new_id_a)
+    self.assertEquals(existing_id, new_id_b)
+
+  def test_hash_composite_id_with_transparent_ids_in_diff_pos(self):
+    id_a = unique_ids.hash_composite_id([unique_ids.TRANSPARENT_ID, "abc"])
+    id_b = unique_ids.hash_composite_id(["abc", unique_ids.TRANSPARENT_ID])
+
+    self.assertNotEquals(id_a, id_b)
+
+
+if __name__ == "__main__":
+  unittest.main()