Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ All notable changes to bestASR are documented here. The format follows

### Fixed

- **The compare stage no longer renders baseline values unescaped (#117)**:
`scripts/lib/baseline-compare.py` interpolated `corpus` / `language` /
`metric` straight into the pass/fail lines it prints, and that stdout is the
log a human reads to decide whether a release is safe. `metric` was validated
**nowhere**: the worklist stage checks corpus and language, and `metric` is
the only rendered field that is neither charset-checked nor numeric, so it
reaches the log as-is. (`float()` on the numbers is a *conversion*, not a
validation — it accepts `NaN` and `Infinity`; see #134.) A committed baseline
carrying `"cer\x1b[32m\rFAKE ALL-PASS"` repainted the
line into a forged green verdict, with no timing window needed. `metric` now
has to be `cer` or `wer`, and every rendered value is wrapped in an escaper
that neutralizes C0/DEL/C1 — enough that no value can emit an ANSI sequence or
return the cursor. Escaped rather than stripped, so the value stays visible.
Two honest limits: the wrapping is explicit at each interpolation, so a new
f-string is not covered until someone wraps it; and Unicode format characters
(U+2028, bidi overrides) are out of scope — they need no control byte, and are
unreachable here only because corpus and language are whitelisted upstream.

- **Baseline field validation extracted to a tested lib, and `model` validated
(#116, #115)**: the gate parsed `baseline.json` in two independent inline
heredocs — the worklist stage (`corpus` + `language`) and the model stage
Expand Down
34 changes: 34 additions & 0 deletions Tests/BestASRKitTests/RegressionBaselineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,38 @@ struct RegressionBaselineTests {
])
#expect(r.exit == 0)
}

// MARK: - baseline values reaching the CI log (#117)

@Test func `an unknown metric is a gate error, not a label nobody checks`() throws {
// metric was the one rendered field validated NOWHERE: the worklist
// stage checks corpus and language, and unlike golden/tolerance it
// never passes through float(). A bounded vocabulary makes a bad value
// loud instead of decorative.
var hostile = entry
hostile["metric"] = "cer\u{1B}[32m\rFAKE ALL-PASS"
let r = try runCompare(
baseline: [hostile],
measured: [["corpus": "c1", "metric": "cer", "error_rate": 0.10]])
#expect(r.exit != 0)
#expect(r.output.contains("unknown metric"))
}

@Test func `control characters never reach the log, even on the passing path`() throws {
// Defense in depth at the RENDER boundary: language passes the worklist
// whitelist in the gate, but this script is a separate entry point that
// re-validates nothing. The payload repaints a CI line — CR returns the
// cursor and the SGR turns it green — so a human reading the log to
// approve a release sees a forged verdict. Escaped, it is inert AND
// still diagnosable.
var hostile = entry
hostile["language"] = "zh\u{1B}[32m\rFAKE ALL-PASS"
let r = try runCompare(
baseline: [hostile],
measured: [["corpus": "c1", "metric": "cer", "error_rate": 0.10]])
#expect(r.exit == 0, "a hostile label must not change the verdict")
#expect(!r.output.contains("\u{1B}"), "ANSI escape reached the log")
#expect(!r.output.contains("\r"), "carriage return reached the log")
#expect(r.output.contains("\\x1b"), "the offending bytes should stay visible")
}
}
65 changes: 60 additions & 5 deletions scripts/lib/baseline-compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,72 @@
import json
import sys

# The accuracy metrics the gate knows how to judge. Same vocabulary as Swift's
# `MetricKind` and the bench validator's `metric_kind`, so an unknown value is a
# loud gate error rather than a label nobody notices (#117).
#
# The three definitions are duplicated with NO coupling test. Adding a third
# metric on the Swift side without adding it here does not just reject that
# corpus — the check below returns early, so one legitimate entry would suppress
# the verdict for every other corpus in the run.
METRICS = {"cer", "wer"}

