Viewing files at commit 653060c4e6b1Commit detailsView latest

Browse files
scripts/oakbench/conformance_gate.py 217 lines · 12.3 KB · python
1
"""Conformance gate: diagnostic conformance rows vs an explicit expected-failures list.
2
3
Semantics (strict xfail, schema 2):
4
- every expected-failure entry names its failing legs AND an error pattern per
5
  leg (``re.fullmatch`` against that leg's detail; legacy v1 scenarios use the
6
  pseudo-leg ``behaviour`` matched against the row's ``failure``). A listed
7
  behaviour fails as expected only when the failing-leg set is identical and
8
  every detail matches its pattern. Any other failure - a new leg, a changed
9
  error (EEXIST -> ``extra=['../../etc/evil']``, a rejection -> a panic) - is
10
  a REGRESSION;
11
- a listed behaviour that passes on a profile the entry applies to is FIXED:
12
  not a regression, but publish_gate refuses until the list is updated;
13
- entries may be scoped to filesystem profiles (``fs_profiles``); on other
14
  profiles they do not apply, so a pass there is just a pass (never FIXED) and
15
  a failure there is a regression;
16
- ``outcome: refused`` marks typed policy refusals (a product decision, e.g.
17
  control characters in names) and is reported separately from bugs;
18
  ``outcome: intermittent`` marks a timing-dependent bug: a pass is a pass
19
  (never FIXED), the pinned failure is INTERMITTENT, any other failure is a
20
  regression;
21
- coverage: for a recorded profile, any skip not in its ``expected_skips`` is
22
  a coverage loss (UNMEASURED) - a behaviour that used to pass or xfail must
23
  not silently turn into a skip. On an unrecorded profile only behaviours that
24
  need a known filesystem-dependent capability (case/normalization/non-UTF-8)
25
  or are gated may skip; a row without ``fs_profile`` and a run with zero
26
  measured behaviours are UNMEASURED. publish_gate additionally requires a
27
  recorded profile;
28
- harness errors, reader-unmeasured legs and required behaviours with no row
29
  make the result UNMEASURED.
30
31
Only the gated subject (Oak) is judged; Git rows are the reference arm.
32
"""
33
from __future__ import annotations
34
35
import json
36
from pathlib import Path
37
import re
38
39
ROOT = Path(__file__).resolve().parents[2]
40
EXPECTED_FAILURES = ROOT / "config" / "conformance_expected_failures.json"
41
SCHEMA_VERSION = 2
42
# fb-N once filed; pending:<slug> only while the coordinator files it (listed
43
# in every summary so it cannot be forgotten).
44
TICKET = re.compile(r"^(fb-\d+|pending:[a-z0-9-]+)$")
45
OUTCOMES = ("bug", "refused", "intermittent")
46
CAPABILITY_SKIP_PREFIXES = ("filesystem capability unavailable", "gated:", "gated behaviour")
47
# Capabilities that legitimately differ between ordinary hosts (macOS APFS vs
48
# Linux ext4). On an unrecorded profile only behaviours needing one of these,
49
# or gated behaviours, may skip; any other skip means a broken probe or host
50
# and the result is UNMEASURED.
51
KNOWN_FS_DEPENDENT = frozenset({"case_insensitive_fs", "case_sensitive_fs", "normalization_sensitive_fs",
52
                                "non_utf8_names"})
53
54
55
def fs_dependent_skip_allowed(behaviour: str) -> bool:
56
    from oakbench.conformance_matrix import BY_ID
57
    spec = BY_ID.get(behaviour)
58
    return spec is not None and (spec.gated or bool(spec.requires & KNOWN_FS_DEPENDENT))
59
60
61
def load_expected(path: Path = EXPECTED_FAILURES) -> dict:
62
    data = json.loads(Path(path).read_text())
63
    if data.get("schema_version") != SCHEMA_VERSION or not isinstance(data.get("entries"), list):
64
        raise ValueError("conformance expected-failures: unsupported schema")
65
    entries = {}
66
    for entry in data["entries"]:
67
        behaviour = entry.get("behaviour")
68
        if not behaviour or behaviour in entries:
69
            raise ValueError(f"conformance expected-failures: bad or duplicate behaviour {behaviour!r}")
70
        if not TICKET.match(str(entry.get("ticket", ""))):
71
            raise ValueError(f"conformance expected-failures: {behaviour} needs a ticket (fb-N or pending:slug)")
