nullcov 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.
- nullcov/__init__.py +47 -0
- nullcov/alignment.py +194 -0
- nullcov/coverage.py +200 -0
- nullcov/langgraph/__init__.py +26 -0
- nullcov/langgraph/topology.py +208 -0
- nullcov/plugin.py +274 -0
- nullcov/puremode.py +148 -0
- nullcov/report.py +137 -0
- nullcov/taxonomy.py +191 -0
- nullcov-0.1.0.dist-info/METADATA +270 -0
- nullcov-0.1.0.dist-info/RECORD +14 -0
- nullcov-0.1.0.dist-info/WHEEL +4 -0
- nullcov-0.1.0.dist-info/entry_points.txt +2 -0
- nullcov-0.1.0.dist-info/licenses/LICENSE +21 -0
nullcov/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""nullcov -- honest coverage for agent scaffolds.
|
|
2
|
+
|
|
3
|
+
An agent test suite that reports 94% is usually reporting on the layers it
|
|
4
|
+
happens to exercise. The layers nobody wrote a case for contribute nothing to
|
|
5
|
+
the number and are absent from the summary, so the suite looks strongest exactly
|
|
6
|
+
where it is blindest.
|
|
7
|
+
|
|
8
|
+
nullcov reports those layers as ``unknown``. Never ``100%``, never omitted. And
|
|
9
|
+
when production incidents are supplied, it separates the layers no case looks at
|
|
10
|
+
from the layers whose cases pass while production fails anyway -- the second
|
|
11
|
+
being the one that explains a green pipeline and a customer-visible outage on the
|
|
12
|
+
same afternoon.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .alignment import (
|
|
16
|
+
AlignmentReport,
|
|
17
|
+
Finding,
|
|
18
|
+
FindingKind,
|
|
19
|
+
Incident,
|
|
20
|
+
align,
|
|
21
|
+
)
|
|
22
|
+
from .coverage import CoverageReport, SliceCoverage, Verdict
|
|
23
|
+
from .plugin import case
|
|
24
|
+
from .puremode import PureModeReport, PureModeViolation, is_active, pure_mode
|
|
25
|
+
from .taxonomy import Layer, Taxonomy, TaxonomyError
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AlignmentReport",
|
|
31
|
+
"CoverageReport",
|
|
32
|
+
"Finding",
|
|
33
|
+
"FindingKind",
|
|
34
|
+
"Incident",
|
|
35
|
+
"Layer",
|
|
36
|
+
"PureModeReport",
|
|
37
|
+
"PureModeViolation",
|
|
38
|
+
"SliceCoverage",
|
|
39
|
+
"Taxonomy",
|
|
40
|
+
"TaxonomyError",
|
|
41
|
+
"Verdict",
|
|
42
|
+
"align",
|
|
43
|
+
"case",
|
|
44
|
+
"is_active",
|
|
45
|
+
"pure_mode",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
nullcov/alignment.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Reality alignment: does the suite look where the system actually bleeds?
|
|
2
|
+
|
|
3
|
+
Half of surveyed enterprises shipped an agent that passed their own evaluations
|
|
4
|
+
and still failed in front of a customer, and the most common complaint about
|
|
5
|
+
those evaluations was poor alignment with real-world outcomes rather than
|
|
6
|
+
insufficient coverage (VentureBeat VB Pulse, June 2026, n=157). Adding more
|
|
7
|
+
cases does not fix that. The suite has to be checked against production.
|
|
8
|
+
|
|
9
|
+
Cross-referencing incidents with coverage separates two failures that look
|
|
10
|
+
identical on a dashboard:
|
|
11
|
+
|
|
12
|
+
**Blind spot** -- incidents in a layer with no coverage. The suite never looks
|
|
13
|
+
there. Adding cases is the fix.
|
|
14
|
+
|
|
15
|
+
**Misalignment** -- incidents in a layer the suite reports as verified. The suite
|
|
16
|
+
looks, and sees the wrong thing. More cases of the same shape make it worse, not
|
|
17
|
+
better; the existing cases are the thing to distrust.
|
|
18
|
+
|
|
19
|
+
Only the second one explains a green suite and an angry customer on the same day.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from enum import Enum
|
|
28
|
+
|
|
29
|
+
from .coverage import CoverageReport, Verdict
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Incident",
|
|
33
|
+
"FindingKind",
|
|
34
|
+
"Finding",
|
|
35
|
+
"AlignmentReport",
|
|
36
|
+
"align",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Incident:
|
|
42
|
+
"""One production failure, attributed to a layer.
|
|
43
|
+
|
|
44
|
+
Attribution is the input this engine cannot derive for itself. It comes from
|
|
45
|
+
an incident review, a postmortem tag, or a trace whose failing span maps onto
|
|
46
|
+
a layer. Attribution quality bounds the quality of everything below.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
incident_id: str
|
|
50
|
+
layer: str
|
|
51
|
+
summary: str = ""
|
|
52
|
+
severity: str = "unknown"
|
|
53
|
+
occurred_at: datetime | None = None
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if not self.incident_id:
|
|
57
|
+
raise ValueError("incident_id cannot be empty")
|
|
58
|
+
if not self.layer:
|
|
59
|
+
raise ValueError(f"incident {self.incident_id} has no layer attribution")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FindingKind(str, Enum):
|
|
63
|
+
BLIND_SPOT = "blind_spot"
|
|
64
|
+
MISALIGNMENT = "misalignment"
|
|
65
|
+
UNATTRIBUTED = "unattributed"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Finding:
|
|
70
|
+
"""A layer where production and the test suite disagree."""
|
|
71
|
+
|
|
72
|
+
kind: FindingKind
|
|
73
|
+
layer: str
|
|
74
|
+
incident_count: int
|
|
75
|
+
incident_share: float
|
|
76
|
+
coverage_verdict: Verdict
|
|
77
|
+
cases_run: int
|
|
78
|
+
|
|
79
|
+
def explain(self) -> str:
|
|
80
|
+
share = f"{self.incident_share:.0%}"
|
|
81
|
+
if self.kind is FindingKind.BLIND_SPOT:
|
|
82
|
+
return (
|
|
83
|
+
f"{self.layer}: {self.incident_count} incidents ({share} of all "
|
|
84
|
+
f"attributed) and no coverage at all. The suite cannot speak to "
|
|
85
|
+
f"this layer."
|
|
86
|
+
)
|
|
87
|
+
if self.kind is FindingKind.MISALIGNMENT:
|
|
88
|
+
return (
|
|
89
|
+
f"{self.layer}: {self.incident_count} incidents ({share} of all "
|
|
90
|
+
f"attributed) despite {self.cases_run} passing cases. The cases "
|
|
91
|
+
f"pass and production still fails -- distrust these cases before "
|
|
92
|
+
f"adding more."
|
|
93
|
+
)
|
|
94
|
+
return (
|
|
95
|
+
f"{self.layer}: {self.incident_count} incidents ({share} of all "
|
|
96
|
+
f"attributed) against a layer that is not in the declared taxonomy."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class AlignmentReport:
|
|
102
|
+
"""Findings, ordered by how much production pain sits behind each."""
|
|
103
|
+
|
|
104
|
+
findings: list[Finding] = field(default_factory=list)
|
|
105
|
+
total_incidents: int = 0
|
|
106
|
+
layers_without_incidents: frozenset[str] = field(default_factory=frozenset)
|
|
107
|
+
|
|
108
|
+
def of_kind(self, kind: FindingKind) -> list[Finding]:
|
|
109
|
+
return [f for f in self.findings if f.kind is kind]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def blind_spots(self) -> list[Finding]:
|
|
113
|
+
return self.of_kind(FindingKind.BLIND_SPOT)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def misalignments(self) -> list[Finding]:
|
|
117
|
+
return self.of_kind(FindingKind.MISALIGNMENT)
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict:
|
|
120
|
+
return {
|
|
121
|
+
"total_incidents": self.total_incidents,
|
|
122
|
+
"layers_without_incidents": sorted(self.layers_without_incidents),
|
|
123
|
+
"findings": [
|
|
124
|
+
{
|
|
125
|
+
"kind": f.kind.value,
|
|
126
|
+
"layer": f.layer,
|
|
127
|
+
"incident_count": f.incident_count,
|
|
128
|
+
"incident_share": round(f.incident_share, 4),
|
|
129
|
+
"coverage_verdict": f.coverage_verdict.value,
|
|
130
|
+
"cases_run": f.cases_run,
|
|
131
|
+
"explanation": f.explain(),
|
|
132
|
+
}
|
|
133
|
+
for f in self.findings
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def align(coverage: CoverageReport, incidents: list[Incident]) -> AlignmentReport:
|
|
139
|
+
"""Cross-reference production incidents against coverage.
|
|
140
|
+
|
|
141
|
+
Findings are sorted by incident count, so the layer costing the most in
|
|
142
|
+
production is read first. Layers with incidents but a clean, exercised
|
|
143
|
+
suite are reported as misalignments rather than passes -- a layer that keeps
|
|
144
|
+
failing in front of users has not been verified in any sense that matters,
|
|
145
|
+
whatever the suite says.
|
|
146
|
+
"""
|
|
147
|
+
if not incidents:
|
|
148
|
+
return AlignmentReport(
|
|
149
|
+
findings=[],
|
|
150
|
+
total_incidents=0,
|
|
151
|
+
layers_without_incidents=coverage.declared_layers,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
counts = Counter(incident.layer for incident in incidents)
|
|
155
|
+
total = len(incidents)
|
|
156
|
+
cases_by_layer = {
|
|
157
|
+
layer: sum(s.cases_run for s in coverage.slices if s.layer == layer)
|
|
158
|
+
for layer in {s.layer for s in coverage.slices}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
findings: list[Finding] = []
|
|
162
|
+
for layer, count in counts.items():
|
|
163
|
+
share = count / total
|
|
164
|
+
cases_run = cases_by_layer.get(layer, 0)
|
|
165
|
+
|
|
166
|
+
if layer not in coverage.declared_layers:
|
|
167
|
+
kind = FindingKind.UNATTRIBUTED
|
|
168
|
+
verdict = Verdict.UNKNOWN
|
|
169
|
+
else:
|
|
170
|
+
verdict = coverage.layer_verdict(layer)
|
|
171
|
+
kind = (
|
|
172
|
+
FindingKind.BLIND_SPOT
|
|
173
|
+
if verdict is Verdict.UNKNOWN
|
|
174
|
+
else FindingKind.MISALIGNMENT
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
findings.append(
|
|
178
|
+
Finding(
|
|
179
|
+
kind=kind,
|
|
180
|
+
layer=layer,
|
|
181
|
+
incident_count=count,
|
|
182
|
+
incident_share=share,
|
|
183
|
+
coverage_verdict=verdict,
|
|
184
|
+
cases_run=cases_run,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
findings.sort(key=lambda f: (-f.incident_count, f.layer))
|
|
189
|
+
|
|
190
|
+
return AlignmentReport(
|
|
191
|
+
findings=findings,
|
|
192
|
+
total_incidents=total,
|
|
193
|
+
layers_without_incidents=coverage.declared_layers - set(counts),
|
|
194
|
+
)
|
nullcov/coverage.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The honesty engine.
|
|
2
|
+
|
|
3
|
+
Conventional coverage collapses to a single percentage. That percentage is a lie
|
|
4
|
+
whenever a layer of the system was never exercised at all: zero cases divided by
|
|
5
|
+
zero cases is reported as "fine", and a green aggregate hides an untested layer.
|
|
6
|
+
|
|
7
|
+
nullcov refuses that. Coverage here is three-valued. A layer that was never
|
|
8
|
+
exercised is ``UNKNOWN`` -- never ``100%``, never silently dropped from the
|
|
9
|
+
average. The aggregate carries the unknowns with it, so a summary cannot launder
|
|
10
|
+
a blind spot.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Verdict",
|
|
20
|
+
"SliceCoverage",
|
|
21
|
+
"CoverageReport",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Verdict(str, Enum):
|
|
26
|
+
"""Outcome for a single slice.
|
|
27
|
+
|
|
28
|
+
``UNKNOWN`` is the whole point of this library: it is structurally distinct
|
|
29
|
+
from ``VERIFIED`` and never coerces to a passing number.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
VERIFIED = "verified"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
UNKNOWN = "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SliceCoverage:
|
|
39
|
+
"""Coverage for one slice -- one contract of one layer.
|
|
40
|
+
|
|
41
|
+
A slice is the smallest unit that can be honestly reported on. ``cases_run``
|
|
42
|
+
counts assertions that actually executed; a slice that was declared but never
|
|
43
|
+
executed has ``cases_run == 0`` and is ``UNKNOWN``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
layer: str
|
|
47
|
+
slice_id: str
|
|
48
|
+
cases_run: int = 0
|
|
49
|
+
cases_failed: int = 0
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if self.cases_run < 0 or self.cases_failed < 0:
|
|
53
|
+
raise ValueError("case counts cannot be negative")
|
|
54
|
+
if self.cases_failed > self.cases_run:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"{self.layer}/{self.slice_id}: cases_failed ({self.cases_failed}) "
|
|
57
|
+
f"exceeds cases_run ({self.cases_run})"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def verdict(self) -> Verdict:
|
|
62
|
+
if self.cases_run == 0:
|
|
63
|
+
return Verdict.UNKNOWN
|
|
64
|
+
if self.cases_failed > 0:
|
|
65
|
+
return Verdict.FAILED
|
|
66
|
+
return Verdict.VERIFIED
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cases_passed(self) -> int:
|
|
70
|
+
return self.cases_run - self.cases_failed
|
|
71
|
+
|
|
72
|
+
def merge(self, other: SliceCoverage) -> SliceCoverage:
|
|
73
|
+
"""Combine two observations of the same slice."""
|
|
74
|
+
if (self.layer, self.slice_id) != (other.layer, other.slice_id):
|
|
75
|
+
raise ValueError("cannot merge coverage for different slices")
|
|
76
|
+
return SliceCoverage(
|
|
77
|
+
layer=self.layer,
|
|
78
|
+
slice_id=self.slice_id,
|
|
79
|
+
cases_run=self.cases_run + other.cases_run,
|
|
80
|
+
cases_failed=self.cases_failed + other.cases_failed,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class CoverageReport:
|
|
86
|
+
"""Aggregate coverage that keeps its unknowns.
|
|
87
|
+
|
|
88
|
+
``declared_layers`` is the taxonomy the author committed to up front. It is
|
|
89
|
+
what makes blind spots visible at all: without a declaration of what *should*
|
|
90
|
+
exist, an unexercised layer is indistinguishable from a layer that does not
|
|
91
|
+
exist, and no tool can tell you the difference.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
declared_layers: frozenset[str] = field(default_factory=frozenset)
|
|
95
|
+
slices: list[SliceCoverage] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
def add(self, slice_coverage: SliceCoverage) -> None:
|
|
98
|
+
"""Record a slice, merging into an existing entry if already present."""
|
|
99
|
+
key = (slice_coverage.layer, slice_coverage.slice_id)
|
|
100
|
+
for index, existing in enumerate(self.slices):
|
|
101
|
+
if (existing.layer, existing.slice_id) == key:
|
|
102
|
+
self.slices[index] = existing.merge(slice_coverage)
|
|
103
|
+
return
|
|
104
|
+
self.slices.append(slice_coverage)
|
|
105
|
+
|
|
106
|
+
# -- layer-level views -------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def exercised_layers(self) -> frozenset[str]:
|
|
109
|
+
"""Layers with at least one case that actually ran."""
|
|
110
|
+
return frozenset(s.layer for s in self.slices if s.cases_run > 0)
|
|
111
|
+
|
|
112
|
+
def unknown_layers(self) -> frozenset[str]:
|
|
113
|
+
"""Declared layers that no case ever exercised.
|
|
114
|
+
|
|
115
|
+
These are the blind spots. They are reported explicitly and are never
|
|
116
|
+
folded into a pass percentage.
|
|
117
|
+
"""
|
|
118
|
+
return self.declared_layers - self.exercised_layers()
|
|
119
|
+
|
|
120
|
+
def undeclared_layers(self) -> frozenset[str]:
|
|
121
|
+
"""Layers that produced cases but were never declared in the taxonomy.
|
|
122
|
+
|
|
123
|
+
Usually a typo in a layer name, or drift between the taxonomy and the
|
|
124
|
+
tests. Either way the author should see it rather than have it silently
|
|
125
|
+
counted as coverage.
|
|
126
|
+
"""
|
|
127
|
+
return self.exercised_layers() - self.declared_layers
|
|
128
|
+
|
|
129
|
+
def layer_verdict(self, layer: str) -> Verdict:
|
|
130
|
+
"""Worst-case verdict across a layer's slices.
|
|
131
|
+
|
|
132
|
+
A layer is only ``VERIFIED`` when every one of its slices ran clean. One
|
|
133
|
+
failing slice makes the layer ``FAILED``; no cases at all make it
|
|
134
|
+
``UNKNOWN``.
|
|
135
|
+
"""
|
|
136
|
+
layer_slices = [s for s in self.slices if s.layer == layer]
|
|
137
|
+
if not layer_slices or all(s.cases_run == 0 for s in layer_slices):
|
|
138
|
+
return Verdict.UNKNOWN
|
|
139
|
+
if any(s.verdict is Verdict.FAILED for s in layer_slices):
|
|
140
|
+
return Verdict.FAILED
|
|
141
|
+
return Verdict.VERIFIED
|
|
142
|
+
|
|
143
|
+
def slices_by_verdict(self, verdict: Verdict) -> list[SliceCoverage]:
|
|
144
|
+
return [s for s in self.slices if s.verdict is verdict]
|
|
145
|
+
|
|
146
|
+
# -- aggregate ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def total_cases(self) -> int:
|
|
150
|
+
return sum(s.cases_run for s in self.slices)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def total_failed(self) -> int:
|
|
154
|
+
return sum(s.cases_failed for s in self.slices)
|
|
155
|
+
|
|
156
|
+
def verified_case_rate(self) -> float | None:
|
|
157
|
+
"""Pass rate over cases that actually ran, or ``None`` if none ran.
|
|
158
|
+
|
|
159
|
+
This deliberately returns ``None`` rather than ``1.0`` for an empty run.
|
|
160
|
+
It is also *not* the headline number: it says nothing about the layers in
|
|
161
|
+
:meth:`unknown_layers`, which is exactly the number people misread.
|
|
162
|
+
"""
|
|
163
|
+
if self.total_cases == 0:
|
|
164
|
+
return None
|
|
165
|
+
return (self.total_cases - self.total_failed) / self.total_cases
|
|
166
|
+
|
|
167
|
+
def is_honest_pass(self) -> bool:
|
|
168
|
+
"""True only when nothing failed *and* nothing is unknown.
|
|
169
|
+
|
|
170
|
+
This is the gate condition. A run with unknown layers is not a pass,
|
|
171
|
+
because the suite cannot speak to those layers at all.
|
|
172
|
+
"""
|
|
173
|
+
return (
|
|
174
|
+
self.total_failed == 0
|
|
175
|
+
and not self.unknown_layers()
|
|
176
|
+
and not self.undeclared_layers()
|
|
177
|
+
and self.total_cases > 0
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> dict:
|
|
181
|
+
"""Machine-readable form, for CI artifacts and the alignment engine."""
|
|
182
|
+
return {
|
|
183
|
+
"declared_layers": sorted(self.declared_layers),
|
|
184
|
+
"unknown_layers": sorted(self.unknown_layers()),
|
|
185
|
+
"undeclared_layers": sorted(self.undeclared_layers()),
|
|
186
|
+
"total_cases": self.total_cases,
|
|
187
|
+
"total_failed": self.total_failed,
|
|
188
|
+
"verified_case_rate": self.verified_case_rate(),
|
|
189
|
+
"honest_pass": self.is_honest_pass(),
|
|
190
|
+
"slices": [
|
|
191
|
+
{
|
|
192
|
+
"layer": s.layer,
|
|
193
|
+
"slice_id": s.slice_id,
|
|
194
|
+
"cases_run": s.cases_run,
|
|
195
|
+
"cases_failed": s.cases_failed,
|
|
196
|
+
"verdict": s.verdict.value,
|
|
197
|
+
}
|
|
198
|
+
for s in sorted(self.slices, key=lambda s: (s.layer, s.slice_id))
|
|
199
|
+
],
|
|
200
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""LangGraph-specific structural coverage.
|
|
2
|
+
|
|
3
|
+
Separate from the framework-agnostic core (``nullcov.coverage``,
|
|
4
|
+
``nullcov.alignment``) on purpose: this module reads ground truth out of a
|
|
5
|
+
compiled ``langgraph`` graph object, which is inherently framework-specific.
|
|
6
|
+
Import it only if you have ``langgraph`` installed (``pip install
|
|
7
|
+
nullcov[langgraph]``); the rest of the package does not depend on it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .topology import (
|
|
11
|
+
Branch,
|
|
12
|
+
BranchCoverage,
|
|
13
|
+
CoverageAccumulator,
|
|
14
|
+
GraphTopology,
|
|
15
|
+
RunTrace,
|
|
16
|
+
trace_run,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Branch",
|
|
21
|
+
"BranchCoverage",
|
|
22
|
+
"CoverageAccumulator",
|
|
23
|
+
"GraphTopology",
|
|
24
|
+
"RunTrace",
|
|
25
|
+
"trace_run",
|
|
26
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Structural coverage for LangGraph state machines.
|
|
2
|
+
|
|
3
|
+
Every other layer in this library depends on a human typing a truthful string:
|
|
4
|
+
``@nullcov.case(layer="escalation", ...)``. Nothing stops that string from
|
|
5
|
+
drifting from the code -- a developer adds a node to the graph, forgets to add
|
|
6
|
+
a case for it, and it is invisible everywhere: not in the declared taxonomy, not
|
|
7
|
+
in the executed cases, not reported as anything at all. It is not even a
|
|
8
|
+
``[????]`` -- it just does not exist as far as nullcov is concerned.
|
|
9
|
+
|
|
10
|
+
This module removes the human from that step for one specific, checkable claim:
|
|
11
|
+
which nodes and which conditional branches of a *compiled LangGraph graph* were
|
|
12
|
+
actually traversed by the test suite, aggregated across every recorded run.
|
|
13
|
+
|
|
14
|
+
The ground truth is the compiled graph object itself, via
|
|
15
|
+
``CompiledGraph.get_graph()`` -- not a file a developer maintains by hand. A new
|
|
16
|
+
node or a new conditional destination is a blind spot the moment it compiles,
|
|
17
|
+
before anyone writes a test for it, because the topology is read from the
|
|
18
|
+
artifact under test rather than declared about it.
|
|
19
|
+
|
|
20
|
+
This is deliberately narrow. It answers one question precisely -- "of all the
|
|
21
|
+
routing decisions this graph can make, which ones has any test ever forced it
|
|
22
|
+
to make?" -- and answers it exactly, with no LLM judgment involved. It does not
|
|
23
|
+
replace the taxonomy/pure-mode/alignment machinery elsewhere in this package;
|
|
24
|
+
it is a second, independent source of ground truth for the one framework where
|
|
25
|
+
that ground truth happens to be mechanically extractable.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any, Protocol
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Branch",
|
|
35
|
+
"GraphTopology",
|
|
36
|
+
"RunTrace",
|
|
37
|
+
"trace_run",
|
|
38
|
+
"BranchCoverage",
|
|
39
|
+
"CoverageAccumulator",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_START = "__start__"
|
|
43
|
+
_END = "__end__"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _StreamableGraph(Protocol):
|
|
47
|
+
def get_graph(self) -> Any: ...
|
|
48
|
+
def stream(self, input: Any, stream_mode: str) -> Any: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Branch:
|
|
53
|
+
"""One possible transition out of a conditional routing node."""
|
|
54
|
+
|
|
55
|
+
source: str
|
|
56
|
+
target: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class GraphTopology:
|
|
61
|
+
"""The routing structure of a compiled graph, as it actually compiled.
|
|
62
|
+
|
|
63
|
+
``__start__`` and ``__end__`` are LangGraph's own bookkeeping nodes, not
|
|
64
|
+
agent logic, and are excluded from both ``nodes`` and ``branches`` -- there
|
|
65
|
+
is no meaningful sense in which the entry point is "undertested".
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
nodes: frozenset[str]
|
|
69
|
+
branches: frozenset[Branch]
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def extract(cls, compiled_graph: _StreamableGraph) -> GraphTopology:
|
|
73
|
+
"""Read the topology straight off a compiled graph.
|
|
74
|
+
|
|
75
|
+
Every outgoing edge from a node with more than one destination is
|
|
76
|
+
treated as a conditional branch -- this covers LangGraph's own
|
|
77
|
+
``conditional=True`` marking, and also catches a node that fans out to
|
|
78
|
+
multiple unconditional edges, which is the same coverage question in
|
|
79
|
+
different syntax.
|
|
80
|
+
"""
|
|
81
|
+
graph = compiled_graph.get_graph()
|
|
82
|
+
|
|
83
|
+
nodes = frozenset(
|
|
84
|
+
name for name in graph.nodes if name not in (_START, _END)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
destinations_by_source: dict[str, set[str]] = {}
|
|
88
|
+
for edge in graph.edges:
|
|
89
|
+
if edge.source == _START:
|
|
90
|
+
continue
|
|
91
|
+
destinations_by_source.setdefault(edge.source, set()).add(edge.target)
|
|
92
|
+
|
|
93
|
+
branches: set[Branch] = set()
|
|
94
|
+
for source, destinations in destinations_by_source.items():
|
|
95
|
+
if len(destinations) > 1:
|
|
96
|
+
branches.update(Branch(source, target) for target in destinations)
|
|
97
|
+
|
|
98
|
+
return cls(nodes=nodes, branches=branches)
|
|
99
|
+
|
|
100
|
+
def branches_from(self, source: str) -> frozenset[Branch]:
|
|
101
|
+
return frozenset(b for b in self.branches if b.source == source)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def branching_nodes(self) -> frozenset[str]:
|
|
105
|
+
return frozenset(b.source for b in self.branches)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class RunTrace:
|
|
110
|
+
"""The path one execution actually took."""
|
|
111
|
+
|
|
112
|
+
nodes_visited: tuple[str, ...]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def edges_taken(self) -> frozenset[Branch]:
|
|
116
|
+
return frozenset(
|
|
117
|
+
Branch(a, b) for a, b in zip(self.nodes_visited, self.nodes_visited[1:])
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def trace_run(compiled_graph: _StreamableGraph, input: Any) -> RunTrace:
|
|
122
|
+
"""Run a graph once and record exactly which nodes it visited, in order.
|
|
123
|
+
|
|
124
|
+
Uses ``stream_mode="updates"``, which yields one ``{node_name: delta}``
|
|
125
|
+
chunk per step -- the same mechanism LangGraph itself uses to report
|
|
126
|
+
progress, so this adds no instrumentation of its own that could drift from
|
|
127
|
+
what actually executed.
|
|
128
|
+
"""
|
|
129
|
+
visited: list[str] = []
|
|
130
|
+
for chunk in compiled_graph.stream(input, stream_mode="updates"):
|
|
131
|
+
visited.extend(chunk.keys())
|
|
132
|
+
return RunTrace(nodes_visited=tuple(visited))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class BranchCoverage:
|
|
137
|
+
"""Coverage of one graph's topology across every recorded run."""
|
|
138
|
+
|
|
139
|
+
topology: GraphTopology
|
|
140
|
+
nodes_visited: frozenset[str]
|
|
141
|
+
branches_taken: frozenset[Branch]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def unvisited_nodes(self) -> frozenset[str]:
|
|
145
|
+
return self.topology.nodes - self.nodes_visited
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def untaken_branches(self) -> frozenset[Branch]:
|
|
149
|
+
return self.topology.branches - self.branches_taken
|
|
150
|
+
|
|
151
|
+
def branch_rate(self) -> float | None:
|
|
152
|
+
total = len(self.topology.branches)
|
|
153
|
+
if total == 0:
|
|
154
|
+
return None
|
|
155
|
+
return len(self.branches_taken) / total
|
|
156
|
+
|
|
157
|
+
def is_fully_covered(self) -> bool:
|
|
158
|
+
return not self.unvisited_nodes and not self.untaken_branches
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> dict:
|
|
161
|
+
return {
|
|
162
|
+
"nodes_total": sorted(self.topology.nodes),
|
|
163
|
+
"nodes_visited": sorted(self.nodes_visited),
|
|
164
|
+
"unvisited_nodes": sorted(self.unvisited_nodes),
|
|
165
|
+
"branches_total": [
|
|
166
|
+
{"source": b.source, "target": b.target}
|
|
167
|
+
for b in sorted(self.topology.branches, key=lambda b: (b.source, b.target))
|
|
168
|
+
],
|
|
169
|
+
"untaken_branches": [
|
|
170
|
+
{"source": b.source, "target": b.target}
|
|
171
|
+
for b in sorted(self.untaken_branches, key=lambda b: (b.source, b.target))
|
|
172
|
+
],
|
|
173
|
+
"branch_rate": self.branch_rate(),
|
|
174
|
+
"fully_covered": self.is_fully_covered(),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class CoverageAccumulator:
|
|
180
|
+
"""Collects traces from many test cases against one topology.
|
|
181
|
+
|
|
182
|
+
One accumulator per graph, shared across a whole test session (a
|
|
183
|
+
session-scoped pytest fixture, in practice) -- coverage is a property of
|
|
184
|
+
the *suite*, not of any single test.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
topology: GraphTopology
|
|
188
|
+
_visited: set[str] = field(default_factory=set)
|
|
189
|
+
_taken: set[Branch] = field(default_factory=set)
|
|
190
|
+
runs_recorded: int = 0
|
|
191
|
+
|
|
192
|
+
def record(self, trace: RunTrace) -> None:
|
|
193
|
+
self._visited.update(trace.nodes_visited)
|
|
194
|
+
self._taken.update(trace.edges_taken & self.topology.branches)
|
|
195
|
+
self.runs_recorded += 1
|
|
196
|
+
|
|
197
|
+
def record_run(self, compiled_graph: _StreamableGraph, input: Any) -> RunTrace:
|
|
198
|
+
"""Run the graph and record the trace in one step."""
|
|
199
|
+
trace = trace_run(compiled_graph, input)
|
|
200
|
+
self.record(trace)
|
|
201
|
+
return trace
|
|
202
|
+
|
|
203
|
+
def coverage(self) -> BranchCoverage:
|
|
204
|
+
return BranchCoverage(
|
|
205
|
+
topology=self.topology,
|
|
206
|
+
nodes_visited=frozenset(self._visited) & self.topology.nodes,
|
|
207
|
+
branches_taken=frozenset(self._taken),
|
|
208
|
+
)
|