Skip to content

ADR 0057: Hold a rate, not a raw count (per)

Amendments.

  • Vocabulary: current spellings are standards (formerly ratchets), accept (formerly graduate), map where it names the command, config, or tree (formerly docs), and discern, the gate, or the bar for the retired product-category wording; the decision and reasoning are unchanged.
  • ADR 0231 — corpus: the prose rate now emits its denominator from the exact versioned corpus Vale inspects; frontmatter and _private contribute to neither side of the ratio, and the corrected unit set a new baseline once at 15.59 alerts per 1,000 linted words.

Status: accepted. Extends ADR 0003 (named metric standards) — the standard mechanism is unchanged; this adds an optional denominator so the measured value can be a rate.

Context

§

ADR 0003 made any only-ever-improve number eligible for a standard: a run emits DISCERN_METRIC <name> <number>, and the engine holds it against a limit compared to main. The number it holds is a raw scalar.

That is correct for a ratio like line coverage (covered / total) and for a true budget like shipped bytes — neither rises just because the project grew. It is a trap for a raw count over a corpus the project grows: lint alerts, TODOs, type errors, documentation nits. Such a count increases as you add code or docs even when nothing got worse, so the ceiling is breached by growth, not by regression. The only way to make the gate pass is to raise the ceiling — i.e. to loosen the standard, the one thing it exists to forbid.

We hit this dogfooding discern's own prose standard. The Vale alert count sat at 1703 with the ceiling locked one above it (1704); discern simultaneously tells agents to write ADRs and keep docs current, which drops new prose straight into the scanned corpus. Every doc-writing branch breached the ceiling, and agents "resolved" it by bumping the limit and rationalising the resulting loosened-vs-main failure as a transient that resolves on accept. The metric was extensive (scales with size); a standard is only honest on an intensive one (a rate or proportion, size divided out). The fix is to let a standard hold a rate. A contributing cause was the guidance itself: "tighten the limit to lock the gain in" is sound for a ratio but, applied to a count, manufactures exactly the zero-headroom ceiling that forces the next branch to loosen.

Decision

§

Add two optional keys to a [standards.<name>] table. They change only the measured value; the never-loosen-vs-main half (declared limit compared to main's) is untouched.

key meaning
per a denominator — divide the metric by it and hold the resulting rate
scale multiply the rate so the limit reads in human units (default 1)

The standardized value becomes metric / per * scale. per is one of:

  • A second emitted metric (a string naming it): the run emits both the numerator and the denominator as DISCERN_METRIC lines. The engine only does arithmetic — fully faithful to ADR 0003's "the run owns measurement."
  • A built-in extent ({ files | lines | words | bytes = <git pathspec> }): discern measures the denominator itself over the tracked tree (git ls-files, so .gitignore is honored and counts are deterministic). The run emits only the numerator. Counting files/lines/words/bytes is a universal, stack-neutral text operation, so owning it does not compromise neutrality, and it is the difference between "add one line to hold a rate" and "go rewrite your measurement script."

Omitting per is exactly ADR 0003's raw-count behavior, so the change is backward-compatible.

Teaching and detection

§

A mechanism users don't reach for changes nothing, so the decision also fixes how rate standards are taught:

  • The shipped guidance leads with a test, not a term: "does this number grow as the project grows, even when nothing got worse? then hold a rate." It drops the "lock the gain in" advice that built zero-margin ceilings, and states that a limit loosened versus main is the regression the standard exists to catch, never a baseline to reset.
  • When a raw-count ceiling is breached, discern standards points at the fix in the failure message (add per). discern audit carries a subjective rule that surfaces un-normalized ceiling counts as candidates — never a hard fail, since a genuine budget (shipped bytes) is a valid raw count.

Consequences

§
  • A growing tree never breaches a normalized standard on its own; only a real quality regression (a worse-than-average rate) does. The recurring loosened-vs-main churn on the prose standard disappears.
  • Discern's own prose standard converts to per = { words = "map/**" }, scale = 1000, tracking ~18.7 alerts per 1,000 words instead of a raw 1703.
  • Converting an existing standard from count to rate changes the limit's units. On the one conversion commit, the new rate-limit is compared to main's old count-limit; if it happens to read as loosened, that is a legitimate one-time change that resolves on accept (it did not for the prose conversion: 19 < 1704).
  • The denominator is measured on the working tree only — no second checkout, no measuring main. The cheap declared-limit-vs-main comparison from ADR 0003 still does the never-loosen work.

Alternatives considered

§
  • Standard an arbitrary expression over emitted metrics (value = "a / b * 1000"). Rejected: needs an expression parser and its safety surface. A quotient plus an optional scale covers the real cases; the second-metric form is the escape hatch for anything more exotic (compute it in the run).
  • Keep the engine arithmetic-only — per may name only an emitted metric. Rejected as the default: it costs every user a second emitted number for the common case. Kept as one of the two per forms for full control.
  • Normalize automatically / forbid raw counts. Rejected: a genuine budget (shipped bytes, binary size) is legitimately a raw count. The discrimination is the user's — "do you bound it, or grow it?" — so discern makes the rate easy and obvious, not mandatory.
  • Extents beyond text measures (e.g. "lines of code" by language, AST nodes). Rejected as scope creep and stack-entangling. files/lines/words/bytes are universal; anything language-aware belongs in the run as a second metric.
choose openEsc close