chromium/chrome/browser/ash/app_list/search/util/score_normalizer_unittest.cc

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/ash/app_list/search/util/score_normalizer.h"

#include "base/files/scoped_temp_dir.h"
#include "base/test/task_environment.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"

namespace app_list::test {
namespace {

constexpr double kDefaultScore = 0.5;

void ExpectBinsEqual(const ScoreNormalizerProto* proto,
                     const std::vector<double>& dividers,
                     const std::vector<double>& counts) {
  const auto& it = proto->normalizers().find("testing");
  if (it == proto->normalizers().end())
    FAIL();
  const auto& normalizer = it->second;

  ASSERT_EQ(dividers.size() + 1, counts.size());
  ASSERT_EQ(static_cast<size_t>(normalizer.bins_size()), counts.size());

  double total = 0.0;
  for (size_t i = 0; i < counts.size(); ++i) {
    const auto& bin = normalizer.bins().at(i);
    EXPECT_EQ(bin.count(), counts[i]);
    // The bottom bin has an automatically-created lower_divider of -infinity.
    EXPECT_EQ(bin.lower_divider(), i == 0 ? -INFINITY : dividers[i - 1]);
    total += counts[i];
  }
  EXPECT_EQ(normalizer.total(), total);
}

}  // namespace

class ScoreNormalizerTest : public testing::Test {
 public:
  void SetUp() override { ASSERT_TRUE(temp_dir_.CreateUniqueTempDir()); }

  base::FilePath GetPath() { return temp_dir_.GetPath().Append("proto"); }

  ash::PersistentProto<ScoreNormalizerProto> GetProto() {
    return ash::PersistentProto<ScoreNormalizerProto>(GetPath(),
                                                      base::Seconds(0));
  }

  ScoreNormalizer::Params TestingParams(size_t bins = 4) {
    ScoreNormalizer::Params params;
    params.version = 3;
    params.max_bins = bins;
    return params;
  }

  ScoreNormalizerProto ReadFromDisk() {
    std::string proto_str;
    CHECK(base::ReadFileToString(GetPath(), &proto_str));
    ScoreNormalizerProto proto;
    CHECK(proto.ParseFromString(proto_str));
    return proto;
  }

  void WriteToDisk(const std::vector<double>& dividers,
                   const std::vector<double>& counts) {
    ASSERT_EQ(dividers.size() + 1, counts.size());

    ScoreNormalizerProto proto;
    proto.set_model_version(1);
    proto.set_parameter_version(TestingParams().version);

    auto& normalizer = (*proto.mutable_normalizers())["testing"];
    double total = 0.0;
    for (size_t i = 0; i < counts.size(); ++i) {
      auto& bin = *normalizer.add_bins();
      // The bottom bin has an automatically-created lower_divider of -infinity.
      bin.set_lower_divider(i == 0 ? -INFINITY : dividers[i - 1]);
      bin.set_count(counts[i]);
      total += counts[i];
    }
    normalizer.set_total(total);

    WriteToDisk(proto);
  }

  void WriteToDisk(const ScoreNormalizerProto& proto) {
    ASSERT_TRUE(base::WriteFile(GetPath(), proto.SerializeAsString()));
  }

  void Wait() { task_environment_.RunUntilIdle(); }

  ScoreNormalizerProto* get_proto(ScoreNormalizer& normalizer) {
    return normalizer.proto_.get();
  }

  base::test::TaskEnvironment task_environment_{
      base::test::TaskEnvironment::MainThreadType::UI,
      base::test::TaskEnvironment::ThreadPoolExecutionMode::QUEUED,
      base::test::TaskEnvironment::TimeSource::MOCK_TIME};
  base::ScopedTempDir temp_dir_;
};

// A dummy test to ensure that the score normalizer doesn't crash on
// initialization.
TEST_F(ScoreNormalizerTest, Initialize) {
  ScoreNormalizer normalizer(GetProto(), TestingParams());
  Wait();
  SUCCEED();
}

TEST_F(ScoreNormalizerTest, StateClearedOnVersionChange) {
  {
    ScoreNormalizerProto proto;
    proto.set_parameter_version(1);
    WriteToDisk(proto);
  }

  {
    ScoreNormalizer normalizer(GetProto(), TestingParams());
    Wait();
    EXPECT_EQ(get_proto(normalizer)->parameter_version(), 3);
    // TODO(crbug.com/40177716): Check that state is reset once it's added to
    // the proto.
  }
}

// The first N scores don't do any bin merging/splitting logic, they just fill
// out the bins. Check we do this correctly.
TEST_F(ScoreNormalizerTest, PopulateBins) {
  ScoreNormalizer normalizer(GetProto(), TestingParams());
  Wait();
  normalizer.Update("testing", 1);
  normalizer.Update("testing", 2);
  normalizer.Update("testing", 3);

  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {0, 1, 1, 1});
}

