[SCReAM] Self-contain Application-Limited detection via fixed-window acked rate

Previously, SCReAM v2 did not autonomously monitor the actual received (acked) bitrate on the channel to evaluate if a session was truly Application-Limited.

With this CL, ScreamV2 tracks the received (acked) transfer rate over a fixed 100ms window and evaluates is_application_limited internally by checking if the received rate is less than 0.9 times the target rate. When periodic padding or probing bursts are transmitted, the receive rate scales up close to the target rate and automatically unfreezes the reference window so BWE can adapt upwards to full link capacity. In addition, leaving ALR when the receive rate is close to the target rate ensures that RTT and feedback hold time estimates are updated faster.

In addition, ReceivedRateWindow and AlrThreshold parameters have been exposed via field trials in ScreamV2Parameters to allow runtime configuration.

Bug: webrtc:447037083
Change-Id: I1ba1a6f098944e79431e46a8547149f2b084c610
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/482680
Commit-Queue: Per Kjellander <perkj@webrtc.org>
Reviewed-by: Björn Terelius <terelius@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#48034}
diff --git a/modules/congestion_controller/scream/scream_feedback.cc b/modules/congestion_controller/scream/scream_feedback.cc
index bcf9e67..9e04d9a 100644
--- a/modules/congestion_controller/scream/scream_feedback.cc
+++ b/modules/congestion_controller/scream/scream_feedback.cc
@@ -48,6 +48,7 @@
 
     // Update received-packet metrics.
     if (packet.IsReceived()) {
+      parsed.received += packet.sent_packet.size;
       parsed.num_received_packets++;
       if (packet.ecn == EcnMarking::kCe) {
         parsed.num_ce_marked_packets++;
diff --git a/modules/congestion_controller/scream/scream_feedback.h b/modules/congestion_controller/scream/scream_feedback.h
index 7060750..ee000d5 100644
--- a/modules/congestion_controller/scream/scream_feedback.h
+++ b/modules/congestion_controller/scream/scream_feedback.h
@@ -29,6 +29,7 @@
   int num_ce_marked_packets = 0;
   int num_lost_packets = 0;
   int num_recovered_packets = 0;
+  DataSize received = DataSize::Zero();
 
   // Sum of the sizes of all packets in the feedback that are NOT ECN CE-marked.
   // This explicitly includes packets reported as lost (since lost packets do
diff --git a/modules/congestion_controller/scream/scream_network_controller_unittest.cc b/modules/congestion_controller/scream/scream_network_controller_unittest.cc
index c9bee8e..0461527 100644
--- a/modules/congestion_controller/scream/scream_network_controller_unittest.cc
+++ b/modules/congestion_controller/scream/scream_network_controller_unittest.cc
@@ -10,6 +10,7 @@
 
 #include "modules/congestion_controller/scream/scream_network_controller.h"
 
+#include <algorithm>
 #include <utility>
 
 #include "api/environment/environment.h"
@@ -793,5 +794,101 @@
   EXPECT_TRUE(alr_detected);
 }
 
+TEST(ScreamControllerTest, AlrRecoversDuringPeriodicPadding) {
+  SimulatedClock clock(Timestamp::Zero());
+  // Set periodic padding and padding duration to 1s so it aligns exactly with
+  // warmup and activates repeatedly in the final loop.
+  Environment env = CreateTestEnvironment({.time = &clock});
+  CcFeedbackGenerator feedback_generator(CcFeedbackGenerator::Config{
+      .network_config = {.queue_delay_ms = 50,
+                         .link_capacity = DataRate::KilobitsPerSec(2000)}});
+
+  NetworkControllerConfig controller_config(env);
+  ScreamNetworkController scream_controller(controller_config);
+
+  StreamsConfig streams_config;
+  streams_config.max_total_allocated_bitrate = DataRate::KilobitsPerSec(10'000);
+  scream_controller.OnStreamsConfig(streams_config);
+
+  TargetRateConstraints rate_constraints;
+  rate_constraints.max_data_rate = DataRate::KilobitsPerSec(10'000);
+  rate_constraints.starting_rate = DataRate::KilobitsPerSec(300);
+  scream_controller.OnTargetRateConstraints(rate_constraints);
+
+  NetworkControlUpdate update = scream_controller.OnNetworkAvailability(
+      {.at_time = clock.CurrentTime(), .network_available = true});
+  DataRate target_rate = DataRate::KilobitsPerSec(300);
+
+  // Warmup loop: allow BWE to adapt up to the 2 Mbps link capacity.
+  Timestamp warmup_start = clock.CurrentTime();
+  while (clock.CurrentTime() - warmup_start < TimeDelta::Seconds(10)) {
+    TransportPacketsFeedback feedback =
+        feedback_generator.ProcessUntilNextFeedback(
+            target_rate, clock, [&](const SentPacket& packet) {
+              scream_controller.OnSentPacket(packet);
+            });
+    update = scream_controller.OnTransportPacketsFeedback(feedback);
+    if (update.target_rate.has_value()) {
+      target_rate = update.target_rate->target_rate;
+    }
+  }
+  EXPECT_GE(target_rate, DataRate::KilobitsPerSec(1800));
+  EXPECT_LE(target_rate, DataRate::KilobitsPerSec(2100));
+
+  // ALR entry: ensure we enter ALR by sending below capacity.
+  bool alr_detected = false;
+  Timestamp alr_start = clock.CurrentTime();
+  while (clock.CurrentTime() - alr_start < TimeDelta::Seconds(1)) {
+    TransportPacketsFeedback feedback =
+        feedback_generator.ProcessUntilNextFeedback(
+            DataRate::KilobitsPerSec(100), clock,
+            [&](const SentPacket& packet) {
+              scream_controller.OnSentPacket(packet);
+            });
+    update = scream_controller.OnTransportPacketsFeedback(feedback);
+    if (update.target_rate.has_value()) {
+      target_rate = update.target_rate->target_rate;
+      if (!update.target_rate->is_bandwidth_limited) {
+        alr_detected = true;
+        break;
+      }
+    }
+  }
+  EXPECT_TRUE(alr_detected);
+
+  // Recovery loop: create a new channel with higher capacity (10 Mbps) and
+  // lower delay (5ms). Even though the application only sends at `target_rate`
+  // (which was frozen by ALR), active periodic padding automatically unblocks
+  // ALR, allowing BWE to adapt upwards close to the new 10 Mbps link capacity.
+  CcFeedbackGenerator high_cap_feedback_generator(CcFeedbackGenerator::Config{
+      .network_config = {.queue_delay_ms = 5,
+                         .link_capacity = DataRate::KilobitsPerSec(10'000)}});
+
+  bool alr_recovered = false;
+  Timestamp recovery_start = clock.CurrentTime();
+  DataRate padding_rate = DataRate::Zero();
+  while (clock.CurrentTime() - recovery_start < TimeDelta::Seconds(5)) {
+    TransportPacketsFeedback feedback =
+        high_cap_feedback_generator.ProcessUntilNextFeedback(
+            std::max(padding_rate, DataRate::KilobitsPerSec(100)), clock,
+            [&](const SentPacket& packet) {
+              scream_controller.OnSentPacket(packet);
+            });
+    update = scream_controller.OnTransportPacketsFeedback(feedback);
+    if (update.target_rate.has_value()) {
+      target_rate = update.target_rate->target_rate;
+      if (update.target_rate->is_bandwidth_limited) {
+        alr_recovered = true;
+      }
+    }
+    if (update.pacer_config.has_value()) {
+      padding_rate = update.pacer_config->pad_rate();
+    }
+  }
+
+  EXPECT_TRUE(alr_recovered);
+  EXPECT_GE(target_rate, DataRate::KilobitsPerSec(9800));
+}
+
 }  // namespace
 }  // namespace webrtc
diff --git a/modules/congestion_controller/scream/scream_v2.cc b/modules/congestion_controller/scream/scream_v2.cc
index 46bb19d..133d2f8 100644
--- a/modules/congestion_controller/scream/scream_v2.cc
+++ b/modules/congestion_controller/scream/scream_v2.cc
@@ -64,9 +64,12 @@
   }
   max_data_in_flight_this_rtt_ =
       std::max(max_data_in_flight_this_rtt_, feedback.data_in_flight);
+  UpdateReceiveRate(feedback);
 
   if (params_.enable_alr.Get()) {
-    is_application_limited_ = max_allowed_ref_window() < ref_window_;
+    is_application_limited_ =
+        max_allowed_ref_window() < ref_window_ &&
+        received_rate_ < params_.alr_threshold.Get() * target_rate_;
   }
 
   delay_based_congestion_control_.Update(feedback, is_application_limited_);
@@ -364,4 +367,24 @@
   target_rate_ = target_rate;
 }
 
+void ScreamV2::UpdateReceiveRate(const ScreamFeedback& feedback) {
+  accumulated_received_bytes_ += feedback.received;
+
+  if (last_received_rate_update_time_.IsInfinite()) {
+    // At the first feedback, set the received rate to infinite to ensure ALR
+    // can not be entered until a valid receive rate estimate exists.
+    last_received_rate_update_time_ = feedback.feedback_time;
+    received_rate_ = DataRate::PlusInfinity();
+    accumulated_received_bytes_ = DataSize::Zero();
+  }
+  if (feedback.feedback_time - last_received_rate_update_time_ >=
+      params_.received_rate_window.Get()) {
+    TimeDelta duration =
+        feedback.feedback_time - last_received_rate_update_time_;
+    received_rate_ = accumulated_received_bytes_ / duration;
+    accumulated_received_bytes_ = DataSize::Zero();
+    last_received_rate_update_time_ = feedback.feedback_time;
+  }
+}
+
 }  // namespace webrtc
diff --git a/modules/congestion_controller/scream/scream_v2.h b/modules/congestion_controller/scream/scream_v2.h
index 163bc39..1aaf2bf 100644
--- a/modules/congestion_controller/scream/scream_v2.h
+++ b/modules/congestion_controller/scream/scream_v2.h
@@ -67,6 +67,8 @@
   // the last RTT.
   DataSize max_allowed_ref_window() const;
 
+  DataRate received_rate() const { return received_rate_; }
+
   // Returns the average fraction of ECN-CE marked data units per RTT.
   double l4s_alpha() const { return l4s_alpha_; }
 
@@ -126,6 +128,7 @@
   void UpdateRefWindow(const ScreamFeedback& parsed);
   void UpdateFeedbackHoldTime(const ScreamFeedback& parsed);
   void UpdateTargetRate(const ScreamFeedback& parsed);
+  void UpdateReceiveRate(const ScreamFeedback& parsed);
 
   const Environment env_;
   const ScreamV2Parameters params_;
@@ -160,6 +163,9 @@
   Timestamp last_data_in_flight_update_ = Timestamp::MinusInfinity();
   DataSize max_data_in_flight_this_rtt_ = DataSize::Zero();
   DataSize max_data_in_flight_prev_rtt_ = DataSize::Zero();
+  DataRate received_rate_ = DataRate::Zero();
+  DataSize accumulated_received_bytes_ = DataSize::Zero();
+  Timestamp last_received_rate_update_time_ = Timestamp::MinusInfinity();
 
   // `last_reaction_to_congestion_time` is called
   // `last_congestion_detected_time` in 4.2.2. Reference Window Update.
diff --git a/modules/congestion_controller/scream/scream_v2_parameters.cc b/modules/congestion_controller/scream/scream_v2_parameters.cc
index e293905..d6368b8 100644
--- a/modules/congestion_controller/scream/scream_v2_parameters.cc
+++ b/modules/congestion_controller/scream/scream_v2_parameters.cc
@@ -66,7 +66,9 @@
       allow_large_pacing_bursts_after_congestion_time(
           "AllowLargePacingBurstsAfterCongestionTime",
           TimeDelta::Seconds(15)),
-      enable_alr("EnableAlr", true) {
+      enable_alr("EnableAlr", true),
+      alr_threshold("AlrThreshold", 0.9),
+      received_rate_window("ReceivedRateWindow", TimeDelta::Millis(100)) {
   ParseFieldTrial({&min_ref_window,
                    &l4s_avg_g_up,
                    &l4s_avg_g_down,
@@ -102,7 +104,9 @@
                    &pacing_factor,
                    &feedback_hold_time_avg_g,
                    &allow_large_pacing_bursts_after_congestion_time,
-                   &enable_alr},
+                   &enable_alr,
+                   &alr_threshold,
+                   &received_rate_window},
                   trials.Lookup("WebRTC-Bwe-ScreamV2"));
 }
 
diff --git a/modules/congestion_controller/scream/scream_v2_parameters.h b/modules/congestion_controller/scream/scream_v2_parameters.h
index a145af2..afe70cb 100644
--- a/modules/congestion_controller/scream/scream_v2_parameters.h
+++ b/modules/congestion_controller/scream/scream_v2_parameters.h
@@ -150,6 +150,13 @@
   // Enable application-limited (ALR) state tracking.
   // In ALR, reference window can not increase, and RTT is updated slower.
   FieldTrialParameter<bool> enable_alr;
+  // An application is deemed application-limited (ALR) if the reference window
+  // exceeds the maximum allowed based on data in flight, and the received rate
+  // is less than alr_threshold * target_rate.
+  FieldTrialParameter<double> alr_threshold;
+
+  // Window over which received rate is calculated.
+  FieldTrialParameter<TimeDelta> received_rate_window;
 };
 
 }  // namespace webrtc
