Repository navigation
raw_arch_api bands a per-file declaration count against a per-function median — one granularity mismatch, three red columns #2771
Description
Same pass as #2769/#2770 (2026-09-05, engine 3b3568e0, corpus main 6183c4b). #2730/#2743 gave the api rule a contract and a documented fallback family for languages with no per-function visibility concept. That fixed what each rule matches. It left the magnitude question untouched, and the magnitude question is what three consecutive red columns are made of:
| metric | consistency | what it reads |
|---|---|---|
raw_arch_api |
76% | the rule's own hits |
risk_api_exposure |
71% | api, encapsulation |
risk_documentation |
76% | api × 2 + opaque execution, over a length floor |
The measurement
Every language expresses the same 12-probe program with the same public surface. The per-function-visibility languages declare it three times per file (one modifier per probe); the fallback-family languages declare it once per file, because their idiom has one file-level declaration and no per-function form:
raw_arch_api, per file, corpus median 3.0
3.00 c, cpp, java, javascript, python, go, rust, swift, … (per-function modifier)
1.25 html (a <div id=> per probe plus main's ownership meta)
1.00 agc_assembly, css, dockerfile, haskell, yacc, yaml (one file-level declaration)
0.25 jcl (one PROC declaration, in main only)
0.00 markdown (no rule), objective-c, sqlite (no plantable idiom)
Eleven red cells on raw_arch_api, and eight on risk_api_exposure — agc_assembly −76%, haskell −75%, css −56%, perl −52%, dockerfile/rust/yaml −50%, sqlite −100%. risk_documentation then adds css/haskell/dockerfile/yaml at −56/−57% for the same reason, because its risk numerator is api × 2 and its defence term (doc, ownership, doc_loc) is near-constant across the corpus — the documentation column is, in this corpus, an api column with extra steps.
The point
None of those languages has less public surface. EXPOSE 8080 publishes a Dockerfile's whole interface; module Probe (probeGlobals, probeTest, probeSafety) where publishes all three Haskell functions in one line; //ROSPROC PROC publishes the JCL procedure that thirteen EXEC steps invoke. The count differs because the metric counts declarations and the two idioms declare at different granularity — one per name versus one per file. Banding a per-file count against a per-function median measures the idiom, not the surface, which is precisely the class of comparison #2669 exists to remove.
The engine already has the pattern for handling this. bias_report.py bands constant_sensitive metrics against their own strictness stratum (#2669 F.3) rather than the global median, because a language-level constant makes a global comparison meaningless. This is the same shape: a declaration-granularity constant, known per language, documented in docs/api_rule_contract.md's own audit table.
Candidate shapes
(a) Scoring layer — normalise by declaration granularity. _calc_api_exposure and _calc_documentation read api as a count of exposed surface. If the language declares per-file, the surface it names is the file's whole function population, so api should enter those formulas as min(api × functions_found, functions_found) for the fallback family, or equivalently as an exposure ratio rather than a count. This is the fix that makes the risk scores comparable, and it is a #2655-shaped design change to two formulas, not 46 rules.
(b) Reporting layer — a second stratum. docs/api_rule_contract.md already lists the fallback family by name. bias_report.py bands raw_arch_api, risk_api_exposure and risk_documentation against the median of the declaring language's own granularity class, exactly as it already does for irc0–irc4. Cheaper, and it fixes the reading of the corpus without changing any score a customer sees — which also means it fixes nothing for real repositories.
(c) Reject both and mark the family n/a. Defensible under GATING's n/a criterion only if the absence is structural, and it is not: these languages do record a nonzero, so the cell is comparable in the mechanical sense the n/a rule uses. This would be a widening of the n/a criterion, and should be argued explicitly rather than fallen into.
(a) and (b) are not exclusive — (a) is the engine answer and (b) is what makes the corpus able to check it. My reading is that (a) is the real one: risk_api_exposure for a Dockerfile that publishes a port genuinely should not sit at −50% against a Java file that publishes three methods.
Corpus pairing
api is planted (SPEC.md, one file-level declaration per fallback-family file, planted in the #2729 wave). No authoring change under (a) or (b); the re-bless moves raw_arch_api's banding, the eight risk_api_exposure cells and the four risk_documentation cells. Ledger entries that narrow: api-non-function-surfaces (which states the granularity fact and files it as morphology), api-column-is-rule-hits-only, api-export-list-morphology, risk-api-exposure-zero-api-shortcircuit.
Activity
squid-protocol commentedon Sep 10, 2026
Closing per #2908's issue-disposition table ("answered by D2's per-unit name set; close when Phase 2 lands").
The granularity mismatch this issue named — a per-file declaration count banded against a per-function median — is resolved at the source: since #2935 (Phase 2) every extracted unit carries
is_public(the D2 name-set union: api-header match ∪ export list ∪ healed orphans, a union of names that cannot double-count), and since #2938 (Phase 3)risk_documentationconsumes exactly that per-unit set. The score contract isstated(#2939;signal_contracts.pyscore-contract section, first entry).What remains of
raw_arch_api's red columns (2 of 46 cells open on the corpus scoreboard) is the api rule's own contract territory, carried by the ledger's api entries and inherited by therisk_api_exposureepic, which copies #2908's phase list and reads the same D2 name set.