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 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
+ )