Merck / Merck/metalite.ae

[BUG] Rounding does not follow round-half-away-from-zero on decimal ties and displayed values show negative zeros

Open
#249 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
R
Stars
27
Forks
5
Avg merge
17h 4m
Merged PRs (30d)
4

Description

Package Version: v0.1.4
Pinned Commit: bdb23d472b16bc9dadbc774e64c5ca40321e9c6b


Description

In regulatory and clinical trial reporting (CSR tables/listings/figures), numerical formatting must adhere to standard data presentation rule BR-001:

  1. Decimal Ties Rounding: Values intended as exact decimal ties must round half away from zero (symmetric arithmetic rounding, where halfway values round towards $+\infty$ for positive numbers and towards $-\infty$ for negative numbers).
  2. Negative Zero Prevention: A displayed/formatted value must never be displayed as a negative zero (e.g., "-0.0", "(-0.0)", "-0.00", "(-0.00)").

An audit of metalite.ae at release v0.1.4 (commit bdb23d4) reveals two systematic divergences:

  • Formatting helpers in R/fmt.R and R/format_ae_exp_adj.R use base R's formatC(..., format = "f"), which delegates to standard C library rounding (IEEE 754 / IEC 60559 round-half-to-even / bankers' rounding) and produces negative zeroes for small negative floats.
  • Row-filtering logic in R/format_ae_specific.R (line 330) uses base R's round(), which also performs round-to-even, causing adverse events on boundary ties to be incorrectly filtered out or included.

Affected Files & Locations
  1. R/fmt.R:
    • fmt_pct() (lines 33, 37): Formats percentages as (x.x) using formatC(..., format = "f").
    • fmt_est() (lines 75, 79): Formats mean/difference estimates and standard errors using formatC(..., format = "f").
    • fmt_ci() (lines 99, 100): Formats confidence intervals using formatC(..., format = "f").
    • fmt_pval() (line 122): Formats p-values using formatC(..., format = "f").
  2. R/format_ae_specific.R:
    • Line 330: filter_index <- round(outdata$prop[, index_total], digits_prop) performs round-to-even before applying filter_criteria.
  3. R/format_ae_exp_adj.R:
    • Line 203 & Line 208: Formats total exposure and exposure-adjusted event rates (eaer) using fmt_pct() and formatC(..., format = "f").

Minimal Reproducible Examples (Reprex)
1. Negative Zero Displayed
library(metalite.ae)

# A small negative risk difference or proportion change displays as "-0.0" / "(-0.0)"
fmt_pct(-0.01, digits = 1)
#> [1] "(-0.0)"          # Expected: "(0.0)"

fmt_est(-0.01, digits = c(1, 1))
#> [1] " -0.0"           # Expected: " 0.0"

fmt_ci(-0.001, 0.50, digits = 2)
#> [1] "(-0.00,  0.50)"   # Expected: "( 0.00,  0.50)"
2. Exact Decimal Ties Not Rounding Half Away From Zero
ties <- c(0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95)

# Current behavior:
fmt_pct(ties, digits = 1)
#>  [1] "(0.1)" "(0.1)" "(0.2)" "(0.3)" "(0.5)" "(0.6)" "(0.7)" "(0.8)" "(0.8)" "(0.9)"

# Discrepancies on positive ties:
# 0.15 -> "(0.1)"  [Expected: "(0.2)"]
# 0.25 -> "(0.2)"  [Expected: "(0.3)"]
# 0.35 -> "(0.3)"  [Expected: "(0.4)"]
# 0.85 -> "(0.8)"  [Expected: "(0.9)"]
# 0.95 -> "(0.9)"  [Expected: "(1.0)"]

# Discrepancies on negative ties:
fmt_pct(-ties, digits = 1)
#>  [1] "(-0.1)" "(-0.1)" "(-0.2)" "(-0.3)" "(-0.5)" "(-0.6)" "(-0.7)" "(-0.8)" "(-0.8)" "(-0.9)"
# -0.15 -> "(-0.1)" [Expected: "(-0.2)"]
# -0.25 -> "(-0.2)" [Expected: "(-0.3)"]
# -0.35 -> "(-0.3)" [Expected: "(-0.4)"]
# -0.85 -> "(-0.8)" [Expected: "(-0.9)"]
# -0.95 -> "(-0.9)" [Expected: "(-1.0)"]
3. Filtering Inconsistency in AE Specific Reports (format_ae_specific)
# Suppose an adverse event has incidence 2.25% with filter_criteria = 2.3% (digits_prop = 1)
# Line 330 in format_ae_specific.R:
prop <- 2.25
round(prop, 1) >= 2.3
#> [1] FALSE   # round(2.25, 1) == 2.2 -> Incorrectly dropped

# Expected under BR-001 (round half away from zero):
# 2.25 rounds to 2.3 -> 2.3 >= 2.3 is TRUE -> Retained

Root Cause
  • formatC(..., format = "f") and round() implement IEEE 754 "round to nearest, ties to even".
  • Standard floating point formatting in C snprintf / formatC retains the sign bit on negative values close to zero (resulting in "-0.0").

Proposed Solution
  1. Add a centralized round-half-away-from-zero helper (with double precision epsilon buffer):

    round_half_away_from_zero <- function(x, digits = 0) {
      posneg <- sign(x)
      z <- abs(x) * 10^digits
      z <- z + 0.5 + sqrt(.Machine$double.eps)
      z <- trunc(z)
      z <- z / 10^digits
      z * posneg
    }
    
  2. Add a formatting helper that suppresses negative zeroes and applies half-away-from-zero rounding:

    format_number <- function(x, digits = 1, width = NULL) {
      if (length(x) == 0) return(character(0))
      
      # Round half away from zero
      x_round <- round_half_away_from_zero(x, digits = digits)
      
      # Eliminate negative zero
      x_round[abs(x_round) < 10^(-digits) / 2] <- 0
      
      # Format to fixed decimals
      res <- ifelse(
        is.na(x_round),
        NA_character_,
        formatC(x_round, digits = digits, format = "f", width = width)
      )
      
      # Final safeguard against any string "-0", "-0.0", etc.
      gsub("^-0(\\.0+)?$", "0\\1", res)
    }
    
  3. Refactor fmt_pct(), fmt_est(), fmt_ci(), fmt_pval(), format_ae_exp_adj(), and format_ae_specific() (line 330) to route all numeric formatting and cutoff-rounding through these functions.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in R/fmt.R by reading fmt_pct(), fmt_est(), fmt_ci(), and fmt_pval(), then inspect the filtering path at line 330 of R/format_ae_specific.R and the exposure formatting in R/format_ae_exp_adj.R. Use the issue's reproducible examples to check decimal ties, negative-zero output, and the 2.25 cutoff case. Done means all listed formatting and filtering paths follow BR-001 consistently.

Written by the indexing model from the issue text.

Assessment

Tech stack
r
Domain
analytics
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.