Repository: epinowcast/baselinenowcast
Pull request #155: Issue #145: Fix bug in apply_delay() for structure = 2 ragged triangles
Author: login: seabbs
Created: 2025-05-19T16:09:36Z | Merged: 2025-05-20T08:54:32Z

This PR closes #145.

### Summary  
This PR fixes a bug in `apply_delay()` that affected handling of ragged reporting triangles with `structure = 2`. The bug caused incorrect nowcasting when the input triangle was not symmetric, due to incorrect updating of delay probabilities and cumulative probabilities within `.calc_expectation()`.

### Details  
- The main issue was that `.calc_expectation()` would update the column index if no NAs were found, but would not update the corresponding delay probability and CDF, leading to incorrect calculations for the first NA column and propagating errors.  
- Following KJ's suggestion in #145, the function now simply returns the unchanged expectation if there are no NAs in the current column, ensuring correct alignment of delay probabilities and CDFs.  
- Additional tests ensure correct behaviour for ragged triangles, especially for `structure = 2`.  
- All function signatures and documentation have been updated for clarity and consistency.

### Other Notes  
- The change in `test-combine_obs_with_pred.R` was a rename for clarity and does not affect functionality.  
- The main focus of this PR is the fix for `apply_delay()` and its correct handling of ragged triangles, as described in Issue #145.  
- **This PR was generated automatically with Cursor. Please review carefully before merging.**


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **Bug Fixes**
	- Improved handling of ragged reporting triangles in the delay application, ensuring accurate results for additional triangle structures.
- **Refactor**
	- Streamlined parameter naming and logic in delay expectation calculations for greater clarity and consistency.
- **Documentation**
	- Updated function documentation to reflect new parameter names and improved descriptions.
- **Tests**
	- Enhanced tests for delay application and expectation calculation, increasing coverage and verification rigor.
- **Chores**
	- Updated ignore list to exclude the `.claude/` directory.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Linked issue #145: Structure 2 handling in apply_delay
**Describe the bug**
As written, `apply_delay()` does not work as expected for a ragged triangle in the last row when the input is uniform structure 2 reporting.

Modify the test in `apply_delay()`
```r
delay_pmf <- c(0.1, 0.2, 0.3, 0.1, 0.1, 0.1, 0.1)
  partial_counts <- c(80, 100, 180, 80, 140)

  # Create a complete triangle based on the known delay PMF
  triangles <- lapply(partial_counts, function(x) x * delay_pmf)
  complete_triangle <- do.call(rbind, triangles)
  ragged_triangle <- generate_triangle(complete_triangle, structure = 2)

  result <- apply_delay(
    rep_tri_to_nowcast = ragged_triangle,
    delay_pmf = delay_pmf
  )
  cols <- colSums(result[3:5, ])
  pmf <- cols / sum(cols)
 pmf
#> [1] 0.06143989 0.12287978 0.25159634 0.23731157 0.10780140 0.10775752 0.11121351

expect_equal(pmf, delay_pmf, tolerance = 0.01) # this fails
#> Error: `pmf` not equal to `delay_pmf`.
#> 5/7 mismatches (average diff: 0.0625)
#> [1] 0.0614 - 0.1 == -0.0386
#> [2] 0.1229 - 0.2 == -0.0771
#> [3] 0.2516 - 0.3 == -0.0484
#> [4] 0.2373 - 0.1 ==  0.1373
#> [7] 0.1112 - 0.1 ==  0.0112
```
**Expected behavior**
Should get back approximately the complete triangle

**Possible solution**
I think this has to do with hard-coding of column iterations that assumes symmetry. 


Linked issue comments: user: login: seabbs
created_at: 2025-05-16T09:22:08Z
body: > I think this has to do with hard-coding of column iterations that assumes symmetry.

I don't think this is the correct conclusion. `apply_delay` was modified in the changes to`get_delay_estimate` with the intension to support ragged triangles by looking for an entries in columns and filling all rows. It sounds like there may be a bug there can you elaborate what it is?

For the future using a true `reprex` might help as it shows output.

author_association: COLLABORATOR
user: login: seabbs
created_at: 2025-05-16T10:10:23Z
body: i.e this (from tests on main) appears to work so its something a bit second order. 