diff --git a/modules/congestion_controller/scream/scream_v2_unittest.cc b/modules/congestion_controller/scream/scream_v2_unittest.cc
index 8a46304..034914d 100644
--- a/modules/congestion_controller/scream/scream_v2_unittest.cc
+++ b/modules/congestion_controller/scream/scream_v2_unittest.cc
@@ -609,5 +609,56 @@
   EXPECT_LT(scream.ref_window(), ref_window_before_loss);
 }
 
+TEST(ScreamV2Test, KeepsTrackOfReceivedRateOver100msWindow) {
+  SimulatedClock clock(Timestamp::Seconds(1'234));
+  Environment env = CreateTestEnvironment({.time = &clock});
+  ScreamV2 scream(env);
+
+  // Initialize constraints
+  scream.SetTargetBitrateConstraints(DataRate::Zero(),
+                                     DataRate::KilobitsPerSec(2000),
+                                     DataRate::KilobitsPerSec(300));
+
+  // First feedback
+  TransportPacketsFeedback feedback1 =
+      CreateFeedback(clock.CurrentTime(), /*rtt=*/TimeDelta::Millis(100),
+                     /*number_of_ect1_packets=*/5,
+                     /*number_of_packets_in_flight=*/20);
+  scream.OnTransportPacketsFeedback(feedback1);
+
+  // feedback1 has 5 packets of 1000 bytes each = 5000 bytes.
+  // At the first feedback, received_rate is initialized to PlusInfinity.
+  EXPECT_EQ(scream.received_rate(), DataRate::PlusInfinity());
+
+  // Advance clock by 50ms (less than 100ms) and send another feedback
+  clock.AdvanceTime(TimeDelta::Millis(50));
+  TransportPacketsFeedback feedback2 =
+      CreateFeedback(clock.CurrentTime(), /*rtt=*/TimeDelta::Millis(100),
+                     /*number_of_ect1_packets=*/3,
+                     /*number_of_packets_in_flight=*/20);
+  scream.OnTransportPacketsFeedback(feedback2);
+
+  // feedback2 has 3 packets of 1000 bytes each = 3000 bytes.
+  // No new calculation because only 50ms has passed.
+  // received_rate remains PlusInfinity.
+  EXPECT_EQ(scream.received_rate(), DataRate::PlusInfinity());
+
+  // Advance clock by another 51ms (so total 101ms has passed since the last
+  // update) and send third feedback.
+  clock.AdvanceTime(TimeDelta::Millis(51));
+  TransportPacketsFeedback feedback3 =
+      CreateFeedback(clock.CurrentTime(), /*rtt=*/TimeDelta::Millis(100),
+                     /*number_of_ect1_packets=*/2,
+                     /*number_of_packets_in_flight=*/20);
+  scream.OnTransportPacketsFeedback(feedback3);
+
+  // feedback3 has 2 packets of 1000 bytes each = 2000 bytes.
+  // Total accumulated bytes since last calculation: 3000 (feedback2) + 2000
+  // (feedback3) = 5000 bytes. Total time duration passed: 50ms + 51ms = 101ms.
+  // rate = 5000 bytes / 101ms.
+  EXPECT_EQ(scream.received_rate(),
+            DataSize::Bytes(5000) / TimeDelta::Millis(101));
+}
+
 }  // namespace
 }  // namespace webrtc