72
        if entry.get("outcome", "bug") not in OUTCOMES:
73
            raise ValueError(f"conformance expected-failures: {behaviour} outcome must be one of {OUTCOMES}")
74
        legs = entry.get("failed_legs")
75
        if not isinstance(legs, dict) or not legs:
76
            raise ValueError(f"conformance expected-failures: {behaviour} needs failed_legs {{leg: pattern}}")
77
        compiled = {}
78
        for leg, pattern in legs.items():
79
            if not isinstance(pattern, str) or not pattern:
80
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} needs an error pattern")
81
            compiled[leg] = re.compile(pattern, re.S)
82
        profiles = entry.get("fs_profiles")
83
        if profiles is not None and (not isinstance(profiles, list) or not profiles):
84
            raise ValueError(f"conformance expected-failures: {behaviour} fs_profiles must be a non-empty list")
85
        entries[behaviour] = {**entry, "outcome": entry.get("outcome", "bug"), "patterns": compiled}
86
    coverage = data.get("coverage") or {}
87
    if not isinstance(coverage, dict):
88
        raise ValueError("conformance expected-failures: coverage must map profile -> {expected_skips}")
89
    return {"subject": data.get("subject", "oak"), "entries": entries, "coverage": coverage}
90
91
92
def row_failed_legs(row: dict) -> dict:
93
    """{failed leg: detail} for a failing row (legacy rows: {'behaviour': failure})."""
94
    legs = row.get("legs")
95
    if isinstance(legs, dict):
96
        return {name: str(leg.get("detail") or "") for name, leg in legs.items()
97
                if leg.get("status") in ("fail", "error")}
98
    return {"behaviour": str(row.get("failure") or "")} if row.get("returncode") == 1 else {}
99
100
101
def entry_applies(entry: dict, profile: str | None) -> bool:
102
    profiles = entry.get("fs_profiles")
103
    return profiles is None or profile in profiles
104
105
106
def mismatch(entry: dict, failed: dict) -> str | None:
107
    """Why a failing row does not match its expected failure, or None."""
108
    expected = set(entry["patterns"])
109
    if set(failed) != expected:
110
        return f"expected legs {sorted(expected)}, observed {sorted(failed)}"
111
    for leg, pattern in entry["patterns"].items():
112
        if not pattern.fullmatch(failed[leg]):
113
            return f"leg {leg} error changed: {failed[leg][:200]!r} does not match {pattern.pattern!r}"
114
    return None
115
116
117
def evaluate(rows: list, expected: dict, required=None) -> dict:
118
    subject = expected["subject"]
119
    entries = expected["entries"]
120
    result = {"subject": subject, "passed": [], "expected_failures": [], "refused": [], "fixed": [],
121
              "intermittent": [], "regressions": [], "unmeasured": [], "skipped": [], "git_reference_failures": [],
122
              "differential_differs": [], "profiles": [], "notes": []}
123
    seen = set()
124
    subject_rows = [row for row in rows if row.get("evidence_class") == "diagnostic_conformance"]
125
    profiles = sorted({str(row.get("fs_profile")) for row in subject_rows
126
                       if row.get("subject") == subject and row.get("fs_profile")})
127
    result["profiles"] = profiles
128
    if len(profiles) > 1:
129
        result["unmeasured"].append(f"rows mix filesystem profiles {profiles}; gate one host at a time")
130
        result["verdict"] = "UNMEASURED"
131
        return result
132
    profile = profiles[0] if len(profiles) == 1 else None
133
    missing_profile = sorted(str(row.get("scenario")) for row in subject_rows
134
                             if row.get("subject") == subject and not row.get("fs_profile"))
135
    if missing_profile:
136
        result["unmeasured"].append(f"rows without fs_profile cannot be judged: {missing_profile[:5]}")
137
    recorded = expected["coverage"].get(profile) if profile else None
138
    result["profile_recorded"] = recorded is not None
139
    if profile and recorded is None:
140
        result["notes"].append(f"filesystem profile {profile} has no recorded coverage; only known "
141
                               f"filesystem-dependent ({sorted(KNOWN_FS_DEPENDENT)}) and gated skips accepted")
142
    expected_skips = set((recorded or {}).get("expected_skips", []))
143
    for row in subject_rows:
144
        behaviour = row.get("scenario")
145
        code = row.get("returncode")
146
        if row.get("subject") != subject:
147
            if row.get("subject") == "git" and code == 1:
