Viewing files at commit 878fc46b2f31Commit detailsView latest

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