```r
test_that("apply_delay works with ragged reporting triangles", {
  delay_pmf <- c(0.4, 0.3, 0.2, 0.05, 0.05)
  partial_counts <- c(80, 100, 180, 80, 140)

  # Create a complete triangle based on the known delay PMF
  triangle <- lapply(partial_counts, function(x) x * delay_pmf)
  triangle <- do.call(rbind, triangle)
  triangle <- generate_triangle(triangle, structure = c(1, 2, 1))

  result <- apply_delay(
    rep_tri_to_nowcast = triangle,
    delay_pmf = delay_pmf
  )
  cols <- colSums(result[3:5, ])
  pmf <- cols / sum(cols)
  expect_equal(pmf, delay_pmf, tolerance = 0.01)
})
```
author_association: COLLABORATOR
user: login: kaitejohnson
created_at: 2025-05-16T14:37:39Z
body: > For the future using a true `reprex` might help as it shows output.

What about this is not a true `reprex`?  

In #145 I added a test with this `structure=2` (which failed) but kept the old one (which passes). 


author_association: COLLABORATOR
user: login: seabbs
created_at: 2025-05-16T14:43:47Z
body: I mean using https://github.com/tidyverse/reprex so it can be read i.e the output more easily.
author_association: COLLABORATOR
user: login: kaitejohnson
created_at: 2025-05-19T13:00:07Z
body: Ok so I think the issue here is that when `.calc_expectation()` updates `col_index` as it identifies that the column corresponding to the passed in delay does not contain any NAs, it doesn't correspondingly update the `delay_prob` and the `delay_cdf_prev` since these are passed in as scalars. 

Instead, I think what we will want to happen is for this function to "do nothing" when `length(na_rows) == 0` and just return the expectation. Then, in the next delay column, it will find the corresponding NA rows in that column, and since the previous row didn't need to be filled in, it will work as expected. 

My proposed solution is simply to get rid of this inner loop: 
https://github.com/epinowcast/baselinenowcast/blob/cc3fec2b119466a6f383c685285aad2c149423be/R/apply_delay.R#L95

If this sounds okay with you @seabbs I can go ahead and implement, adding more tests to make sure this continues to act as expected. 
author_association: COLLABORATOR
user: login: seabbs
created_at: 2025-05-19T14:47:33Z
body: Why does it work in the structure 1, 2, 1 example? From the sounds of it what you are describing seems like intended behaviour? It should only update delay_prob and friends when it finds an NA because until it does so it hasn't done anything. 

I am not clear this has identified the bug.

author_association: COLLABORATOR
user: login: kaitejohnson
created_at: 2025-05-19T15:08:51Z
body: > It should only update delay_prob and friends when it finds an NA because until it does so it hasn't done anything.

Yes but as written, in a particular delay index, it will update the column index if it doesn't find an NA in the delay index column, so `col_index = delay_index + 1`. 

Then it will return the expectation, updated for `delay_index + 1`, but using `delay_pmf[delay_index]` and `delay_cdf[delay_index-1]` instead of what it should be using which is `delay_pmf[col_index]` and `delay_cdf[col_index-1]`.

It would be better behavior to just do nothing if there are no NAs in that delay index column, and move onto the next in the next iteration. 

As written, it uses a cdf that is one or more indexes lower than it should be for the first NA column, so it overestimates the expectation in that NA column and this just continues to propagate. 

This doesn't happen in structure = 1, 2, 1 because the first column index in the iterator does contain NA, so it doesn't ever enter this loop where `col_index` updates to `col_index + 1` 

E.g.: structure = 1, 2, 1 looks like this:
```r
     [,1] [,2] [,3] [,4] [,5]
[1,]   32   24   16    4    4
[2,]   40   30   20    5    5
[3,]   72   54   36    9   NA
[4,]   32   24   16   NA   NA
[5,]   56   NA   NA   NA   NA
```
whereas structure = 2 looks like:
```r
   [,1] [,2] [,3] [,4] [,5]
[1,]   32   24   16    4    4
[2,]   40   30   20    5    5
[3,]   72   54   36    9    9
[4,]   32   24   16    4   NA
[5,]   56   42   NA   NA   NA
```

author_association: COLLABORATOR
user: login: seabbs
created_at: 2025-05-19T16:12:26Z
body: Thank you this is a great outline. This was an understanding bug from me vs a code bug in the sense that for some reason I forgot that completed columns used up a delay index in the same way that non-completed ones did.  I think the catch would have been this test you have suggested though I didn't realise at the time that the one we had didn't cover this issue properly. Not sure what would have caught that but perhaps a bit more checking of the test.