// Updates to the bins should end up on disk.
TEST_F(ScoreNormalizerTest, BinUpdatesWrittenToDisk) {
  {
    ScoreNormalizer normalizer(GetProto(), TestingParams());
    Wait();
    normalizer.Update("testing", 1);
    normalizer.Update("testing", 2);
    normalizer.Update("testing", 3);
    Wait();
  }

  auto proto = ReadFromDisk();
  ExpectBinsEqual(&proto, {1, 2, 3}, {0, 1, 1, 1});
}

// Add scores in such a way that bins should never split.
TEST_F(ScoreNormalizerTest, AddScoresWithoutSplitting) {
  ScoreNormalizer normalizer(GetProto(), TestingParams());
  Wait();
  normalizer.Update("testing", 1);
  normalizer.Update("testing", 2);
  normalizer.Update("testing", 3);

  // Max entropy.
  normalizer.Update("testing", 0);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {1, 1, 1, 1});

  // These three are balanced, we can achieve equal entropy by doing a
  // split/merge, but not improve it. This shouldn't happen.
  normalizer.Update("testing", -100);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {2, 1, 1, 1});
  normalizer.Update("testing", 1.2);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {2, 2, 1, 1});
  normalizer.Update("testing", 2.3);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {2, 2, 2, 1});

  // Back to max entropy.
  normalizer.Update("testing", 400);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {2, 2, 2, 2});

  // Any split is lower entropy.
  normalizer.Update("testing", 2.5);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {2, 2, 3, 2});

  // Back to balance.
  normalizer.Update("testing", 2.1);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {2, 2, 4, 2});
}

// Add scores to cause one split/merge, where the split index is to the left of
// the merge index. In this case the split and merge and directly next to each
// other.
TEST_F(ScoreNormalizerTest, SmallLefthandedSplitMerge) {
  ScoreNormalizer normalizer(GetProto(), TestingParams());
  Wait();

  // Set up some bins.
  for (const auto& score : {1, 2, 3, 0, 1})
    normalizer.Update("testing", score);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {1, 2, 1, 1});

  // This should split/merge into:
  //     1     1     2
  // 1.0 | 1.5 | 1.5 | 2.0
  normalizer.Update("testing", 1);
  ExpectBinsEqual(get_proto(normalizer), {1, 1, 2}, {1.0, 1.5, 1.5, 2.0});
}

// Add scores to cause one split/merge, where the split index is to the left of
// the merge index. In this case there's a gap between the split and merge.
TEST_F(ScoreNormalizerTest, LargeLefthandedSplitMerge) {
  auto params = TestingParams();
  params.max_bins = 5;
  ScoreNormalizer normalizer(GetProto(), params);
  Wait();

  // Set up some bins.
  for (const auto& score : {1, 2, 3, 4, 0, 0, 1, 2, 3})
    normalizer.Update("testing", score);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3, 4}, {2, 2, 2, 2, 1});

  // This should split/merge into:
  //     0     1     2     3
  // 1.5 | 1.5 | 2.0 | 2.0 | 3.0
  normalizer.Update("testing", 0);
  ExpectBinsEqual(get_proto(normalizer), {0, 1, 2, 3},
                  {1.5, 1.5, 2.0, 2.0, 3.0});
}