148
                result["git_reference_failures"].append(f"{behaviour}: {row.get('failure')}")
149
            continue
150
        seen.add(behaviour)
151
        entry = entries.get(behaviour)
152
        if entry is not None and not entry_applies(entry, profile):
153
            entry = None
154
        differential = str(row.get("git_differential") or "")
155
        if differential.startswith("differs"):
156
            result["differential_differs"].append(f"{behaviour}: {differential}")
157
        if code == 0:
158
            if entry and entry["outcome"] == "intermittent":
159
                # An intermittent failure is expected to pass most runs: a pass
160
                # is a pass, never evidence of a fix.
161
                result["passed"].append(behaviour)
162
            elif entry:
163
                result["fixed"].append(f"{behaviour}: now passes; remove {entry['ticket']} from the expected-failures list")
164
            else:
165
                result["passed"].append(behaviour)
166
        elif code == 1:
167
            failed = row_failed_legs(row)
168
            if entry is None:
169
                result["regressions"].append(f"{behaviour}: {row.get('failure')}")
170
                continue
171
            why = mismatch(entry, failed)
172
            if why:
173
                result["regressions"].append(f"{behaviour}: known failure ({entry['ticket']}) changed: {why}")
174
            else:
175
                key = {"refused": "refused", "intermittent": "intermittent"}.get(entry["outcome"], "expected_failures")
176
                result[key].append(f"{behaviour}: {entry['ticket']}")
177
        elif code == 77 and str(row.get("skip_reason", "")).startswith(CAPABILITY_SKIP_PREFIXES):
178
            if recorded is not None and behaviour not in expected_skips:
179
                result["unmeasured"].append(f"{behaviour}: coverage loss on {profile}: skipped "
180
                                            f"({row.get('skip_reason')}) but not an expected skip")
181
            elif recorded is None and not fs_dependent_skip_allowed(behaviour):
182
                result["unmeasured"].append(f"{behaviour}: skipped on unrecorded profile {profile} "
183
                                            f"({row.get('skip_reason')}); only known filesystem-dependent "
184
                                            "or gated behaviours may skip there")
185
            else:
186
                result["skipped"].append(f"{behaviour}: {row.get('skip_reason')}")
187
        else:
188
            result["unmeasured"].append(f"{behaviour}: {row.get('failure') or row.get('skip_reason') or code}")
189
    for behaviour in sorted(set(required or ()) - seen):
190
        result["unmeasured"].append(f"{behaviour}: no {subject} conformance row")
191
    if not seen:
192
        result["unmeasured"].append(f"no {subject} conformance rows")
193
    measured = sum(len(result[key]) for key in ("passed", "expected_failures", "refused", "intermittent", "fixed",
194
                                                "regressions"))
195
    pending = sorted({entry["ticket"] for entry in entries.values() if entry["ticket"].startswith("pending:")})
196
    if pending:
197
        result["notes"].append(f"unfiled tickets in the expected-failures list: {pending}")
198
    result["measured"] = measured
199
    if seen and measured == 0:
200
        result["unmeasured"].append(f"zero measured {subject} behaviours (every row skipped or unmeasured)")
201
    result["verdict"] = ("REGRESSED" if result["regressions"] else
202
                         "UNMEASURED" if result["unmeasured"] else "PASS")
203
    return result
204
205
206
def summary_lines(result: dict) -> list:
207
    lines = [f"conformance ({result['subject']}, {','.join(result['profiles']) or 'no profile'}): "
208
             f"{result['verdict']} - {len(result['passed'])} pass, {len(result['expected_failures'])} expected-fail, "
209
             f"{len(result['refused'])} policy-refused, {len(result['intermittent'])} intermittent-fail, "
210
             f"{len(result['fixed'])} fixed, "
211
             f"{len(result['regressions'])} regression(s), {len(result['unmeasured'])} unmeasured, "
212
             f"{len(result['skipped'])} capability/gated skip(s)"]
213
    for key, label in (("regressions", "REGRESSION"), ("fixed", "FIXED"), ("unmeasured", "UNMEASURED"),
214
                       ("expected_failures", "XFAIL"), ("refused", "REFUSED"), ("intermittent", "INTERMITTENT"), ("differential_differs", "GIT-DIFF"),
215
                       ("git_reference_failures", "GIT-REF"), ("skipped", "SKIP"), ("notes", "NOTE")):
216
        lines += [f"  {label} {item}" for item in result[key]]
217
    return lines