For interest I fed your new test and this outline into a Agent AI that could run the tests and let it iterate until it had something working. It was a bit of a mess so I then manually intervened to make the changes more minimal hence #155 
author_association: COLLABORATOR

Diff:
diff --git a/.gitignore b/.gitignore
index af9c661e..a290d8e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,4 +21,5 @@ cache
 *.pdf
 *.html
 CRAN-SUBMISSION
-claude.md
\ No newline at end of file
+claude.md
+.claude/
\ No newline at end of file
diff --git a/R/apply_delay.R b/R/apply_delay.R
index bfcd0184..471daf14 100644
--- a/R/apply_delay.R
+++ b/R/apply_delay.R
@@ -57,12 +57,12 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
   # Iterates through each column (delay) and adds entries to the reporting
   # matrix to nowcast
   point_nowcast_matrix <- Reduce(
-    function(acc, delay_index) {
+    function(acc, index) {
       return(.calc_expectation(
-        delay_index,
+        index,
         acc,
-        delay_pmf[delay_index],
-        delay_cdf[delay_index - 1],
+        delay_pmf[index],
+        delay_cdf[index - 1],
         n_rows
       ))
     },
@@ -74,31 +74,25 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
 
 #' Calculate the updated rows of the expected nowcasted triangle
 #'
-#' @param delay_index Integer indicating the delay index
+#' @param index Integer indicating the delay index
 #' @param expectation Matrix of the incomplete reporting matrix
-#' @param delay_prob Probability of a case being reported with the current delay
-#' @param delay_cdf_prev CDF of the delay PMF up to the previous delay
+#' @param delay_prob Scalar probability for the current delay
+#' @param delay_cdf_prev Scalar cumulative probability up to previous delay
 #' @param n_rows Number of rows in the expectation matrix
 #' @returns Matrix with another set of entries corresponding to the updated
 #'   values for the specified rows and column
 #' @keywords internal
 .calc_expectation <- function(
-    delay_index,
+    index,
     expectation,
     delay_prob,
     delay_cdf_prev,
     n_rows) {
   # Find rows with NA in this column that need to be filled
-  col_index <- delay_index
-  na_rows <- .where_is_na_in_col(expectation, col_index)
+  na_rows <- .where_is_na_in_col(expectation, index)
 
-  while (length(na_rows) == 0) {
-    col_index <- col_index + 1
-    # Stop if we've reached the end of the matrix
-    if (col_index > ncol(expectation)) {
-      return(expectation)
-    }
-    na_rows <- .where_is_na_in_col(expectation, col_index)
+  if (length(na_rows) == 0) {
+    return(expectation)
   }
 
   # Start with the first row that has NA
@@ -107,7 +101,7 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
   # Extract the left block for these rows
   block_bottom_left <- .extract_block_bottom_left(
     expectation,
-    col_index,
+    index,
     n_rows,
     row_start
   )
@@ -119,7 +113,7 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
   exp_N <- .calc_modified_expectation(x, delay_cdf_prev)
 
   # Update only the NA rows in the column
-  expectation[row_start:n_rows, col_index] <- exp_N * delay_prob
+  expectation[row_start:n_rows, index] <- exp_N * delay_prob
 
   return(expectation)
 }
diff --git a/man/dot-calc_expectation.Rd b/man/dot-calc_expectation.Rd
index ebf944ec..d9479ea0 100644
--- a/man/dot-calc_expectation.Rd
+++ b/man/dot-calc_expectation.Rd
@@ -4,16 +4,16 @@
 \alias{.calc_expectation}
 \title{Calculate the updated rows of the expected nowcasted triangle}
 \usage{
-.calc_expectation(delay_index, expectation, delay_prob, delay_cdf_prev, n_rows)
+.calc_expectation(index, expectation, delay_prob, delay_cdf_prev, n_rows)
 }
 \arguments{
-\item{delay_index}{Integer indicating the delay index}
+\item{index}{Integer indicating the delay index}
 
 \item{expectation}{Matrix of the incomplete reporting matrix}
 
-\item{delay_prob}{Probability of a case being reported with the current delay}
+\item{delay_prob}{Scalar probability for the current delay}
 
-\item{delay_cdf_prev}{CDF of the delay PMF up to the previous delay}
+\item{delay_cdf_prev}{Scalar cumulative probability up to previous delay}
 
 \item{n_rows}{Number of rows in the expectation matrix}
 }
diff --git a/tests/testthat/test-apply_delay.R b/tests/testthat/test-apply_delay.R
index 8592d19b..ed75226e 100644
--- a/tests/testthat/test-apply_delay.R
+++ b/tests/testthat/test-apply_delay.R
@@ -222,3 +222,21 @@ test_that("apply_delay works with ragged reporting triangles", {
   pmf <- cols / sum(cols)
   expect_equal(pmf, delay_pmf, tolerance = 0.01)
 })
+
+test_that("apply_delay works with structure=2 ragged reporting triangles", {
+  delay_pmf <- c(0.1, 0.2, 0.3, 0.1, 0.1, 0.1, 0.1)
+  partial_counts <- c(80, 100, 180, 80, 140)
+
+  # Create a complete triangle based on the known delay PMF
+  triangles <- lapply(partial_counts, function(x) x * delay_pmf)
+  complete_triangle <- do.call(rbind, triangles)
+  ragged_triangle <- generate_triangle(complete_triangle, structure = 2)
+
+  result <- apply_delay(
+    rep_tri_to_nowcast = ragged_triangle,
+    delay_pmf = delay_pmf
+  )
+  cols <- colSums(result[3:5, ])
+  pmf <- cols / sum(cols)
+  expect_equal(pmf, delay_pmf, tolerance = 0.01)
+})
diff --git a/tests/testthat/test-calc_expectation.R b/tests/testthat/test-calc_expectation.R
index ff98958d..2eb83078 100644
--- a/tests/testthat/test-calc_expectation.R
+++ b/tests/testthat/test-calc_expectation.R
@@ -4,14 +4,14 @@ n_delays <- 5
 full_expectation <- matrix(1:50, nrow = n_dates, ncol = n_delays)
 expectation <- generate_triangle(full_expectation)
 delay_pmf <- c(0.5, 0.3, 0.1, 0.07, 0.03)
-delay_cdf_prev <- sum(delay_pmf[1])
+delay_cdf <- cumsum(delay_pmf)
 
 test_that(".calc_expectation has correct basic functionality", {
   result <- .calc_expectation(
-    delay_index = 3,
+    index = 3,
     expectation = expectation,
     delay_prob = delay_pmf[3],
-    delay_cdf_prev = delay_cdf_prev,
+    delay_cdf_prev = delay_cdf[2],
     n_rows = n_dates
   )
 
@@ -21,32 +21,45 @@ test_that(".calc_expectation has correct basic functionality", {
 test_that(".calc_expectation modifies only the correct cells", {
   original <- expectation
   modified <- .calc_expectation(
-    delay_index = 2,
+    index = 2,
     expectation = expectation,
     delay_prob = delay_pmf[2],
-    delay_cdf_prev = delay_cdf_prev,
+    delay_cdf_prev = delay_cdf[1],
     n_rows = n_dates
   )
 
   expect_equal(modified[1:9, ], original[1:9, ], tolerance = 1e-6)
-  expect_identical(modified[10, 2], 6.3)
+  # Check that only the expected cell (row 10, column 2) has been modified
+  expect_false(identical(modified[10, 2], original[10, 2]))
+
+  # Check all other cells remain unchanged
+  for (i in 1:10) {
+    for (j in 1:5) {
+      if (!(i == 10 && j == 2)) {
+        expect_identical(modified[i, j], original[i, j])
+      }
+    }
+  }
+
+  # Verify the specific value that was changed
+  expect_equal(modified[10, 2], 6.3, tolerance = 1e-6)
 })
 
 test_that(".calc_expectation calculates correct values", {
   modified_step1 <- .calc_expectation(
-    delay_index = 2,
+    index = 2,
     expectation = expectation,
     delay_prob = delay_pmf[2],
-    delay_cdf_prev = delay_cdf_prev,
+    delay_cdf_prev = delay_cdf[1],
     n_rows = n_dates
   )
 
   # Then apply for delay_index 3
   modified_step2 <- .calc_expectation(
-    delay_index = 3,
+    index = 3,
     expectation = modified_step1,
     delay_prob = delay_pmf[3],
-    delay_cdf_prev = sum(delay_pmf[1:2]),
+    delay_cdf_prev = delay_cdf[2],
     n_rows = n_dates
   )
 
@@ -54,7 +67,7 @@ test_that(".calc_expectation calculates correct values", {
   block_bottom_left <- modified_step1[9:10, 1:2]
   exp_total <- .calc_modified_expectation(
     x = rowSums(block_bottom_left),
-    delay_cdf_prev = sum(delay_pmf[1:2])
+    delay_cdf_prev = delay_cdf[2]
   )
   expected_values <- exp_total * delay_pmf[3]
 
diff --git a/tests/testthat/combine_obs_with_pred.R b/tests/testthat/test-combine_obs_with_pred.R
similarity index 100%
rename from tests/testthat/combine_obs_with_pred.R
rename to tests/testthat/test-combine_obs_with_pred.R


Reviews:
user: login: kaitejohnson
body: @seabbs I don't feel super strongly about this but I don't think its necessary to change the input arguments to `.calc_expectation()` to now take vectors. 

I just opened #146 and pushed to it to save my work as I had to try things to understand what was going on here. I can either edit that or revert the change in args made here, or we can go with this as it works fine.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T16:32:51Z
user: login: seabbs
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T17:06:32Z
user: login: kaitejohnson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T18:20:55Z
user: login: kaitejohnson
body: LGTM, just the one linting issue in `apply_delay()` that is already flagged
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T07:39:54Z
user: login: kaitejohnson
body: LGTM!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T08:49:58Z

Review comments:
diff_hunk: @@ -74,40 +74,37 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
 
 #' Calculate the updated rows of the expected nowcasted triangle
 #'
-#' @param delay_index Integer indicating the delay index
+#' @param index Integer indicating the delay index
 #' @param expectation Matrix of the incomplete reporting matrix
-#' @param delay_prob Probability of a case being reported with the current delay
-#' @param delay_cdf_prev CDF of the delay PMF up to the previous delay
+#' @param delay_pmf Vector of delays assumed to be indexed starting at the
+#'   first delay column in `rep_mat_to_nowcast`
+#' @param delay_cdf Vector of cumulative probabilities of the delay PMF
 #' @param n_rows Number of rows in the expectation matrix
 #' @returns Matrix with another set of entries corresponding to the updated
 #'   values for the specified rows and column
 #' @keywords internal
 .calc_expectation <- function(
-    delay_index,
+    index,
     expectation,
-    delay_prob,
-    delay_cdf_prev,
+    delay_pmf,
path: R/apply_delay.R
user: login: kaitejohnson
body: I think this sort of "copied" what I was experimenting with in #146, and I don't thin the changes it made to ingest the entire cdf and pmf are necessary... 

Since it always will return the expectation updated (or not updated) for a single index, we can continue to pass in a scalar value, `delay_prob` and `delay_cdf_prev` rather than doing that within the `.calc_expectation` function 

It doesn't matter a ton, but it maintains more of the previous functionality and spirit of the loop. 
created_at: 2025-05-19T16:29:42Z
author_association: COLLABORATOR
diff_hunk: @@ -74,40 +74,37 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
 
 #' Calculate the updated rows of the expected nowcasted triangle
 #'
-#' @param delay_index Integer indicating the delay index
+#' @param index Integer indicating the delay index
 #' @param expectation Matrix of the incomplete reporting matrix
-#' @param delay_prob Probability of a case being reported with the current delay
-#' @param delay_cdf_prev CDF of the delay PMF up to the previous delay
+#' @param delay_pmf Vector of delays assumed to be indexed starting at the
+#'   first delay column in `rep_mat_to_nowcast`
+#' @param delay_cdf Vector of cumulative probabilities of the delay PMF
 #' @param n_rows Number of rows in the expectation matrix
 #' @returns Matrix with another set of entries corresponding to the updated
 #'   values for the specified rows and column
 #' @keywords internal
 .calc_expectation <- function(
-    delay_index,
+    index,
     expectation,
-    delay_prob,
-    delay_cdf_prev,
+    delay_pmf,
path: R/apply_delay.R
user: login: seabbs
body: yup good point. This is from an interim fix where it modified the while loop but keep it . Droppigh 
created_at: 2025-05-19T17:06:32Z
author_association: COLLABORATOR
diff_hunk: @@ -74,40 +74,37 @@ apply_delay <- function(rep_tri_to_nowcast, delay_pmf) {
 
 #' Calculate the updated rows of the expected nowcasted triangle
 #'
-#' @param delay_index Integer indicating the delay index
+#' @param index Integer indicating the delay index
 #' @param expectation Matrix of the incomplete reporting matrix
-#' @param delay_prob Probability of a case being reported with the current delay
-#' @param delay_cdf_prev CDF of the delay PMF up to the previous delay
+#' @param delay_pmf Vector of delays assumed to be indexed starting at the
+#'   first delay column in `rep_mat_to_nowcast`
+#' @param delay_cdf Vector of cumulative probabilities of the delay PMF
 #' @param n_rows Number of rows in the expectation matrix
 #' @returns Matrix with another set of entries corresponding to the updated
 #'   values for the specified rows and column
 #' @keywords internal
 .calc_expectation <- function(
-    delay_index,
+    index,
     expectation,
-    delay_prob,
-    delay_cdf_prev,
+    delay_pmf,
path: R/apply_delay.R
user: login: kaitejohnson
body: It did the same thing as I did 
created_at: 2025-05-19T18:20:55Z
author_association: COLLABORATOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-19T16:09:42Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

"""
## Walkthrough

The changes update the `.gitignore` to exclude the `.claude/` directory, refactor the `apply_delay` function and its helper `.calc_expectation` for clearer parameter naming and streamlined logic, enhance related documentation, and improve and extend tests, particularly for handling structure 2 ragged triangles in `apply_delay`.

## Changes

| File(s)                                           | Summary                                                                                                      |
|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
| .gitignore                                        | Added `.claude/` to ignore list.                                                                             |
| R/apply_delay.R                                   | Refactored parameter names (`delay_index` → `index`), simplified NA row detection, unified delay PMF/CDF usage. |
| man/dot-calc_expectation.Rd                       | Updated argument names and descriptions in `.calc_expectation` documentation for clarity and consistency.      |
| tests/testthat/test-apply_delay.R                 | Added test for `apply_delay` with structure 2 ragged triangles, verifying output matches expected delay PMF.   |
| tests/testthat/test-calc_expectation.R            | Updated tests to use full delay PMF/CDF vectors, improved assertions for matrix modification accuracy.         |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant TestSuite
    participant apply_delay
    participant .calc_expectation

    TestSuite->>apply_delay: Call with ragged triangle (structure=2), delay_pmf
    loop For each delay column
        apply_delay->>.calc_expectation: Pass index, delay_pmf, delay_cdf, n_rows
        .calc_expectation-->>apply_delay: Return updated expectation matrix
    end
    apply_delay-->>TestSuite: Return result
```

## Assessment against linked issues

| Objective                                                                                         | Addressed | Explanation                                                         |
|---------------------------------------------------------------------------------------------------|-----------|---------------------------------------------------------------------|
| Ensure `apply_delay()` works as expected for ragged triangle with structure 2 (#145)              | ✅        |                                                                     |
| Add/modify test to confirm output matches delay PMF for structure 2 ragged triangle (#145)        | ✅        |                                                                     |
| Refactor code to avoid hard-coding column iterations and handle asymmetry (#145)                  | ✅        |                                                                     |

## Poem

> A hop, a skip, a code refactor,  
> Delay triangles now flow much faster!  
> With structure two, no bugs remain,  
> Tests and docs both entertain.  
> In fields of code, this bunny cheers,  
> For tidy names and passing tests—three happy ears! 🐰  
"""

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ea3e82718b46fa15cfedeba7dc5df76b6ea1d1bd and 251b358b876bd8788c2cda853002a6e1a4ff4318.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `R/apply_delay.R` (4 hunks)
* `man/dot-calc_expectation.Rd` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* man/dot-calc_expectation.Rd
* R/apply_delay.R

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (7)</summary>

* GitHub Check: windows-latest (release)
* GitHub Check: macOS-latest (release)
* GitHub Check: ubuntu-latest (oldrel-1)
* GitHub Check: ubuntu-latest (release)
* GitHub Check: pkgdown
* GitHub Check: test-coverage
* GitHub Check: lint-changed-files

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=epinowcast/baselinenowcast&utm_content=155):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T16:12:05Z
body: <!-- action-comment-id:website -->
Thank you for your contribution seabbs :rocket:! Your website is ready for download :point_right: [here]( https://github.com/epinowcast/baselinenowcast/actions/runs/15133004925/artifacts/3158149823) :point_left:!
<sub>(The artifact expires on 2025-05-25T08:46:57Z. You can re-generate it by re-running the workflow [here](https://github.com/epinowcast/baselinenowcast/actions/runs/15133004925).)</sub>
author_association: CONTRIBUTOR
user: login: seabbs
created_at: 2025-05-19T17:20:45Z
body: Good catch. Reverted
author_association: COLLABORATOR