diff --git a/rtc_tools/rtc_event_log_visualizer/analyze_bwe.cc b/rtc_tools/rtc_event_log_visualizer/analyze_bwe.cc
index 1129bed..291433e 100644
--- a/rtc_tools/rtc_event_log_visualizer/analyze_bwe.cc
+++ b/rtc_tools/rtc_event_log_visualizer/analyze_bwe.cc
@@ -735,6 +735,7 @@
   TimeSeries target_rate_series("Target rate", LineStyle::kStep);
   TimeSeries pacing_rate_series("Pacing rate", LineStyle::kStep);
   TimeSeries send_rate_series("Send rate", LineStyle::kStep);
+  TimeSeries received_rate_series("Received rate", LineStyle::kStep);
   IntervalSeries app_limited_series("Application limited", "#5092fc",
                                     IntervalSeries::kHorizontal);
 
@@ -752,6 +753,10 @@
                                            state.pacing_rate.bps() / 1000);
     send_rate_series.points.emplace_back(config.GetCallTimeSec(state.time),
                                          state.send_rate.bps() / 1000);
+    if (state.received_rate.IsFinite()) {
+      received_rate_series.points.emplace_back(
+          config.GetCallTimeSec(state.time), state.received_rate.bps() / 1000);
+    }
     if (state.is_application_limited && !previously_app_limited) {
       app_limited_start_time = config.GetCallTimeSec(state.time);
       previously_app_limited = true;
@@ -770,6 +775,7 @@
   plot->AppendTimeSeries(std::move(target_rate_series));
   plot->AppendTimeSeries(std::move(pacing_rate_series));
   plot->AppendTimeSeries(std::move(send_rate_series));
+  plot->AppendTimeSeries(std::move(received_rate_series));
   plot->AppendIntervalSeries(std::move(app_limited_series));
 
   plot->SetXAxis(config.CallBeginTimeSec(), config.CallEndTimeSec(), "Time (s)",
diff --git a/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.cc b/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.cc
index d04c1da..e735648 100644
--- a/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.cc
+++ b/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.cc
@@ -205,6 +205,7 @@
       .pacing_rate = scream_->pacing_rate(),
       .send_rate =
           send_rate_tracker_.Rate(msg.feedback_time).value_or(DataRate::Zero()),
+      .received_rate = scream_->received_rate(),
       .ref_window = scream_->ref_window(),
       .ref_window_i = scream_->ref_window_i(),
       .max_allowed_ref_window = scream_->max_allowed_ref_window(),
diff --git a/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.h b/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.h
index 20cc844..7165a95 100644
--- a/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.h
+++ b/rtc_tools/rtc_event_log_visualizer/log_scream_simulation.h
@@ -45,6 +45,7 @@
     DataRate target_rate = DataRate::Zero();
     DataRate pacing_rate = DataRate::Zero();
     DataRate send_rate = DataRate::Zero();
+    DataRate received_rate = DataRate::Zero();
 
     DataSize ref_window = DataSize::Zero();
     DataSize ref_window_i = DataSize::Zero();