oracle-gate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ """oracle-gate — the reference implementation of The Oracle Gate framework."""
2
+ __version__ = "0.1.0"
oracle_gate/check.py ADDED
@@ -0,0 +1,346 @@
1
+ """Scoring and evidence emission for `oracle-gate check` — pure, no IO.
2
+
3
+ Two rules govern this module, and both exist because the framework accuses other tools
4
+ of breaking them:
5
+
6
+ 1. **Percentages are reported, never gated.** `mutation_score` and its flattering cousin
7
+ `mutation_score_covered` are printed together, each with its formula, so nobody has to
8
+ trust which denominator we chose. The gate is `survivors.triage`.
9
+
10
+ 2. **`check` fills G1 and G2 and nothing else.** It cannot certify human accountability,
11
+ an independent oracle, or a trustworthy eval. Those gates are emitted `not_met` with a
12
+ prompt, every time, even when everything it *can* measure passes.
13
+
14
+ Metrics it produces are stamped `source = "measured"`, distinguishing them from the
15
+ human-typed `declared` values the conformance checker also accepts. That is the third rung
16
+ of the ladder — attached, declared, measured — and the fourth rung, *correct*, is not
17
+ reachable by this or any tool.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from .checklist import PROFILE_KEY_BY_TIER, gates_for_tier
25
+ from .survivors import UNCLASSIFIED, UnclassifiedStatus
26
+
27
+ __all__ = [
28
+ "NoMutantsError",
29
+ "CountsDisagree",
30
+ "Verdict",
31
+ "scores",
32
+ "verdict",
33
+ "evidence",
34
+ "counts_from_mutants",
35
+ "cross_check",
36
+ "format_report",
37
+ ]
38
+
39
+ # The mutmut status strings that count toward a score, mapped to this tool's count keys.
40
+ # "no tests" -> "no_tests" is the one rename; everything else that reaches here is
41
+ # already classified by `undetected`/`counts_from_mutants` raising on anything else.
42
+ _STATUS_TO_COUNT_KEY = {"killed": "killed", "timeout": "timeout", "survived": "survived", "no tests": "no_tests"}
43
+
44
+ _FORMULA = {
45
+ "mutation_score": "detected / valid (detected = killed + timeout; valid = detected + survived + no_tests)",
46
+ "mutation_score_covered": "detected / covered (covered = detected + survived) — reported, not gated",
47
+ }
48
+
49
+ # Gates `check` is allowed to fill. Everything else needs a human.
50
+ _MACHINE_GATES = ("G1", "G2")
51
+
52
+
53
+ class NoMutantsError(ValueError):
54
+ """No valid mutants were produced, so there is no score to report."""
55
+
56
+
57
+ class CountsDisagree(ValueError):
58
+ """Two independently-derived count dicts disagree on a field.
59
+
60
+ `run_mutation` gives us counts two ways: mutmut's own `mutmut-cicd-stats.json`, and
61
+ whatever `counts_from_mutants` tallies by parsing `mutmut results --all true`. They
62
+ should always agree -- they describe the same run. If they do not, something is
63
+ wrong with the run or with how one of the two was read, and printing a score built
64
+ from either one would be trusting a source of truth that just proved itself
65
+ unreliable. Fail closed instead.
66
+ """
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Verdict:
71
+ passed: bool
72
+ unexplained: int
73
+
74
+ @property
75
+ def exit_code(self) -> int:
76
+ return 0 if self.passed else 1
77
+
78
+
79
+ def scores(counts) -> dict:
80
+ """Compute both mutation scores, with their formulas attached.
81
+
82
+ Fails closed if mutmut reported a status we cannot place on Stryker's map: silently
83
+ dropping those counts would choose a denominator by accident.
84
+ """
85
+ for name in sorted(UNCLASSIFIED):
86
+ # no default: absent and zero are both falsy, and spelling one invites an
87
+ # equivalent mutant that no test can ever kill.
88
+ if counts.get(name):
89
+ raise UnclassifiedStatus(
90
+ f"mutmut reported {counts[name]} {name!r} mutant(s). These do not map onto "
91
+ f"a known mutant state, and guessing would silently pick a denominator. "
92
+ f"Investigate or exclude them from the run."
93
+ )
94
+
95
+ killed = counts.get("killed", 0)
96
+ timeout = counts.get("timeout", 0)
97
+ survived = counts.get("survived", 0)
98
+ no_tests = counts.get("no_tests", 0)
99
+
100
+ detected = killed + timeout
101
+ valid = detected + survived + no_tests
102
+ covered = detected + survived
103
+
104
+ if valid == 0:
105
+ raise NoMutantsError("mutmut produced no valid mutants; there is nothing to score")
106
+
107
+ return {
108
+ "mutation_score": detected / valid,
109
+ "mutation_score_covered": (detected / covered) if covered else None,
110
+ "counts": {
111
+ "killed": killed,
112
+ "timeout": timeout,
113
+ "survived": survived,
114
+ "no_tests": no_tests,
115
+ "detected": detected,
116
+ "valid": valid,
117
+ "covered": covered,
118
+ },
119
+ "formula": dict(_FORMULA),
120
+ }
121
+
122
+
123
+ def verdict(triage) -> Verdict:
124
+ """The gate: every undetected mutant is killed or explained."""
125
+ return Verdict(passed=triage.passed, unexplained=len(triage.unexplained))
126
+
127
+
128
+ def counts_from_mutants(mutants) -> dict:
129
+ """Tally {"killed", "timeout", "survived", "no_tests"} straight from parsed mutants.
130
+
131
+ This is a second, independent path to the same counts `run_mutation` reads out of
132
+ mutmut's stats file -- see `cross_check`. Any status other than the four this tool
133
+ knows how to place (including "suspicious"/"segfault"/"skipped") is refused rather
134
+ than silently dropped or lumped in: naming the status and the mutant lets a human
135
+ decide, instead of this tool guessing a denominator.
136
+ """
137
+ counts = {"killed": 0, "timeout": 0, "survived": 0, "no_tests": 0}
138
+ for m in mutants:
139
+ key = _STATUS_TO_COUNT_KEY.get(m.status)
140
+ if key is None:
141
+ raise UnclassifiedStatus(
142
+ f"mutmut reported status {m.status!r} for {m.id}, which does not map "
143
+ f"onto a known mutant state. Refusing to guess: classify it or exclude "
144
+ f"it from the run."
145
+ )
146
+ counts[key] += 1
147
+ return counts
148
+
149
+
150
+ def cross_check(counts_from_stats, counts_from_results) -> None:
151
+ """Refuse to score unless mutmut's two outputs agree on every count.
152
+
153
+ `counts_from_stats` comes from `mutmut-cicd-stats.json`; `counts_from_results`
154
+ comes from parsing `mutmut results --all true` (see `counts_from_mutants`). They
155
+ describe the same run and must match. A disagreement means one of the two readings
156
+ is wrong, and printing a score anyway would silently pick which one to trust.
157
+ """
158
+ for field in ("killed", "survived", "no_tests", "timeout"):
159
+ a = counts_from_stats.get(field, 0)
160
+ b = counts_from_results.get(field, 0)
161
+ if a != b:
162
+ raise CountsDisagree(
163
+ f"counts disagree on {field!r}: mutmut-cicd-stats.json says {a}, "
164
+ f"mutmut results --all true says {b}"
165
+ )
166
+
167
+
168
+ def _fmt(value) -> str:
169
+ return f"{value:.6g}"
170
+
171
+
172
+ def evidence(
173
+ *,
174
+ tier: int,
175
+ coverage: float,
176
+ scores: dict,
177
+ triage,
178
+ undetected_total: int,
179
+ commit_sha: str,
180
+ generated_at: str,
181
+ coverage_report: str,
182
+ coverage_sha256: str,
183
+ mutation_report: str,
184
+ mutation_sha256: str,
185
+ ) -> str:
186
+ """Emit a TOML evidence package with G1/G2 measured and every other gate left to a human."""
187
+ profile = PROFILE_KEY_BY_TIER[tier]
188
+ g2_passed = triage.passed
189
+
190
+ lines = [
191
+ f"# Oracle Gate evidence package — Tier {tier} ({profile})",
192
+ "# G1/G2 were MEASURED by `oracle-gate check`. Every other gate needs a human.",
193
+ "# A pass attests completeness, declared floors, and provenance. Never correctness.",
194
+ "",
195
+ f'profile = "{profile}"',
196
+ "has_model_component = false",
197
+ f'commit_sha = "{commit_sha}"',
198
+ f'generated_at = "{generated_at}"',
199
+ "",
200
+ ]
201
+
202
+ for gate in gates_for_tier(tier):
203
+ level = gate.levels[profile]
204
+ lines.append(f"[gates.{gate.id}] # {gate.title} ({level})")
205
+
206
+ if gate.id == "G1":
207
+ lines += [
208
+ 'status = "met"',
209
+ f"value = {_fmt(coverage)} # coverage — reported, NOT gated",
210
+ 'source = "measured"',
211
+ f'report = "{coverage_report}"',
212
+ f'sha256 = "{coverage_sha256}"',
213
+ ]
214
+ elif gate.id == "G2":
215
+ lines += [
216
+ f'status = "{"met" if g2_passed else "not_met"}"',
217
+ f"value = {_fmt(scores['mutation_score'])} # mutation_score — reported, NOT gated",
218
+ f"survivors_total = {undetected_total}",
219
+ f"survivors_explained = {len(triage.explained)}",
220
+ 'source = "measured"',
221
+ f'report = "{mutation_report}"',
222
+ f'sha256 = "{mutation_sha256}"',
223
+ ]
224
+ if not g2_passed:
225
+ lines.append(
226
+ f"# {len(triage.unexplained)} undetected mutant(s) are neither killed nor "
227
+ f"explained. Kill them, or sign an entry in survivors.toml."
228
+ )
229
+ else:
230
+ lines.append('status = "not_met"')
231
+ lines.append(f"# {_prompt(gate)}")
232
+
233
+ lines.append("")
234
+
235
+ return "\n".join(lines)
236
+
237
+
238
+ def _prompt(gate) -> str:
239
+ if gate.automatable:
240
+ return "attach a report + its sha256, then set status."
241
+ return "a named human must approve: set approver, artifact, date, sha256."
242
+
243
+
244
+ def should_emit_evidence(*, scope_applied: bool) -> bool:
245
+ """An evidence package attests a REVISION. A diff-scoped run only gates a change.
246
+
247
+ Emitting a package that records `G2 status = "met"` after examining a subset of the
248
+ repository would claim more than was verified — the exact failure this framework
249
+ names. So a run that actually filtered by diff writes no package. A `--scope diff`
250
+ run whose base could not be resolved fell back to the whole repo, really did examine
251
+ it, and therefore still attests.
252
+ """
253
+ return not scope_applied
254
+
255
+
256
+ def _pct(value) -> str:
257
+ return " n/a " if value is None else f"{value * 100:5.1f}%"
258
+
259
+
260
+ def format_report(
261
+ coverage,
262
+ scores,
263
+ triage,
264
+ *,
265
+ base_warning: bool = False,
266
+ no_mutated_files: bool = False,
267
+ filtered: tuple | None = None,
268
+ ) -> str:
269
+ """The human-facing report. Both scores appear; neither is a gate.
270
+
271
+ The score section always reflects the whole configured mutmut scope -- only the
272
+ gate below it can be narrowed to a diff. Three optional, mutually exclusive flags
273
+ describe how `--scope diff` went:
274
+
275
+ `base_warning` -- no diff base could be resolved; the run fell back to the
276
+ full repo scope.
277
+ `no_mutated_files` -- a base resolved, but none of the files mutmut mutates
278
+ appear in the diff, so there is nothing to gate.
279
+ `filtered` -- `(filtered_out, total)` undetected mutants outside the
280
+ diff were dropped from the gate below.
281
+ """
282
+ counts = scores["counts"]
283
+ covered = scores["mutation_score_covered"]
284
+
285
+ lines = [""]
286
+ if base_warning:
287
+ lines.append(" WARNING: could not resolve a diff base; falling back to repo scope.")
288
+ lines += [
289
+ f" coverage {_pct(coverage)} reported, not gated",
290
+ f" mutation score {_pct(scores['mutation_score'])} reported, not gated"
291
+ " detected / valid",
292
+ f" on covered code {_pct(covered)} reported, not gated"
293
+ " detected / covered",
294
+ "",
295
+ f" {counts['valid']} mutants: {counts['killed']} killed, "
296
+ f"{counts['survived']} survived, "
297
+ f"{counts['no_tests']} never reached by any test.",
298
+ ]
299
+ if filtered is not None:
300
+ filtered_out, total = filtered
301
+ lines.append(
302
+ f" mutmut mutated the full configured scope; {filtered_out} of {total} "
303
+ f"undetected mutants were filtered out as untouched by this diff."
304
+ )
305
+ lines.append("")
306
+
307
+ undetected_n = len(triage.undetected)
308
+ if no_mutated_files:
309
+ lines.append(" no mutated files changed — nothing to gate.")
310
+ elif triage.passed and filtered is not None:
311
+ # Mutants outside the diff were FILTERED, not killed and not explained. Saying
312
+ # "all killed or explained" here would be false.
313
+ lines.append(f" G2 PASS — {undetected_n} undetected in the changed files.")
314
+ filtered_out, _total = filtered
315
+ if filtered_out:
316
+ lines.append(f" {filtered_out} undetected mutants remain elsewhere in this repo.")
317
+ elif triage.passed:
318
+ lines.append(f" G2 PASS — {undetected_n} undetected, all killed or explained.")
319
+ else:
320
+ lines.append(
321
+ f" G2 FAIL — {undetected_n} undetected, "
322
+ f"{len(triage.explained)} explained, {len(triage.unexplained)} unexplained."
323
+ )
324
+ for mutant in triage.unexplained[:10]:
325
+ lines.append(f" {mutant.id} ({mutant.status})")
326
+ if len(triage.unexplained) > 10:
327
+ lines.append(f" … and {len(triage.unexplained) - 10} more")
328
+ lines.append("")
329
+ lines.append(" Kill each one, or have a named human explain it in survivors.toml.")
330
+
331
+ if triage.absorbed:
332
+ lines.append("")
333
+ lines.append(" Explanations applied:")
334
+ for match, count in sorted(triage.absorbed.items()):
335
+ lines.append(f" {match:40} absorbed {count} mutant(s)")
336
+
337
+ if triage.stale:
338
+ lines.append("")
339
+ lines.append(" STALE explanations (their file changed; they absorb nothing):")
340
+ for match in triage.stale:
341
+ lines.append(f" {match}")
342
+
343
+ lines.append("")
344
+ lines.append(" A pass attests completeness, declared floors, and provenance. Never correctness.")
345
+ lines.append("")
346
+ return "\n".join(lines)
@@ -0,0 +1,131 @@
1
+ """The gate model, the per-tier checklist, and the machine-readable schema.
2
+
3
+ Each gate carries, per conformance profile, a requirement level: "must", "should", or "na"
4
+ (not applicable). This is the normative core the framework's prose gestured at, made precise so a
5
+ conformance claim can be checked (see conformance.py) instead of self-declared.
6
+
7
+ Pure logic (no network, no scanners) — fully testable and mutation-tested.
8
+
9
+ Honesty rule: gates that a tool cannot verify (requirements, human accountability, evals) are marked
10
+ `automatable = False` and are NEVER auto-passed — they require attached human evidence.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ # Profiles, and the tier -> profile mapping (framework Part 3/4).
17
+ PROFILE_KEY_BY_TIER = {1: "baseline", 2: "baseline", 3: "regulated", 4: "safety_critical"}
18
+ PROFILE_NAME = {
19
+ "baseline": "Baseline",
20
+ "regulated": "Regulated",
21
+ "safety_critical": "Safety-or-Security-Critical",
22
+ }
23
+ PROFILE_ORDER = ("baseline", "regulated", "safety_critical")
24
+
25
+ # Back-compat display map (tier -> profile display name).
26
+ PROFILE_BY_TIER = {t: PROFILE_NAME[PROFILE_KEY_BY_TIER[t]] for t in PROFILE_KEY_BY_TIER}
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Gate:
31
+ id: str
32
+ title: str
33
+ automatable: bool # can a tool ever verify this, or is it human/process?
34
+ levels: dict # profile_key -> "must" | "should" | "na"
35
+ metric: dict | None = None # optional {name, floors: {profile_key -> float}} for content-checking
36
+
37
+
38
+ def _lv(baseline, regulated, safety):
39
+ return {"baseline": baseline, "regulated": regulated, "safety_critical": safety}
40
+
41
+
42
+ def _metric(name, **floors):
43
+ """A reported metric, and optionally per-profile floors.
44
+
45
+ THIS FRAMEWORK SHIPS NO DEFAULT FLOORS. No published mutation- or coverage-score threshold has an
46
+ evidentiary basis (EVIDENCE.md), and a threshold invites Goodhart's law: a score rises either by
47
+ writing tests or by arguing that survivors are equivalent, and only one of those is free. The gate
48
+ is surviving mutants (see `survivors.py`), not a percentage.
49
+
50
+ Adopters MAY declare a local floor in their evidence package; `conformance._check_metric` enforces
51
+ whatever they declare. With no floor, a metric gate requires only that the value be REPORTED —
52
+ disclosure, not a target.
53
+ """
54
+ return {"name": name, "floors": dict(floors)}
55
+
56
+
57
+ # The twelve gates. `automatable` = whether a tool can ever check it. `levels` = the requirement per
58
+ # profile. G8 (evals) applies only when the deliverable has an AI/model component (checker handles
59
+ # that); G11 (waivers) is validated structurally by the checker when a waiver is present, not required
60
+ # as standalone evidence.
61
+ GATES = (
62
+ Gate("G0", "Approved requirements & traceability", False, _lv("na", "must", "must")),
63
+ Gate("G1", "Coverage reported (a disclosure, never a target)", True, _lv("must", "must", "must"),
64
+ metric=_metric("coverage")),
65
+ Gate("G2", "Fault-detection shown (no unexplained surviving mutants)", True, _lv("should", "must", "must"),
66
+ metric=_metric("mutation_score")),
67
+ Gate("G3", "Independent oracle (independence established)", False, _lv("should", "must", "must")),
68
+ Gate("G4", "Real supply-chain control (SCA, SBOM, pinning)", True, _lv("should", "must", "must")),
69
+ Gate("G4b", "Security gates by tier (static + secret scan; more at T3+)", True, _lv("must", "must", "must")),
70
+ Gate("G5", "Cross-model review (advisory, human-triaged)", True, _lv("should", "should", "should")),
71
+ Gate("G6", "Human accountability / code review", False, _lv("should", "must", "must")),
72
+ Gate("G7", "Tests are protected (no silent weakening)", True, _lv("should", "must", "must")),
73
+ Gate("G8", "Trustworthy evals (AI/model components)", False, _lv("should", "must", "must")),
74
+ Gate("G9", "AI-use provenance recorded", False, _lv("should", "must", "must")),
75
+ Gate("G10", "Evidence package assembled", False, _lv("should", "must", "must")),
76
+ Gate("G11", "Waivers explicit and time-boxed", False, _lv("should", "should", "must")),
77
+ )
78
+
79
+ GATE_BY_ID = {g.id: g for g in GATES}
80
+
81
+
82
+ def gates_for_tier(tier: int) -> list[Gate]:
83
+ """The gates that apply at a given tier (1-4): those whose level for the tier's profile != 'na'."""
84
+ if tier not in PROFILE_KEY_BY_TIER:
85
+ raise ValueError(f"tier must be 1-4, got {tier}")
86
+ profile = PROFILE_KEY_BY_TIER[tier]
87
+ return [g for g in GATES if g.levels[profile] != "na"]
88
+
89
+
90
+ def render_checklist(tier: int) -> str:
91
+ """A Markdown evidence-package skeleton for the tier: what applies, and how each is satisfied."""
92
+ applicable = gates_for_tier(tier) # validates the tier first (clean ValueError on bad input)
93
+ profile = PROFILE_BY_TIER[tier]
94
+ auto = [g for g in applicable if g.automatable]
95
+ human = [g for g in applicable if not g.automatable]
96
+
97
+ lines = [
98
+ f"# Oracle Gate — evidence package (Tier {tier} · {profile} profile)",
99
+ "",
100
+ f"{len(applicable)} gates apply at this tier: {len(auto)} machine-checkable, "
101
+ f"{len(human)} require human evidence (never auto-passed).",
102
+ "",
103
+ "## Machine-checkable gates",
104
+ ]
105
+ for g in auto:
106
+ lines.append(f"- [ ] **{g.id}** — {g.title} _(tool can run; attach the report)_")
107
+ lines += ["", "## Human-evidence gates (sign-off required — a tool cannot verify these)"]
108
+ for g in human:
109
+ lines.append(f"- [ ] **{g.id}** — {g.title} _(attach the artifact + approver)_")
110
+ lines += [
111
+ "",
112
+ "## Conformance statement",
113
+ f"> Meets The Oracle Gate, **{profile}** profile, when every box above is checked with "
114
+ "attached evidence. Anything short ships under an explicit, time-boxed waiver (G11).",
115
+ ]
116
+ return "\n".join(lines)
117
+
118
+
119
+ def schema() -> dict:
120
+ """The machine-readable gate/profile schema — so other tools can build against it."""
121
+ return {
122
+ "framework": "The Oracle Gate",
123
+ "profiles": {k: PROFILE_NAME[k] for k in PROFILE_ORDER},
124
+ "tier_to_profile": {str(t): PROFILE_KEY_BY_TIER[t] for t in PROFILE_KEY_BY_TIER},
125
+ "levels": ["must", "should", "na"],
126
+ "gates": [
127
+ {"id": g.id, "title": g.title, "automatable": g.automatable, "levels": g.levels,
128
+ "metric": g.metric}
129
+ for g in GATES
130
+ ],
131
+ }