// Add scores to cause one split/merge, where the split index is to the right of
// the merge index. In this case the split and merge and directly next to each
// other.
TEST_F(ScoreNormalizerTest, SmallRighthandedSplitMerge) {
  ScoreNormalizer normalizer(GetProto(), TestingParams());
  Wait();

  // Set up some bins.
  for (const auto& score : {1, 2, 3, 0, 2})
    normalizer.Update("testing", score);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3}, {1, 1, 2, 1});

  // This should split/merge into:
  //     2     3     3
  // 1.0 | 1.5 | 1.5 | 2.0
  normalizer.Update("testing", 2);
  ExpectBinsEqual(get_proto(normalizer), {2, 2, 3}, {2.0, 1.5, 1.5, 1.0});
}

// Add scores to cause one split/merge, where the split index is to the right of
// the merge index. In this case there's a gap between the split and merge.
TEST_F(ScoreNormalizerTest, LargeRighthandedSplitMerge) {
  ScoreNormalizer normalizer(GetProto(), TestingParams(5));
  Wait();

  // Set up some bins.
  for (const auto& score : {1, 2, 3, 4, 0, 1, 2, 3, 4})
    normalizer.Update("testing", score);
  ExpectBinsEqual(get_proto(normalizer), {1, 2, 3, 4}, {1, 2, 2, 2, 2});

  // This should split/merge into:
  //     2     3     4     5
  // 3.0 | 2.0 | 2.0 | 1.5 | 1.5
  normalizer.Update("testing", 5);
  ExpectBinsEqual(get_proto(normalizer), {2, 3, 4, 5},
                  {3.0, 2.0, 2.0, 1.5, 1.5});
}

// Tests early-exit cases when normalizing a score.
TEST_F(ScoreNormalizerTest, NormalizeSpecialCases) {
  {
    // A normalizer that hasn't finished initializing.
    ScoreNormalizer normalizer(GetProto(), TestingParams(5));
    EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 1.0), kDefaultScore);
  }

  {
    // An empty normalizer
    ScoreNormalizer normalizer(GetProto(), TestingParams(5));
    Wait();
    EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 1.0), kDefaultScore);
  }

  {
    // A normalizer with two bins
    ScoreNormalizer normalizer(GetProto(), TestingParams(5));
    Wait();
    normalizer.Update("testing", 1.0);
    EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 1.0), kDefaultScore);
  }
}

// Tests normalizing scores in the leftmost and rightmost bins.
TEST_F(ScoreNormalizerTest, NormalizeEdgeBins) {
  ScoreNormalizer normalizer(GetProto(), TestingParams(5));
  Wait();

  // Bin dividers are: -infinity, 2, 3, 4, 5
  for (const auto& score : {1, 2, 3, 4, 0, 1, 2, 3, 4, 5})
    normalizer.Update("testing", score);

  // Very close to the leftmost bin's right boundary.
  EXPECT_NEAR(normalizer.Normalize("testing", 2), 0.2 - 1.0e-5, 1.0e-3);
  // Partway into the leftmost bin, offset should be 2/3.
  EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 1.5), 2.0 / 15.0);
  // Far out into the leftmost bin, the score should tend to 0.
  EXPECT_NEAR(normalizer.Normalize("testing", -10000), 0.0, 1.0e-3);

  // Exactly on the rightmost bin's left boundary.
  EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 5), 0.8);
  // Partway into the rightmost bin, offset should be 2/3.
  EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 5.5), 13.0 / 15.0);
  // Far out into the rightmost bin, the score should tend to 1.
  EXPECT_NEAR(normalizer.Normalize("testing", 10000), 1.0, 1.0e-3);
}

// Tests normalizing scores in the non-leftmost and non-rightmost bins.
TEST_F(ScoreNormalizerTest, NormalizeMiddleBins) {
  ScoreNormalizer normalizer(GetProto(), TestingParams(5));
  Wait();

  // Bin dividers are: -infinity, 2, 3, 4, 5
  for (const auto& score : {1, 2, 3, 4, 0, 1, 2, 3, 4, 5})
    normalizer.Update("testing", score);

  // Halfway through the second bin:
  // - bin index is 1
  // - offset is 0.5
  // So score should be (1 + 0.5) / 5
  EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 2.5), 0.3);

  // Partway through the fourth bin:
  // - bin index is 3
  // - offset is 1/3
  // So score should be (3 + 1/3) / 5
  EXPECT_FLOAT_EQ(normalizer.Normalize("testing", 4.0 + 1.0 / 3.0), 2.0 / 3.0);
}

}  // namespace app_list::test