build-cran-binaries/local/proposal-tracking-lib.R
pat-s 21a2fe9e6c
All checks were successful
ci/crow/manual/weekly-patch-proposals Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful
feat(local): report unclassified and dependency-blocked failures for discovery (#122)
## Why

Issue #120 (the auto-proposed-patches issue) only lists **auto-proposable** fixes -- currently just the TBB signatures. So a reasonable read of it was "TBB is our only build failure", when in fact three whole categories are simply not shown there:

- **Unclassified failures** -- anything that doesn't match a seeded signature is routed to human triage and never appears (we've only seeded TBB and libuv signatures).
- **Dependency-blocked failures** -- the ~800 RcppParallel dependents (post #121) are still failing; they only show as a log line.
- Human-only signatures (libuv).

These blind spots are exactly where the *next* signatures should come from, so they deserve the same visibility as the proposals.

## What

Extend the feedback-loop tracker to surface the classifier's blind spots:

- **`unclassified_summary()`** -- groups every unknown-signature failure by normalised fingerprint, ranked by build count, capped with an explicit `dropped_groups` count (no silent truncation), each with example packages + platforms. These are the candidates for new `build_signatures()` rules.
- **`blocked_summary()`** -- lists each dependency (e.g. RcppParallel) and how many dependents wait on it.
- `proposal-tracking.R` prints both sections, and a new **`--open-issue`** mode posts/updates a *"Unclassified build failures (needs signatures) (#115)"* Forgejo issue.
- The weekly crow pipeline now runs the tracker with `--open-issue`, so it maintains a second tracking issue alongside the proposals one. Read-only on the DB; the only writes are the two issues.

## Verification

- New tests cover `unclassified_summary` (ranking + both caps) and `blocked_summary`.
- Tracker smoke with a stubbed DB (proposable + blocked + unclassified mix) prints the hit rate, `Blocked on a dependency: RcppParallel: 2 dependent(s)`, and `Unclassified failures ... [2 builds | 2 pkgs] ld: undefined reference ...`.
- Full suite: 95 tests pass; all pre-commit hooks pass (air, prettier, markdownlint, yamllint, validate-patches).

Reviewed-on: #122
2026-07-15 07:46:29 +00:00

178 lines
6.6 KiB
R

# Pure, IO-free helpers for the proposal feedback loop (issue #115, step 4):
# a small ledger of what the classifier proposed, plus metrics derived from the
# live triage report and the registry (signature hit rate, proposed-vs-merged,
# retirement candidates).
#
# Kept free of DB/HTTP/clock so it can be sourced by the proposer, the tracker
# entrypoint, and the unit tests. Timestamps are passed in by callers.
# Split proposal candidates into the ones safe to emit and the ambiguous ones.
# A candidate is a list with at least `package` and `signature`. A package that
# maps to more than one distinct auto-proposable signature is genuinely
# ambiguous (two conflicting fix tiers, e.g. makevars vs source patch): emitting
# both would create colliding registry entries, so those are routed to human
# triage instead of guessed at. Returns list(keep = ..., ambiguous = ...), where
# `ambiguous` is a named list of package -> the distinct signatures seen.
dedupe_candidates <- function(candidates) {
if (length(candidates) == 0L) {
return(list(keep = list(), ambiguous = list()))
}
pkgs <- vapply(candidates, function(c) as.character(c$package), character(1L))
by_pkg <- split(candidates, pkgs)
keep <- list()
ambiguous <- list()
for (pkg in names(by_pkg)) {
cs <- by_pkg[[pkg]]
sigs <- unique(vapply(
cs,
function(c) as.character(c$signature),
character(1L)
))
if (length(sigs) == 1L) {
keep[[length(keep) + 1L]] <- cs[[1L]] # one signature -> take the first
} else {
ambiguous[[pkg]] <- sigs
}
}
list(keep = keep, ambiguous = ambiguous)
}
# Stable identity of a ledger record: one proposal per (package, signature).
ledger_key <- function(record) {
paste0(
if (is.null(record$package)) "?" else record$package,
"|",
if (is.null(record$signature)) "?" else record$signature
)
}
# Merge freshly-generated proposals into an existing ledger without clobbering
# history: a record whose (package, signature) already exists is left as-is
# (its status/PR/issue are preserved); genuinely new proposals are appended.
# Returns the combined list. Pure: callers stamp `proposed_at` before passing.
merge_ledger <- function(existing, new_records) {
if (is.null(existing)) {
existing <- list()
}
seen <- vapply(existing, ledger_key, character(1L))
out <- existing
for (rec in new_records) {
if (!(ledger_key(rec) %in% seen)) {
out[[length(out) + 1L]] <- rec
seen <- c(seen, ledger_key(rec))
}
}
out
}
# Per-signature hit rate from a triage report (list of group records from
# build_triage_report) crossed with the set of already-registered packages.
# For each *matched* signature: how many failing builds/packages it explains,
# and how many of those packages are already addressed by a registry entry.
signature_hit_rate <- function(report, registered_pkgs = character(0L)) {
matched <- Filter(function(g) isTRUE(g$matched), report)
by_sig <- split(
matched,
vapply(matched, function(g) g$signature, character(1L))
)
lapply(names(by_sig), function(sig) {
grps <- by_sig[[sig]]
pkgs <- unique(unlist(lapply(grps, function(g) g$packages)))
addressed <- intersect(pkgs, registered_pkgs)
list(
signature = sig,
builds = sum(vapply(grps, function(g) g$build_count, integer(1L))),
packages = length(pkgs),
addressed = length(addressed),
open = length(setdiff(pkgs, registered_pkgs)),
auto_proposable = any(vapply(
grps,
function(g) isTRUE(g$auto_proposable),
logical(1L)
))
)
})
}
# Proposed-vs-merged: a ledger record counts as "merged" once its package
# appears in the registry. Returns per-record status plus a rollup.
proposed_vs_merged <- function(ledger, registered_pkgs = character(0L)) {
if (is.null(ledger)) {
ledger <- list()
}
rows <- lapply(ledger, function(rec) {
merged <- !is.null(rec$package) && rec$package %in% registered_pkgs
list(
package = rec$package,
signature = rec$signature,
status = if (merged) "merged" else (rec$status %||% "proposed"),
merged = merged
)
})
list(
records = rows,
total = length(rows),
merged = sum(vapply(rows, function(r) isTRUE(r$merged), logical(1L)))
)
}
# Registry entries whose package no longer appears in any current failure are
# retirement candidates: the upstream cause was likely fixed, so the entry can
# be reviewed for removal. `failing_pkgs` is the set of currently-failing
# package names (from the live report).
retirement_candidates <- function(registry_entries, failing_pkgs) {
if (is.null(registry_entries)) {
registry_entries <- list()
}
keep <- Filter(
function(e) !is.null(e$package) && !(e$package %in% failing_pkgs),
registry_entries
)
vapply(keep, function(e) as.character(e$package), character(1L))
}
# Discovery view: the failure groups the classifier could NOT auto-propose, so
# they get the same visibility as proposals. `report` is a build_triage_report
# result. Returns the unclassified groups (unknown signature -> candidates for a
# new signature) ranked by build count, capped to `max_groups`, each with up to
# `max_pkgs` example packages. `dropped_groups`/`packages_truncated` record any
# cap so nothing is silently hidden.
unclassified_summary <- function(report, max_groups = 30L, max_pkgs = 15L) {
un <- Filter(function(g) !isTRUE(g$matched), report)
un <- un[order(-vapply(un, function(g) g$build_count, integer(1L)))]
shown <- utils::head(un, max_groups)
groups <- lapply(shown, function(g) {
pkgs <- g$packages
list(
fingerprint = g$fingerprint,
build_count = g$build_count,
n_packages = length(pkgs),
packages = utils::head(pkgs, max_pkgs),
packages_truncated = length(pkgs) > max_pkgs,
platforms = g$platforms
)
})
list(
groups = groups,
total_groups = length(un),
dropped_groups = max(0L, length(un) - length(shown)),
total_builds = sum(vapply(un, function(g) g$build_count, integer(1L)))
)
}
# Groups blocked on a dependency (a package-specific fix pinned via `applies_to`
# whose dependents merely carry its error): report the dependency + how many
# dependents wait on it, so fixing it once is recognised as clearing the batch.
blocked_summary <- function(report, max_pkgs = 15L) {
bl <- Filter(function(g) !is.null(g$blocked_on), report)
lapply(bl, function(g) {
list(
blocked_on = g$blocked_on,
n_packages = length(g$packages),
packages = utils::head(g$packages, max_pkgs),
packages_truncated = length(g$packages) > max_pkgs
)
})
}
`%||%` <- function(a, b) if (is.null(a)) b else a