# Every C0 control, DEL, and every C1 control, mapped to a visible escape.
# Baseline values reach stdout verbatim, and this stdout IS the CI log a human
# reads to decide whether a release is safe. A `metric` carrying CR + an ANSI
# SGR sequence can repaint the line — the reproduced payload
# "cer\x1b[32m\rFAKE ALL-PASS" renders as a green pass over the real verdict
# (#117). Escaping rather than deleting keeps the value visible, so a reader
# can still see roughly what was there.
_CONTROL_CHARS = {c: f"\\x{c:02x}" for c in range(0x20)}
_CONTROL_CHARS[0x7F] = "\\x7f"
_CONTROL_CHARS.update({c: f"\\x{c:02x}" for c in range(0x80, 0xA0)})


def safe(value) -> str:
"""Neutralize ANSI/terminal control codes in a baseline-supplied value.

Defense in depth at the render boundary: validation lives in the gate's
worklist stage, but this script is a separate entry point that re-validates
nothing it is handed.

SCOPE, so callers do not over-trust it: this escapes C0, DEL and C1 —
enough that a value cannot emit an ANSI sequence or return the cursor. It
does NOT cover Unicode format characters (U+2028/U+2029, bidi overrides),
which can reorder or split a line in a Unicode-aware viewer without using
any control byte; those are unreachable through the gate today because
corpus and language are `fullmatch`ed upstream and metric is bounded above.
And it must be called EXPLICITLY at each interpolation — a new f-string
added below is not covered until someone wraps it (#117).
"""
return str(value).translate(_CONTROL_CHARS)


def main() -> int:
data = json.load(sys.stdin)
failures = 0

# `metric` is the one rendered field with no validation ANYWHERE — the
# worklist stage checks corpus and language, nothing checks this. Unlike
# golden/tolerance/error_rate it never passes through float(), so a hostile
# string reaches the log intact (#117).
for e in data.get("baseline", []):
metric = e.get("metric")
if metric not in METRICS:
print(f"✗ GATE ERROR: baseline corpus '{safe(e.get('corpus'))}' has "
f"unknown metric {safe(metric)!r} — expected one of "
f"{'|'.join(sorted(METRICS))}")
failures += 1
if failures:
print(f"\n✗ regression gate: {failures} failure(s).")
return 1

# Duplicate corpus names would silently collapse (last-wins) in the dicts
# below — surface them as gate errors instead (#34 verify).
for side in ("baseline", "measured"):
names = [e["corpus"] for e in data.get(side, [])]
for dup in sorted({n for n in names if names.count(n) > 1}):
print(f"✗ GATE ERROR: duplicate corpus '{dup}' in {side} "
print(f"✗ GATE ERROR: duplicate corpus '{safe(dup)}' in {side} "
f"— entries would silently shadow each other")
failures += 1
if failures:
Expand All @@ -37,25 +92,25 @@ def main() -> int:
for corpus, m in measured.items():
b = baseline.get(corpus)
if b is None:
print(f"✗ GATE ERROR: measured corpus '{corpus}' has no baseline entry "
print(f"✗ GATE ERROR: measured corpus '{safe(corpus)}' has no baseline entry "
f"— add it to benchmarks/baseline.json (never silently pass)")
failures += 1
continue
golden, tol = float(b["golden"]), float(b["tolerance"])
actual = float(m["error_rate"])
diff = actual - golden
if diff > tol:
print(f"✗ REGRESSION {corpus} [{b['language']}] {b['metric']}: "
print(f"✗ REGRESSION {safe(corpus)} [{safe(b['language'])}] {safe(b['metric'])}: "
f"golden {golden:.4f} → measured {actual:.4f} "
f"(+{diff:.4f} > tolerance {tol:.4f})")
failures += 1
else:
print(f"✓ {corpus} [{b['language']}] {b['metric']}: "
print(f"✓ {safe(corpus)} [{safe(b['language'])}] {safe(b['metric'])}: "
f"golden {golden:.4f} → measured {actual:.4f} ({diff:+.4f})")

for corpus in baseline:
if corpus not in measured:
print(f"✗ GATE ERROR: baseline corpus '{corpus}' was never measured "
print(f"✗ GATE ERROR: baseline corpus '{safe(corpus)}' was never measured "
f"— gate cannot verify it (run fetch-corpora / check registration)")
failures += 1

Expand Down
Loading