nullcov 0.1.0__tar.gz

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,61 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install
24
+ run: pip install -e ".[dev]"
25
+
26
+ # nullcov gates its own suite. An unexercised declared layer fails the
27
+ # build, which is the behaviour the library exists to produce.
28
+ - name: Test
29
+ run: pytest --nullcov-strict --nullcov-json nullcov-report.json
30
+
31
+ - name: Upload coverage report
32
+ if: always()
33
+ uses: actions/upload-artifact@v4
34
+ with:
35
+ name: nullcov-report-py${{ matrix.python-version }}
36
+ path: nullcov-report.json
37
+ if-no-files-found: ignore
38
+
39
+ example:
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+
44
+ - uses: actions/setup-python@v5
45
+ with:
46
+ python-version: "3.12"
47
+
48
+ - name: Install
49
+ run: pip install -e ".[dev]"
50
+
51
+ # The example is deliberately incomplete: it has an untested escalation
52
+ # layer and a real boundary bug behind it. Strict mode is expected to
53
+ # fail here, so a passing run would mean the gate stopped working.
54
+ - name: Example demonstrates the gate
55
+ working-directory: examples/refund_agent
56
+ run: |
57
+ if pytest --nullcov-strict --nullcov-incidents incidents.json; then
58
+ echo "::error::The example was expected to fail the nullcov gate."
59
+ exit 1
60
+ fi
61
+ echo "Gate correctly rejected the run."
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ nullcov-report.json
14
+ .DS_Store
15
+ .idea/
16
+ .vscode/
nullcov-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nullcov-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,270 @@
1
+ Metadata-Version: 2.5
2
+ Name: nullcov
3
+ Version: 0.1.0
4
+ Summary: Honest coverage for agent scaffolds: unexercised layers report unknown, never 100%.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Shay
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Keywords: agents,coverage,evaluation,llm,pytest,testing
28
+ Classifier: Development Status :: 3 - Alpha
29
+ Classifier: Framework :: Pytest
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Classifier: Programming Language :: Python :: 3.13
35
+ Classifier: Topic :: Software Development :: Testing
36
+ Requires-Python: >=3.11
37
+ Requires-Dist: pytest>=8.0
38
+ Provides-Extra: dev
39
+ Requires-Dist: langgraph>=0.2; extra == 'dev'
40
+ Requires-Dist: pytest>=8.0; extra == 'dev'
41
+ Provides-Extra: langgraph
42
+ Requires-Dist: langgraph>=0.2; extra == 'langgraph'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # nullcov
46
+
47
+ **Your agent test suite reports 94%. This tells you which part of that number is a lie.**
48
+
49
+ Conventional coverage divides passing cases by total cases. A layer of your agent
50
+ that nobody wrote a case for contributes nothing to either side of that division —
51
+ so it vanishes from the summary entirely. The suite looks strongest exactly where
52
+ it is blindest.
53
+
54
+ nullcov reports those layers as `unknown`. Never `100%`, never silently dropped.
55
+
56
+ ```
57
+ ============================= nullcov ==============================
58
+ [ok ] routing 3 slice(s), 41 case(s)
59
+ [ok ] schema 2 slice(s), 18 case(s)
60
+ [????] escalation no cases -- coverage unknown, not 100%
61
+ [FAIL] safety 1 slice(s), 6 case(s), 1 failing
62
+ [----] reasoning generative -- not scored, by design
63
+
64
+ 65 case(s) ran, 1 failing (98.5% of executed cases passed).
65
+ 1 declared layer(s) have no coverage. The number above says nothing about them.
66
+ ```
67
+
68
+ ## Why this exists
69
+
70
+ In a June 2026 survey of 157 enterprises (VentureBeat VB Pulse), **half had shipped
71
+ an agent that passed their own evaluations and still failed in front of a customer.**
72
+ Only 5% fully trusted the evaluations they were shipping on. When asked what was
73
+ wrong with those evaluations, the most common answer was not "not enough coverage" —
74
+ it was **poor alignment with real-world outcomes**.
75
+
76
+ The same pattern shows up in the research. A production agent team
77
+ ([arXiv:2606.11686](https://arxiv.org/abs/2606.11686)) documented a confirmation-gate
78
+ bug that caused roughly half of confirmed orders to silently not execute — while
79
+ their aggregate quality metric barely moved. Under controlled fault injection,
80
+ end-to-end pass rates dropped 1.7–5.9% while layer-isolated slices dropped 25–91%.
81
+
82
+ One aggregate number hides a broken layer. That is the problem nullcov is built for.
83
+
84
+ ## Install
85
+
86
+ ```bash
87
+ pip install nullcov
88
+ ```
89
+
90
+ ## Use
91
+
92
+ **1. Declare your layers.** In `pyproject.toml` (or a `nullcov.toml`):
93
+
94
+ ```toml
95
+ [tool.nullcov]
96
+ layers = ["routing", "schema", "escalation", "safety"]
97
+ ```
98
+
99
+ This declaration is the point. Without a statement of what *should* exist, an
100
+ untested layer is indistinguishable from a layer that does not exist, and no tool
101
+ can tell you the difference. Keeping it in version control means deleting a layer
102
+ to make a report go green is a diff a reviewer sees — not a number that quietly
103
+ improves.
104
+
105
+ Layers that are genuinely generative are declared as such and are never scored:
106
+
107
+ ```toml
108
+ [tool.nullcov.layers]
109
+ routing = { description = "picks the next node" }
110
+ reasoning = { deterministic = false }
111
+ ```
112
+
113
+ **2. Attach tests to layers.**
114
+
115
+ ```python
116
+ import nullcov
117
+
118
+ @nullcov.case(layer="escalation", slice_id="high_value_requires_human")
119
+ def test_high_value_transactions_go_to_a_human():
120
+ with nullcov.pure_mode():
121
+ result = run_scaffold(graph, {"amount": 1200})
122
+ assert result.next_node == "human_review"
123
+ ```
124
+
125
+ **3. Run.**
126
+
127
+ ```bash
128
+ pytest --nullcov
129
+ ```
130
+
131
+ Add `--nullcov-strict` to make it a CI gate. An unexercised declared layer fails
132
+ the run, because a suite that cannot speak to a layer has not verified it.
133
+
134
+ ## Pure mode
135
+
136
+ `pure_mode()` blocks outbound network connections at the socket layer for the
137
+ duration of a block. It is not a stub you have to remember to install — it holds
138
+ regardless of which SDK the agent reaches for, and raises `PureModeViolation`
139
+ naming the host if anything tries to escape.
140
+
141
+ ```python
142
+ with nullcov.pure_mode() as report:
143
+ result = graph.invoke(state)
144
+ assert report.clean
145
+ ```
146
+
147
+ A test that passes under pure mode **provably** made zero model calls. Not by
148
+ convention, not because someone remembered to patch the client — because nothing
149
+ could reach the network. That makes it deterministic, free, and fast enough to run
150
+ on every commit. Loopback stays open, so local fixtures and recorded-cassette
151
+ servers still work.
152
+
153
+ ## Reality alignment
154
+
155
+ Coverage tells you what you looked at. It cannot tell you whether you looked at the
156
+ right thing. Feed nullcov your production incidents and it separates two failures
157
+ that look identical on a dashboard:
158
+
159
+ ```bash
160
+ pytest --nullcov --nullcov-incidents incidents.json
161
+ ```
162
+
163
+ ```
164
+ reality alignment
165
+ 29 attributed incident(s).
166
+
167
+ Blind spots -- production fails here and no case looks:
168
+ escalation: 11 incidents (38% of all attributed) and no coverage at all.
169
+ The suite cannot speak to this layer.
170
+
171
+ Misaligned -- cases pass here and production fails anyway:
172
+ routing: 9 incidents (31% of all attributed) despite 41 passing cases.
173
+ The cases pass and production still fails -- distrust these cases before
174
+ adding more.
175
+ ```
176
+
177
+ **Blind spot**: no coverage. Write cases.
178
+
179
+ **Misalignment**: full, passing coverage — and production fails there anyway. More
180
+ cases of the same shape make this worse, not better. The existing cases are the
181
+ thing to distrust. This is the finding that explains a green pipeline and a
182
+ customer-visible outage on the same afternoon, and it is the reason this tool is
183
+ not just another eval runner.
184
+
185
+ `incidents.json` is a list of records — an export from your incident tracker, or a
186
+ handful of postmortem entries:
187
+
188
+ ```json
189
+ [
190
+ {"incident_id": "INC-4471", "layer": "escalation", "severity": "sev2",
191
+ "summary": "refund over threshold auto-approved"}
192
+ ]
193
+ ```
194
+
195
+ Attribution is the one input nullcov cannot derive for itself, and the quality of
196
+ everything above depends on it.
197
+
198
+ ## Structural coverage for LangGraph
199
+
200
+ Everything above depends on a human typing a truthful layer name. Nothing stops
201
+ that from drifting: add a node to the graph, forget to tag a test for it, and
202
+ it is invisible everywhere -- not `[????]`, not undeclared, just absent.
203
+
204
+ `nullcov.langgraph` removes the human from that specific step. It reads the
205
+ topology straight off your **compiled** graph object -- the actual nodes and
206
+ conditional branches LangGraph built, not a file you maintain by hand -- and
207
+ tells you which of them any test run ever actually traversed.
208
+
209
+ ```python
210
+ from nullcov.langgraph import CoverageAccumulator, GraphTopology
211
+
212
+ graph = build_graph() # your compiled LangGraph graph
213
+ accumulator = CoverageAccumulator(topology=GraphTopology.extract(graph))
214
+
215
+ # in your tests, run the graph through the accumulator instead of graph.invoke()
216
+ accumulator.record_run(graph, {"amount": 50, "reason": "wrong size"})
217
+ accumulator.record_run(graph, {"amount": 600, "reason": "wrong size"})
218
+
219
+ coverage = accumulator.coverage()
220
+ print(coverage.branch_rate()) # 0.5
221
+ print(coverage.untaken_branches) # {Branch("validate", "reject")}
222
+ print(coverage.unvisited_nodes) # {"reject"}
223
+ ```
224
+
225
+ Run that against the example agent in this repo and it finds a real gap the
226
+ manual layer system misses entirely: `validate()` has a passing unit test for
227
+ an invalid payload, and `reject` is a fully-implemented node — but no test ever
228
+ runs the **compiled graph** end-to-end with a bad input, so the
229
+ `validate → reject` transition has never once fired in the test suite. A unit
230
+ test on the routing function proves the function is correct. It proves nothing
231
+ about whether the graph, as wired, ever takes that path.
232
+
233
+ We checked before building this: neither AWS Bedrock AgentCore Evaluations nor
234
+ `fasteval-langgraph` (Intuit) compute this. Both score individual test cases
235
+ against a trajectory the developer writes by hand — a real capability, but a
236
+ different one. Neither aggregates, across a whole suite, what fraction of the
237
+ graph's actual branch topology was ever exercised. `nullcov.langgraph` is
238
+ narrower than either of them and answers a question they don't ask.
239
+
240
+ Requires `langgraph` (`pip install nullcov[langgraph]`). This module is
241
+ LangGraph-specific by design — the ground truth it reads only exists because
242
+ `CompiledGraph.get_graph()` exposes it. CrewAI and AutoGen would each need
243
+ their own adapter reading their own internal representation; none exists yet.
244
+
245
+ ## What this does not do
246
+
247
+ - **It does not score generative output.** LLM-as-judge is not statistically
248
+ calibrated — position, verbosity and self-enhancement bias are documented, and
249
+ calibrating judge confidence needs logprobs most providers do not expose. nullcov
250
+ declines to put a number on free-form reasoning rather than pretending.
251
+ - **It does not prove your agent is correct.** It proves which contracts held and,
252
+ more usefully, which ones nobody checked.
253
+ - **It does not replace your eval suite.** It tells you where that suite is blind.
254
+
255
+ ## Prior art
256
+
257
+ nullcov leans on published work rather than inventing around it. The
258
+ scaffold/generative split and the coverage-honesty criterion come from
259
+ [arXiv:2606.11686](https://arxiv.org/abs/2606.11686). Deterministic record-and-replay
260
+ of agent I/O is solved by [agrepl](https://arxiv.org/abs/2607.16200) and
261
+ `vcr-langchain`; multi-agent tracing by
262
+ [MAESTRO](https://github.com/sands-lab/maestro). Signed action receipts exist in
263
+ `nobulex` and `protect-mcp`. If you need those, use those.
264
+
265
+ What nullcov adds is the honesty constraint on the aggregate, and the alignment
266
+ pass against production.
267
+
268
+ ## License
269
+
270
+ MIT
@@ -0,0 +1,226 @@
1
+ # nullcov
2
+
3
+ **Your agent test suite reports 94%. This tells you which part of that number is a lie.**
4
+
5
+ Conventional coverage divides passing cases by total cases. A layer of your agent
6
+ that nobody wrote a case for contributes nothing to either side of that division —
7
+ so it vanishes from the summary entirely. The suite looks strongest exactly where
8
+ it is blindest.
9
+
10
+ nullcov reports those layers as `unknown`. Never `100%`, never silently dropped.
11
+
12
+ ```
13
+ ============================= nullcov ==============================
14
+ [ok ] routing 3 slice(s), 41 case(s)
15
+ [ok ] schema 2 slice(s), 18 case(s)
16
+ [????] escalation no cases -- coverage unknown, not 100%
17
+ [FAIL] safety 1 slice(s), 6 case(s), 1 failing
18
+ [----] reasoning generative -- not scored, by design
19
+
20
+ 65 case(s) ran, 1 failing (98.5% of executed cases passed).
21
+ 1 declared layer(s) have no coverage. The number above says nothing about them.
22
+ ```
23
+
24
+ ## Why this exists
25
+
26
+ In a June 2026 survey of 157 enterprises (VentureBeat VB Pulse), **half had shipped
27
+ an agent that passed their own evaluations and still failed in front of a customer.**
28
+ Only 5% fully trusted the evaluations they were shipping on. When asked what was
29
+ wrong with those evaluations, the most common answer was not "not enough coverage" —
30
+ it was **poor alignment with real-world outcomes**.
31
+
32
+ The same pattern shows up in the research. A production agent team
33
+ ([arXiv:2606.11686](https://arxiv.org/abs/2606.11686)) documented a confirmation-gate
34
+ bug that caused roughly half of confirmed orders to silently not execute — while
35
+ their aggregate quality metric barely moved. Under controlled fault injection,
36
+ end-to-end pass rates dropped 1.7–5.9% while layer-isolated slices dropped 25–91%.
37
+
38
+ One aggregate number hides a broken layer. That is the problem nullcov is built for.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install nullcov
44
+ ```
45
+
46
+ ## Use
47
+
48
+ **1. Declare your layers.** In `pyproject.toml` (or a `nullcov.toml`):
49
+
50
+ ```toml
51
+ [tool.nullcov]
52
+ layers = ["routing", "schema", "escalation", "safety"]
53
+ ```
54
+
55
+ This declaration is the point. Without a statement of what *should* exist, an
56
+ untested layer is indistinguishable from a layer that does not exist, and no tool
57
+ can tell you the difference. Keeping it in version control means deleting a layer
58
+ to make a report go green is a diff a reviewer sees — not a number that quietly
59
+ improves.
60
+
61
+ Layers that are genuinely generative are declared as such and are never scored:
62
+
63
+ ```toml
64
+ [tool.nullcov.layers]
65
+ routing = { description = "picks the next node" }
66
+ reasoning = { deterministic = false }
67
+ ```
68
+
69
+ **2. Attach tests to layers.**
70
+
71
+ ```python
72
+ import nullcov
73
+
74
+ @nullcov.case(layer="escalation", slice_id="high_value_requires_human")
75
+ def test_high_value_transactions_go_to_a_human():
76
+ with nullcov.pure_mode():
77
+ result = run_scaffold(graph, {"amount": 1200})
78
+ assert result.next_node == "human_review"
79
+ ```
80
+
81
+ **3. Run.**
82
+
83
+ ```bash
84
+ pytest --nullcov
85
+ ```
86
+
87
+ Add `--nullcov-strict` to make it a CI gate. An unexercised declared layer fails
88
+ the run, because a suite that cannot speak to a layer has not verified it.
89
+
90
+ ## Pure mode
91
+
92
+ `pure_mode()` blocks outbound network connections at the socket layer for the
93
+ duration of a block. It is not a stub you have to remember to install — it holds
94
+ regardless of which SDK the agent reaches for, and raises `PureModeViolation`
95
+ naming the host if anything tries to escape.
96
+
97
+ ```python
98
+ with nullcov.pure_mode() as report:
99
+ result = graph.invoke(state)
100
+ assert report.clean
101
+ ```
102
+
103
+ A test that passes under pure mode **provably** made zero model calls. Not by
104
+ convention, not because someone remembered to patch the client — because nothing
105
+ could reach the network. That makes it deterministic, free, and fast enough to run
106
+ on every commit. Loopback stays open, so local fixtures and recorded-cassette
107
+ servers still work.
108
+
109
+ ## Reality alignment
110
+
111
+ Coverage tells you what you looked at. It cannot tell you whether you looked at the
112
+ right thing. Feed nullcov your production incidents and it separates two failures
113
+ that look identical on a dashboard:
114
+
115
+ ```bash
116
+ pytest --nullcov --nullcov-incidents incidents.json
117
+ ```
118
+
119
+ ```
120
+ reality alignment
121
+ 29 attributed incident(s).
122
+
123
+ Blind spots -- production fails here and no case looks:
124
+ escalation: 11 incidents (38% of all attributed) and no coverage at all.
125
+ The suite cannot speak to this layer.
126
+
127
+ Misaligned -- cases pass here and production fails anyway:
128
+ routing: 9 incidents (31% of all attributed) despite 41 passing cases.
129
+ The cases pass and production still fails -- distrust these cases before
130
+ adding more.
131
+ ```
132
+
133
+ **Blind spot**: no coverage. Write cases.
134
+
135
+ **Misalignment**: full, passing coverage — and production fails there anyway. More
136
+ cases of the same shape make this worse, not better. The existing cases are the
137
+ thing to distrust. This is the finding that explains a green pipeline and a
138
+ customer-visible outage on the same afternoon, and it is the reason this tool is
139
+ not just another eval runner.
140
+
141
+ `incidents.json` is a list of records — an export from your incident tracker, or a
142
+ handful of postmortem entries:
143
+
144
+ ```json
145
+ [
146
+ {"incident_id": "INC-4471", "layer": "escalation", "severity": "sev2",
147
+ "summary": "refund over threshold auto-approved"}
148
+ ]
149
+ ```
150
+
151
+ Attribution is the one input nullcov cannot derive for itself, and the quality of
152
+ everything above depends on it.
153
+
154
+ ## Structural coverage for LangGraph
155
+
156
+ Everything above depends on a human typing a truthful layer name. Nothing stops
157
+ that from drifting: add a node to the graph, forget to tag a test for it, and
158
+ it is invisible everywhere -- not `[????]`, not undeclared, just absent.
159
+
160
+ `nullcov.langgraph` removes the human from that specific step. It reads the
161
+ topology straight off your **compiled** graph object -- the actual nodes and
162
+ conditional branches LangGraph built, not a file you maintain by hand -- and
163
+ tells you which of them any test run ever actually traversed.
164
+
165
+ ```python
166
+ from nullcov.langgraph import CoverageAccumulator, GraphTopology
167
+
168
+ graph = build_graph() # your compiled LangGraph graph
169
+ accumulator = CoverageAccumulator(topology=GraphTopology.extract(graph))
170
+
171
+ # in your tests, run the graph through the accumulator instead of graph.invoke()
172
+ accumulator.record_run(graph, {"amount": 50, "reason": "wrong size"})
173
+ accumulator.record_run(graph, {"amount": 600, "reason": "wrong size"})
174
+
175
+ coverage = accumulator.coverage()
176
+ print(coverage.branch_rate()) # 0.5
177
+ print(coverage.untaken_branches) # {Branch("validate", "reject")}
178
+ print(coverage.unvisited_nodes) # {"reject"}
179
+ ```
180
+
181
+ Run that against the example agent in this repo and it finds a real gap the
182
+ manual layer system misses entirely: `validate()` has a passing unit test for
183
+ an invalid payload, and `reject` is a fully-implemented node — but no test ever
184
+ runs the **compiled graph** end-to-end with a bad input, so the
185
+ `validate → reject` transition has never once fired in the test suite. A unit
186
+ test on the routing function proves the function is correct. It proves nothing
187
+ about whether the graph, as wired, ever takes that path.
188
+
189
+ We checked before building this: neither AWS Bedrock AgentCore Evaluations nor
190
+ `fasteval-langgraph` (Intuit) compute this. Both score individual test cases
191
+ against a trajectory the developer writes by hand — a real capability, but a
192
+ different one. Neither aggregates, across a whole suite, what fraction of the
193
+ graph's actual branch topology was ever exercised. `nullcov.langgraph` is
194
+ narrower than either of them and answers a question they don't ask.
195
+
196
+ Requires `langgraph` (`pip install nullcov[langgraph]`). This module is
197
+ LangGraph-specific by design — the ground truth it reads only exists because
198
+ `CompiledGraph.get_graph()` exposes it. CrewAI and AutoGen would each need
199
+ their own adapter reading their own internal representation; none exists yet.
200
+
201
+ ## What this does not do
202
+
203
+ - **It does not score generative output.** LLM-as-judge is not statistically
204
+ calibrated — position, verbosity and self-enhancement bias are documented, and
205
+ calibrating judge confidence needs logprobs most providers do not expose. nullcov
206
+ declines to put a number on free-form reasoning rather than pretending.
207
+ - **It does not prove your agent is correct.** It proves which contracts held and,
208
+ more usefully, which ones nobody checked.
209
+ - **It does not replace your eval suite.** It tells you where that suite is blind.
210
+
211
+ ## Prior art
212
+
213
+ nullcov leans on published work rather than inventing around it. The
214
+ scaffold/generative split and the coverage-honesty criterion come from
215
+ [arXiv:2606.11686](https://arxiv.org/abs/2606.11686). Deterministic record-and-replay
216
+ of agent I/O is solved by [agrepl](https://arxiv.org/abs/2607.16200) and
217
+ `vcr-langchain`; multi-agent tracing by
218
+ [MAESTRO](https://github.com/sands-lab/maestro). Signed action receipts exist in
219
+ `nobulex` and `protect-mcp`. If you need those, use those.
220
+
221
+ What nullcov adds is the honesty constraint on the aggregate, and the alignment
222
+ pass against production.
223
+
224
+ ## License
225
+
226
+ MIT
@@ -0,0 +1,82 @@
1
+ # Example: a refund agent with a blind spot
2
+
3
+ A small LangGraph refund agent, a test suite a competent team would plausibly
4
+ write for it, and a real bug that the suite cannot see.
5
+
6
+ ## Run it
7
+
8
+ ```bash
9
+ pytest --nullcov --nullcov-incidents incidents.json
10
+ ```
11
+
12
+ ```
13
+ [????] escalation no cases -- coverage unknown, not 100%
14
+ [ok ] routing 1 slice(s), 2 case(s)
15
+ [ok ] safety 1 slice(s), 2 case(s)
16
+ [ok ] schema 2 slice(s), 5 case(s)
17
+ [----] summary generative -- not scored, by design
18
+
19
+ 9 case(s) ran, 0 failing (100.0% of executed cases passed).
20
+ 1 declared layer(s) have no coverage. The number above says nothing about them.
21
+
22
+ reality alignment
23
+ 6 attributed incident(s).
24
+
25
+ Blind spots -- production fails here and no case looks:
26
+ escalation: 4 incidents (67% of all attributed) and no coverage at all.
27
+ The suite cannot speak to this layer.
28
+
29
+ Misaligned -- cases pass here and production fails anyway:
30
+ routing: 1 incidents (17% of all attributed) despite 2 passing cases.
31
+ safety: 1 incidents (17% of all attributed) despite 2 passing cases.
32
+ ```
33
+
34
+ Every test passes. A conventional runner would print `9 passed` and stop there.
35
+
36
+ ## The bug
37
+
38
+ `decide()` in `agent.py` routes large refunds to a human:
39
+
40
+ ```python
41
+ if amount > AUTO_APPROVE_LIMIT: # should be >=
42
+ return {**state, "decision": "human_review"}
43
+ ```
44
+
45
+ Policy says the limit itself requires approval. The comparison is strictly
46
+ greater-than, so a refund of exactly £500 is auto-approved:
47
+
48
+ ```
49
+ at limit (500.0) -> auto_approve <-- policy says human_review
50
+ above (500.01) -> human_review
51
+ below (499.99) -> auto_approve
52
+ ```
53
+
54
+ An off-by-one on a boundary. End-to-end sampling almost never lands exactly on
55
+ it, and an aggregate pass rate will not notice when it does.
56
+
57
+ ## What each half of the output tells you
58
+
59
+ **The blind spot** is the escalation layer: four of six production incidents,
60
+ and not one test case. Nobody decided that layer was safe — the person writing
61
+ the suite covered what they thought of, and the boundary rule was not it. That
62
+ is what `????` means, and why it is not `100%`.
63
+
64
+ **The misalignments** are routing and safety: cases pass, production fails
65
+ anyway. Those layers *are* tested. The tests are looking at the wrong thing.
66
+ Writing more cases in the same shape will not help; the existing ones are what
67
+ to distrust.
68
+
69
+ Half of enterprises surveyed in June 2026 had shipped an agent that passed their
70
+ own evaluations and still failed in front of a customer (VentureBeat VB Pulse,
71
+ n=157). Misalignment is the name for that, and it does not show up on a
72
+ coverage percentage.
73
+
74
+ ## Fix it
75
+
76
+ Uncomment the last case in `test_refund_agent.py` and run again. The escalation
77
+ layer goes from `????` to `FAIL` — the tool stops saying "I do not know" and
78
+ starts saying "this is broken." Change `>` to `>=` in `agent.py` and it goes
79
+ green.
80
+
81
+ The point is the first transition, not the second. `????` to `FAIL` is a suite
82
+ learning to see. A conventional report never leaves `100%`.