Files

270 lines
12 KiB
R

#!/usr/bin/env Rscript
# analyse_artemis_stress.R
#
# Analysis for the Artemis surface stress test bug hunt (2026-08-02):
# a stale-pointer aliasing bug between the block-subsystem's devblock
# cache and the VM-level window cache in src/word_source/block_words.c,
# found by the first completed run of ART-STRESS-TEST, fixed, and
# re-verified on all three architectures.
#
# Reads experiments/artemis_stress/runs/*.csv, writes SVG charts to
# analysis/charts/ and PNG copies to analysis/img/.
suppressMessages({
library(ggplot2)
library(dplyr)
library(tidyr)
library(scales)
library(svglite)
})
run_dir <- file.path("experiments", "artemis_stress", "runs")
chart_dir <- file.path("experiments", "artemis_stress", "analysis", "charts")
img_dir <- file.path("experiments", "artemis_stress", "analysis", "img")
dir.create(chart_dir, recursive = TRUE, showWarnings = FALSE)
dir.create(img_dir, recursive = TRUE, showWarnings = FALSE)
## ---- palette (matches experiments/bare_metal house style) ----
sfblue <- "#1F77B4"
sforange <- "#FF7F0E"
sfgreen <- "#2CA02C"
sfred <- "#D62728"
sfgray <- "#505050"
theme_sf <- function() {
theme_minimal(base_size = 12) +
theme(
panel.grid.minor = element_blank(),
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(color = sfgray),
legend.position = "bottom"
)
}
save_chart <- function(plot, name, width = 7, height = 4.2) {
svg_path <- file.path(chart_dir, paste0(name, ".svg"))
png_path <- file.path(img_dir, paste0(name, ".png"))
ggsave(svg_path, plot, width = width, height = height, device = svglite)
ggsave(png_path, plot, width = width, height = height, dpi = 200)
cat(" wrote", svg_path, "and", png_path, "\n")
}
## ---- load runs ----
runs <- list(
amd64_buggy = "amd64 (pre-fix)",
amd64_fixed = "amd64 (post-fix)",
aarch64_fixed = "aarch64 (post-fix)",
riscv64_fixed = "riscv64 (post-fix)"
)
load_run <- function(key, label) {
df <- read.csv(file.path(run_dir, paste0(key, ".csv")))
df$run <- key
df$label <- label
df$status <- factor(ifelse(df$result == 1, "PASS", "FAIL"), levels = c("PASS", "FAIL"))
df
}
all_runs <- bind_rows(Map(load_run, names(runs), runs))
## ---- Chart 1: pass rate before/after, all runs ----
summary_tbl <- all_runs %>%
group_by(run, label) %>%
summarise(n = n(), passed = sum(result), .groups = "drop") %>%
mutate(pass_rate = passed / n,
phase = ifelse(grepl("pre-fix", label), "pre-fix", "post-fix"))
p1 <- ggplot(summary_tbl, aes(x = reorder(label, pass_rate), y = pass_rate, fill = phase)) +
geom_col(width = 0.6) +
geom_text(aes(label = sprintf("%d/%d", passed, n)), vjust = -0.5, size = 4) +
scale_y_continuous(labels = percent_format(), limits = c(0, 1.08)) +
scale_fill_manual(values = c("pre-fix" = sfred, "post-fix" = sfgreen)) +
coord_flip() +
labs(
title = "Artemis surface stress test: pass rate before/after fix",
subtitle = "50 trials per run, unique random blocks across the 22998-block data pool",
x = NULL, y = "Pass rate", fill = NULL
) +
theme_sf()
save_chart(p1, "pass_rate_comparison", width = 8)
## ---- Chart 2: pass/fail sequence in write/verify order, buggy run ----
## A visual scan of this sequence first suggested serial clustering; the
## Wald-Wolfowitz runs test (H1, below) does not support that reading --
## observed runs (26) exactly match the count expected under a random
## ordering. The chart is kept because it is still the raw sequence the
## tests are run against, not because it shows a confirmed pattern.
buggy <- all_runs %>% filter(run == "amd64_buggy") %>% arrange(trial)
p2 <- ggplot(buggy, aes(x = trial, y = 1, fill = status)) +
geom_tile(color = "white", linewidth = 0.6, height = 1) +
scale_fill_manual(values = c(PASS = sfgreen, FAIL = sfred)) +
scale_x_continuous(breaks = seq(0, 49, 5)) +
labs(
title = "Pass/fail sequence in write order, amd64 pre-fix run",
subtitle = "25 of 50 trials failed; a formal runs test (H1) finds no\nsignificant serial clustering in this sequence -- see Section 4",
x = "Trial index (= write/verify order)", y = NULL, fill = NULL
) +
theme_sf() +
theme(axis.text.y = element_blank(), panel.grid = element_blank())
save_chart(p2, "failure_clustering", height = 2.6)
## ---- Chart 3: surface coverage map, buggy vs one fixed run ----
cov <- all_runs %>% filter(run %in% c("amd64_buggy", "amd64_fixed"))
p3 <- ggplot(cov, aes(x = lbn, y = trial, color = status)) +
geom_point(size = 2.2, alpha = 0.85) +
scale_color_manual(values = c(PASS = sfgreen, FAIL = sfred)) +
facet_wrap(~label, ncol = 1) +
labs(
title = "Surface coverage: scattered across the full data pool",
subtitle = "Each point is one trial's block. A Wilcoxon test (H2) finds LBN and\noutcome are not independent, but no sub-region is visually implicated",
x = "Logical block number (data pool: 3076-26073)", y = "Trial index", color = NULL
) +
theme_sf()
save_chart(p3, "surface_coverage_map", height = 5.5)
## ---- summary table for the report ----
write.csv(summary_tbl, file.path("experiments", "artemis_stress", "analysis", "summary.csv"),
row.names = FALSE)
cat("\nDone. Summary:\n")
print(as.data.frame(summary_tbl))
## ═══════════════════════════════════════════════════════════════════════
## Hypothesis tests
##
## Three questions, each with an explicit null hypothesis, tested against
## the actual recorded trial data rather than asserted from eyeballing
## charts. Results are written to tables/ for the LaTeX report to quote
## verbatim -- no numbers in the report are hand-typed.
## ═══════════════════════════════════════════════════════════════════════
table_dir <- file.path("experiments", "artemis_stress", "analysis", "tables")
dir.create(table_dir, recursive = TRUE, showWarnings = FALSE)
sink_lines <- c()
say <- function(...) {
line <- paste0(...)
sink_lines <<- c(sink_lines, line)
cat(line, "\n")
}
say("Artemis surface stress test -- hypothesis tests")
say("=================================================")
say("")
## ---- H1: are pre-fix failures independently distributed across trials, ----
## ---- or do they show serial clustering (a runs test)? ----
## H0: the pass/fail sequence is a random ordering (no serial correlation).
## Manual Wald-Wolfowitz runs test (base R only, no extra package needed).
runs_test <- function(seq01) {
n1 <- sum(seq01 == 1); n2 <- sum(seq01 == 0); n <- n1 + n2
r <- 1 + sum(diff(seq01) != 0)
mu <- 1 + 2 * n1 * n2 / n
var_r <- (2 * n1 * n2 * (2 * n1 * n2 - n)) / (n^2 * (n - 1))
z <- (r - mu) / sqrt(var_r)
p <- 2 * pnorm(-abs(z))
list(n1 = n1, n2 = n2, runs = r, expected = mu, z = z, p = p)
}
buggy_seq <- buggy$result[order(buggy$trial)]
rt <- runs_test(buggy_seq)
say("H1 -- pre-fix failure clustering (Wald-Wolfowitz runs test)")
say(" H0: pass/fail outcomes are independently ordered (no clustering).")
say(sprintf(" Observed runs = %d (expected under H0 = %.2f, sd = %.2f)",
rt$runs, rt$expected, sqrt((2*rt$n1*rt$n2*(2*rt$n1*rt$n2-(rt$n1+rt$n2))) /
((rt$n1+rt$n2)^2*(rt$n1+rt$n2-1)))))
say(sprintf(" z = %.3f, p = %.2e", rt$z, rt$p))
say(sprintf(" -> %s H0 at alpha=0.05: failures %s serially clustered.",
ifelse(rt$p < 0.05, "REJECT", "fail to reject"),
ifelse(rt$p < 0.05, "ARE", "are not")))
say("")
## ---- H1b: is outcome associated with trial/write order directly ----
## ---- (not via the runs test, which only detects consecutive-value ----
## ---- clustering and can miss a more general order-dependent trend)? ----
## H0: trial index is drawn from the same distribution for PASS and FAIL.
wt_order <- wilcox.test(trial ~ status, data = buggy)
say("H1b -- pre-fix outcome vs. trial/write order directly (Wilcoxon)")
say(" H0: trial index is drawn from the same distribution for PASS/FAIL")
say(" (complements H1: the runs test only catches consecutive-value")
say(" clustering, not a general rank-order trend).")
say(sprintf(" W = %.1f, p = %.3f", wt_order$statistic, wt_order$p.value))
say(sprintf(" -> %s H0 at alpha=0.05: outcome %s associated with write order.",
ifelse(wt_order$p.value < 0.05, "REJECT", "fail to reject"),
ifelse(wt_order$p.value < 0.05, "IS", "is not")))
say("")
## ---- H2: is failure status independent of disk location (logical ----
## ---- block number)? ----
## H0: LBN is drawn from the same distribution for PASS and FAIL trials.
## Wilcoxon rank-sum test (non-parametric, no normality assumption on LBN).
wt <- wilcox.test(lbn ~ status, data = buggy)
say("H2 -- pre-fix failure vs. disk location (Wilcoxon rank-sum test)")
say(" H0: logical block number is drawn from the same distribution")
say(" for PASS and FAIL trials (failure does not depend on location).")
say(sprintf(" W = %.1f, p = %.3f", wt$statistic, wt$p.value))
say(sprintf(" -> %s H0 at alpha=0.05: failure %s associated with LBN.",
ifelse(wt$p.value < 0.05, "REJECT", "fail to reject"),
ifelse(wt$p.value < 0.05, "IS", "is not")))
say("")
## Secondary check: sibling position within devblock (0/1/2).
buggy$sib_pos <- (buggy$lbn - 3076) %% 3
pos_tbl <- table(buggy$sib_pos, buggy$status)
ct <- suppressWarnings(chisq.test(pos_tbl))
say(" Secondary check -- sibling position (0/1/2) vs. outcome (chi-squared)")
say(sprintf(" chi-sq = %.3f, df = %d, p = %.3f",
ct$statistic, ct$parameter, ct$p.value))
say(sprintf(" -> %s H0: outcome %s associated with sibling position.",
ifelse(ct$p.value < 0.05, "REJECT", "fail to reject"),
ifelse(ct$p.value < 0.05, "IS", "is not")))
say("")
## ---- H3: did the fix change the pass rate, or is 25/50 vs 150/150 ----
## ---- explainable by chance? ----
## H0: pre-fix and post-fix runs share the same true pass probability.
post_passed <- sum(all_runs$run != "amd64_buggy" & all_runs$result == 1)
post_n <- sum(all_runs$run != "amd64_buggy")
pre_passed <- sum(all_runs$run == "amd64_buggy" & all_runs$result == 1)
pre_n <- sum(all_runs$run == "amd64_buggy")
pt <- prop.test(c(pre_passed, post_passed), c(pre_n, post_n), correct = TRUE)
say("H3 -- did the fix change the pass rate (two-proportion test)")
say(" H0: pre-fix and post-fix trials share the same true pass rate.")
say(sprintf(" Pre-fix: %d/%d = %.1f%% Post-fix (pooled, 3 arches): %d/%d = %.1f%%",
pre_passed, pre_n, 100*pre_passed/pre_n,
post_passed, post_n, 100*post_passed/post_n))
say(sprintf(" chi-sq = %.2f, df = %d, p = %.2e", pt$statistic, 1, pt$p.value))
say(sprintf(" 95%% CI on the difference in pass rate: [%.3f, %.3f]",
pt$conf.int[1], pt$conf.int[2]))
say(sprintf(" -> %s H0 at alpha=0.05: the fix %s the pass rate.",
ifelse(pt$p.value < 0.05, "REJECT", "fail to reject"),
ifelse(pt$p.value < 0.05, "DID CHANGE", "did not measurably change")))
writeLines(sink_lines, file.path(table_dir, "hypothesis_tests.txt"))
## Machine-readable version for \newcommand substitution in the report.
hyp_df <- data.frame(
key = c("h1_runs_obs","h1_runs_exp","h1_z","h1_p",
"h1b_W","h1b_p",
"h2_W","h2_p","h2b_chisq","h2b_df","h2b_p",
"h3_pre_n","h3_pre_pass","h3_post_n","h3_post_pass",
"h3_chisq","h3_p","h3_ci_lo","h3_ci_hi"),
value = c(rt$runs, rt$expected, rt$z, rt$p,
unname(wt_order$statistic), wt_order$p.value,
unname(wt$statistic), wt$p.value, unname(ct$statistic), unname(ct$parameter), ct$p.value,
pre_n, pre_passed, post_n, post_passed,
unname(pt$statistic), pt$p.value, pt$conf.int[1], pt$conf.int[2])
)
write.csv(hyp_df, file.path(table_dir, "hypothesis_tests.csv"), row.names = FALSE)
cat("\nWrote", file.path(table_dir, "hypothesis_tests.txt"), "and .csv\n")