leakprobe 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raunak Sood
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.
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: leakprobe
3
+ Version: 0.1.0
4
+ Summary: Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing.
5
+ Author: Raunak Sood
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/quantraunak/leakprobe
8
+ Project-URL: Issues, https://github.com/quantraunak/leakprobe/issues
9
+ Keywords: leakage,data-leakage,machine-learning,testing,time-series,point-in-time,feature-engineering
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pandas>=2.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.4; extra == "dev"
23
+ Requires-Dist: numpy>=1.26; extra == "dev"
24
+ Provides-Extra: examples
25
+ Requires-Dist: numpy>=1.26; extra == "examples"
26
+ Requires-Dist: openpyxl>=3.1; extra == "examples"
27
+ Dynamic: license-file
28
+
29
+ # leakprobe
30
+
31
+ [![CI](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml/badge.svg)](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/leakprobe.svg)](https://pypi.org/project/leakprobe/) [![Python](https://img.shields.io/pypi/pyversions/leakprobe.svg)](https://pypi.org/project/leakprobe/)
32
+
33
+
34
+ **Find features that read data they were never supposed to see.**
35
+
36
+ Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
37
+ something that wasn't knowable yet, nothing throws, no number looks implausible,
38
+ and your model gets better. You find out in production, if you find out at all.
39
+
40
+ `leakprobe` finds it without needing to know the right answer. It needs one
41
+ thing you already know: **a change to your data that your features must be
42
+ invariant to.**
43
+
44
+ ```bash
45
+ pip install leakprobe
46
+ ```
47
+
48
+ ```python
49
+ import leakprobe as lp
50
+
51
+ report = lp.check(
52
+ compute=build_features, # (sources) -> DataFrame of features
53
+ sources={"events": events, "tickets": tickets},
54
+ timestamps={"events": "occurred_at", "tickets": "resolved_at"},
55
+ declared={
56
+ "total_spend": ["events"],
57
+ "event_count": ["events"],
58
+ "tickets_resolved": ["tickets"],
59
+ "avg_severity": ["tickets"],
60
+ },
61
+ )
62
+ report.raise_for_leaks() # fails your test suite if anything leaked
63
+ ```
64
+
65
+ ```
66
+ 4 features x 2 sources
67
+
68
+ feature events tickets
69
+ total_spend reads it exactly 0
70
+ event_count reads it exactly 0
71
+ tickets_resolved exactly 0 reads it
72
+ avg_severity exactly 0 bypass?
73
+
74
+ No undeclared dependencies.
75
+
76
+ Declared, but did not respond to the source's clock:
77
+ - avg_severity declares tickets but did not move when its clock did -- it
78
+ reads the source without consulting availability, or the declaration is stale
79
+ ```
80
+
81
+ That last line is a real bug. `avg_severity` filters tickets on `opened_at`
82
+ instead of `resolved_at`, so tickets that were still open at scoring time leak
83
+ in — exactly the ones that predict churn. Nothing about the code looks wrong.
84
+
85
+ ## Try it on real data
86
+
87
+ ```bash
88
+ pip install leakprobe openpyxl
89
+ python examples/online_retail.py
90
+ ```
91
+
92
+ The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
93
+ customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
94
+ built as of a cutoff, two of them wrong in the two ways temporal leakage
95
+ actually happens. Neither raises. Both are caught:
96
+
97
+ ```
98
+ feature orders returns
99
+ total_spend reads it exactly 0
100
+ order_count reads it exactly 0
101
+ recency_days reads it exactly 0
102
+ return_count exactly 0 reads it
103
+ avg_unit_price bypass? exactly 0
104
+ net_spend reads it LEAK
105
+
106
+ 1 undeclared dependencies:
107
+ - net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
108
+
109
+ Declared, but did not respond to the source's clock:
110
+ - avg_unit_price declares orders but did not move when its clock did
111
+ ```
112
+
113
+ `avg_unit_price` is missing one cutoff filter, so it averages invoices that had
114
+ not happened yet. `net_spend` reaches into the returns table without declaring
115
+ it, silently inheriting that table's latency. One dropped subscript and one
116
+ undeclared read -- the two shapes this bug takes in production.
117
+
118
+ ## How it works
119
+
120
+ Four steps, and no ground truth anywhere in them.
121
+
122
+ 1. **Move when a source became knowable.** Not its values — only its
123
+ availability timestamp. `delay` pushes it later, which can only ever remove
124
+ information.
125
+ 2. **Recompute every feature.**
126
+ 3. **Compare, exactly.** A feature that genuinely cannot read that source gets
127
+ the identical input arrays through the identical code and returns bit-identical
128
+ floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
129
+ of a dependency, not a number you have to squint at.
130
+ 4. **Check what moved against what you declared.** Anything in one list and not
131
+ the other is the finding.
132
+
133
+ Two kinds of finding:
134
+
135
+ - **`leak`** — moved, but doesn't declare the source. It has a dependency you
136
+ didn't know about. In a temporal pipeline, an unknown dependency on *when*
137
+ data arrived is look-ahead.
138
+ - **`bypass?`** — declares the source but didn't move when that source's clock
139
+ did. It's reaching the data by a path that ignores availability, which is how
140
+ look-ahead usually gets in. Not a failure on its own. Worth reading.
141
+
142
+ Before any of that, `check` runs `compute` twice on untouched inputs and refuses
143
+ to continue if the two runs disagree. A nondeterministic pipeline makes every
144
+ result below it noise, so that's a hard error rather than a warning.
145
+
146
+ ## Declaring nothing
147
+
148
+ You don't have to write the `declared` map. Leave it out and every real timing
149
+ dependency is reported:
150
+
151
+ ```python
152
+ report = lp.check(compute, sources, timestamps, declared={})
153
+ for f in report.leaks:
154
+ print(f.feature, "reads", f.source)
155
+ ```
156
+
157
+ That's the fastest way to answer "what does this pipeline actually depend on"
158
+ for code you inherited, which is usually a shorter list than the author believed.
159
+
160
+ ## In CI
161
+
162
+ ```python
163
+ def test_no_temporal_leakage():
164
+ lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
165
+ ```
166
+
167
+ The declaration map becomes the thing code review argues about, which is where
168
+ that argument belongs.
169
+
170
+ ## Perturbations
171
+
172
+ | | |
173
+ |---|---|
174
+ | `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
175
+ | `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
176
+ | `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
177
+
178
+ ```python
179
+ report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
180
+ ```
181
+
182
+ ## What it will not catch
183
+
184
+ **Dependencies that don't flow through a timestamp.** The perturbation moves
185
+ availability, so it reveals as-of joins, merges on a date, and windows anchored
186
+ to one. A feature that reads a source's values with no reference to when they
187
+ arrived is invariant to it and will not be flagged. In practice this costs less
188
+ than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
189
+ catching are the detectable ones. The boundary is asserted in the test suite so
190
+ it can't quietly stop being true.
191
+
192
+ **Sources with no clock.** Pass `None` and static tables are skipped, with a
193
+ note saying dependencies on them went untested.
194
+
195
+ **Leakage across rows rather than time** — target encoding fit on the full
196
+ dataset, a scaler fit before the split. Different bug, different tool.
197
+
198
+ ## Where this came from
199
+
200
+ The technique is metamorphic testing, which software testing has used for
201
+ decades and research code almost never does. This is an extraction of a check
202
+ built for a quantitative finance pipeline, where the sources are stock prices
203
+ and regulatory filings and the question is whether a factor read an earnings
204
+ figure before it was published.
205
+
206
+ It caught a real one. A factor called `turnover_1m` was classified as
207
+ price-and-volume — it's built from volume, it lives in `price.py`, and every
208
+ human who looked at it filed it under prices. It divides by shares outstanding,
209
+ which comes off a filing. It was the only member of its group that moved when
210
+ the filing calendar shifted, while eleven genuine price factors held at exactly
211
+ zero. That measurement is written up in
212
+ [bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
213
+
214
+ ## Install
215
+
216
+ ```bash
217
+ pip install leakprobe # pandas is the only dependency
218
+ ```
219
+
220
+ Python 3.10+.
221
+
222
+ ## Licence
223
+
224
+ MIT.
@@ -0,0 +1,196 @@
1
+ # leakprobe
2
+
3
+ [![CI](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml/badge.svg)](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/leakprobe.svg)](https://pypi.org/project/leakprobe/) [![Python](https://img.shields.io/pypi/pyversions/leakprobe.svg)](https://pypi.org/project/leakprobe/)
4
+
5
+
6
+ **Find features that read data they were never supposed to see.**
7
+
8
+ Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
9
+ something that wasn't knowable yet, nothing throws, no number looks implausible,
10
+ and your model gets better. You find out in production, if you find out at all.
11
+
12
+ `leakprobe` finds it without needing to know the right answer. It needs one
13
+ thing you already know: **a change to your data that your features must be
14
+ invariant to.**
15
+
16
+ ```bash
17
+ pip install leakprobe
18
+ ```
19
+
20
+ ```python
21
+ import leakprobe as lp
22
+
23
+ report = lp.check(
24
+ compute=build_features, # (sources) -> DataFrame of features
25
+ sources={"events": events, "tickets": tickets},
26
+ timestamps={"events": "occurred_at", "tickets": "resolved_at"},
27
+ declared={
28
+ "total_spend": ["events"],
29
+ "event_count": ["events"],
30
+ "tickets_resolved": ["tickets"],
31
+ "avg_severity": ["tickets"],
32
+ },
33
+ )
34
+ report.raise_for_leaks() # fails your test suite if anything leaked
35
+ ```
36
+
37
+ ```
38
+ 4 features x 2 sources
39
+
40
+ feature events tickets
41
+ total_spend reads it exactly 0
42
+ event_count reads it exactly 0
43
+ tickets_resolved exactly 0 reads it
44
+ avg_severity exactly 0 bypass?
45
+
46
+ No undeclared dependencies.
47
+
48
+ Declared, but did not respond to the source's clock:
49
+ - avg_severity declares tickets but did not move when its clock did -- it
50
+ reads the source without consulting availability, or the declaration is stale
51
+ ```
52
+
53
+ That last line is a real bug. `avg_severity` filters tickets on `opened_at`
54
+ instead of `resolved_at`, so tickets that were still open at scoring time leak
55
+ in — exactly the ones that predict churn. Nothing about the code looks wrong.
56
+
57
+ ## Try it on real data
58
+
59
+ ```bash
60
+ pip install leakprobe openpyxl
61
+ python examples/online_retail.py
62
+ ```
63
+
64
+ The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
65
+ customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
66
+ built as of a cutoff, two of them wrong in the two ways temporal leakage
67
+ actually happens. Neither raises. Both are caught:
68
+
69
+ ```
70
+ feature orders returns
71
+ total_spend reads it exactly 0
72
+ order_count reads it exactly 0
73
+ recency_days reads it exactly 0
74
+ return_count exactly 0 reads it
75
+ avg_unit_price bypass? exactly 0
76
+ net_spend reads it LEAK
77
+
78
+ 1 undeclared dependencies:
79
+ - net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
80
+
81
+ Declared, but did not respond to the source's clock:
82
+ - avg_unit_price declares orders but did not move when its clock did
83
+ ```
84
+
85
+ `avg_unit_price` is missing one cutoff filter, so it averages invoices that had
86
+ not happened yet. `net_spend` reaches into the returns table without declaring
87
+ it, silently inheriting that table's latency. One dropped subscript and one
88
+ undeclared read -- the two shapes this bug takes in production.
89
+
90
+ ## How it works
91
+
92
+ Four steps, and no ground truth anywhere in them.
93
+
94
+ 1. **Move when a source became knowable.** Not its values — only its
95
+ availability timestamp. `delay` pushes it later, which can only ever remove
96
+ information.
97
+ 2. **Recompute every feature.**
98
+ 3. **Compare, exactly.** A feature that genuinely cannot read that source gets
99
+ the identical input arrays through the identical code and returns bit-identical
100
+ floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
101
+ of a dependency, not a number you have to squint at.
102
+ 4. **Check what moved against what you declared.** Anything in one list and not
103
+ the other is the finding.
104
+
105
+ Two kinds of finding:
106
+
107
+ - **`leak`** — moved, but doesn't declare the source. It has a dependency you
108
+ didn't know about. In a temporal pipeline, an unknown dependency on *when*
109
+ data arrived is look-ahead.
110
+ - **`bypass?`** — declares the source but didn't move when that source's clock
111
+ did. It's reaching the data by a path that ignores availability, which is how
112
+ look-ahead usually gets in. Not a failure on its own. Worth reading.
113
+
114
+ Before any of that, `check` runs `compute` twice on untouched inputs and refuses
115
+ to continue if the two runs disagree. A nondeterministic pipeline makes every
116
+ result below it noise, so that's a hard error rather than a warning.
117
+
118
+ ## Declaring nothing
119
+
120
+ You don't have to write the `declared` map. Leave it out and every real timing
121
+ dependency is reported:
122
+
123
+ ```python
124
+ report = lp.check(compute, sources, timestamps, declared={})
125
+ for f in report.leaks:
126
+ print(f.feature, "reads", f.source)
127
+ ```
128
+
129
+ That's the fastest way to answer "what does this pipeline actually depend on"
130
+ for code you inherited, which is usually a shorter list than the author believed.
131
+
132
+ ## In CI
133
+
134
+ ```python
135
+ def test_no_temporal_leakage():
136
+ lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
137
+ ```
138
+
139
+ The declaration map becomes the thing code review argues about, which is where
140
+ that argument belongs.
141
+
142
+ ## Perturbations
143
+
144
+ | | |
145
+ |---|---|
146
+ | `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
147
+ | `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
148
+ | `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
149
+
150
+ ```python
151
+ report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
152
+ ```
153
+
154
+ ## What it will not catch
155
+
156
+ **Dependencies that don't flow through a timestamp.** The perturbation moves
157
+ availability, so it reveals as-of joins, merges on a date, and windows anchored
158
+ to one. A feature that reads a source's values with no reference to when they
159
+ arrived is invariant to it and will not be flagged. In practice this costs less
160
+ than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
161
+ catching are the detectable ones. The boundary is asserted in the test suite so
162
+ it can't quietly stop being true.
163
+
164
+ **Sources with no clock.** Pass `None` and static tables are skipped, with a
165
+ note saying dependencies on them went untested.
166
+
167
+ **Leakage across rows rather than time** — target encoding fit on the full
168
+ dataset, a scaler fit before the split. Different bug, different tool.
169
+
170
+ ## Where this came from
171
+
172
+ The technique is metamorphic testing, which software testing has used for
173
+ decades and research code almost never does. This is an extraction of a check
174
+ built for a quantitative finance pipeline, where the sources are stock prices
175
+ and regulatory filings and the question is whether a factor read an earnings
176
+ figure before it was published.
177
+
178
+ It caught a real one. A factor called `turnover_1m` was classified as
179
+ price-and-volume — it's built from volume, it lives in `price.py`, and every
180
+ human who looked at it filed it under prices. It divides by shares outstanding,
181
+ which comes off a filing. It was the only member of its group that moved when
182
+ the filing calendar shifted, while eleven genuine price factors held at exactly
183
+ zero. That measurement is written up in
184
+ [bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
185
+
186
+ ## Install
187
+
188
+ ```bash
189
+ pip install leakprobe # pandas is the only dependency
190
+ ```
191
+
192
+ Python 3.10+.
193
+
194
+ ## Licence
195
+
196
+ MIT.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "leakprobe"
7
+ version = "0.1.0"
8
+ description = "Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Raunak Sood" }]
13
+ keywords = ["leakage", "data-leakage", "machine-learning", "testing", "time-series", "point-in-time", "feature-engineering"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ "Topic :: Software Development :: Testing",
22
+ ]
23
+ dependencies = ["pandas>=2.0"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=7.4", "numpy>=1.26"]
27
+ examples = ["numpy>=1.26", "openpyxl>=3.1"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/quantraunak/leakprobe"
31
+ Issues = "https://github.com/quantraunak/leakprobe/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ """leakprobe -- find features that read data they were never supposed to see.
2
+
3
+ import leakprobe as lc
4
+
5
+ report = lc.check(
6
+ compute=build_features,
7
+ sources={"events": events, "profiles": profiles},
8
+ timestamps={"events": "occurred_at", "profiles": "updated_at"},
9
+ declared={"rolling_7d_spend": ["events"]},
10
+ )
11
+ report.raise_for_leaks()
12
+ """
13
+
14
+ from .core import check
15
+ from .perturb import advance, delay, use_column
16
+ from .report import Finding, Report
17
+
18
+ __all__ = ["check", "delay", "advance", "use_column", "Report", "Finding"]
19
+ __version__ = "0.1.0"
@@ -0,0 +1,150 @@
1
+ """Find features that read a source they never declared.
2
+
3
+ The test needs no ground truth. It needs one change the answer must be
4
+ invariant to: move when a source became knowable, recompute, and read the list
5
+ of features that flinched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping, Sequence
11
+
12
+ import pandas as pd
13
+
14
+ from .perturb import delay
15
+ from .report import BYPASS, LEAK, OK, Finding, Report
16
+
17
+ __all__ = ["check"]
18
+
19
+ Sources = Mapping[str, pd.DataFrame]
20
+ Compute = Callable[[Sources], pd.DataFrame]
21
+
22
+
23
+ def _as_frame(result: object, label: str) -> pd.DataFrame:
24
+ if isinstance(result, pd.Series):
25
+ return result.to_frame()
26
+ if isinstance(result, pd.DataFrame):
27
+ return result
28
+ raise TypeError(f"{label} must return a DataFrame or Series, got {type(result).__name__}")
29
+
30
+
31
+ def _changed(left: pd.Series, right: pd.Series, tolerance: float) -> tuple[bool, float]:
32
+ """Did this column move? NaN in the same place counts as unchanged."""
33
+ both_null = left.isna() & right.isna()
34
+ if (left.isna() != right.isna()).any():
35
+ return True, float("inf")
36
+ if pd.api.types.is_numeric_dtype(left) and pd.api.types.is_numeric_dtype(right):
37
+ gap = (left - right).abs()
38
+ gap = gap[~both_null]
39
+ worst = float(gap.max()) if len(gap) else 0.0
40
+ return worst > tolerance, worst
41
+ unequal = (left != right) & ~both_null
42
+ return bool(unequal.any()), float(unequal.sum())
43
+
44
+
45
+ def check(
46
+ compute: Compute,
47
+ sources: Sources,
48
+ timestamps: Mapping[str, str],
49
+ declared: Mapping[str, Sequence[str]],
50
+ *,
51
+ by: pd.Timedelta = pd.Timedelta(days=30),
52
+ tolerance: float = 0.0,
53
+ perturb: Callable[[pd.DataFrame, str, pd.Timedelta], pd.DataFrame] = delay,
54
+ ) -> Report:
55
+ """Perturb each source's availability in turn; report features that moved.
56
+
57
+ Args:
58
+ compute: builds your features. Takes the sources dict, returns a frame
59
+ whose columns are features. Must be deterministic -- this is checked.
60
+ sources: name -> raw table. Each carries an availability timestamp.
61
+ timestamps: source name -> the column holding that timestamp, or None for
62
+ a static table with no clock, which is skipped.
63
+ declared: feature name -> the sources it is supposed to read. A feature
64
+ absent from this mapping is treated as declaring nothing, which is
65
+ the strictest reading and usually what you want for a new pipeline.
66
+ by: how far to move availability. Large enough to bite, small enough to
67
+ leave rows in the window.
68
+ tolerance: treat changes at or below this as no change. Leave at 0.0
69
+ unless your pipeline has genuine floating-point nondeterminism, and
70
+ prefer to fix that instead.
71
+ perturb: the perturbation. `delay` removes information and is the safe
72
+ default; see `leakprobe.perturb` for others.
73
+
74
+ Returns:
75
+ A Report. `report.leaks` is the list of undeclared dependencies, and
76
+ `report.raise_for_leaks()` turns them into a test failure.
77
+ """
78
+ absent = set(sources) - set(timestamps)
79
+ if absent:
80
+ raise KeyError(
81
+ f"No availability column given for source(s): {sorted(absent)}. "
82
+ "Pass None for a static table that has no clock."
83
+ )
84
+
85
+ unknown = {s for deps in declared.values() for s in deps} - set(sources)
86
+ if unknown:
87
+ raise KeyError(f"declared refers to unknown source(s): {sorted(unknown)}")
88
+
89
+ baseline = _as_frame(compute(sources), "compute")
90
+ control = _as_frame(compute(sources), "compute")
91
+
92
+ notes: list[str] = []
93
+ if list(baseline.columns) != list(control.columns):
94
+ raise RuntimeError(
95
+ "compute() returned different columns on two identical calls. The test "
96
+ "cannot separate a leak from nondeterminism until that is fixed."
97
+ )
98
+ unstable = [c for c in baseline.columns if _changed(baseline[c], control[c], tolerance)[0]]
99
+ if unstable:
100
+ raise RuntimeError(
101
+ "compute() is not deterministic: "
102
+ + ", ".join(unstable[:5])
103
+ + (" ..." if len(unstable) > 5 else "")
104
+ + ". Seed it, or every result below is noise."
105
+ )
106
+
107
+ features = [str(c) for c in baseline.columns]
108
+ findings: list[Finding] = []
109
+
110
+ for source in sources:
111
+ if timestamps[source] is None:
112
+ notes.append(
113
+ f"note: {source!r} has no availability column, so nothing about it was "
114
+ f"perturbed. Dependencies on it are untested."
115
+ )
116
+ continue
117
+ moved_frame = _as_frame(
118
+ compute({**sources, source: perturb(sources[source], timestamps[source], by)}),
119
+ "compute",
120
+ )
121
+ if list(moved_frame.columns) != list(baseline.columns):
122
+ notes.append(
123
+ f"note: perturbing {source!r} changed the feature set itself, which is "
124
+ f"a stronger dependency than this test is designed to describe."
125
+ )
126
+ continue
127
+
128
+ for column, feature in zip(baseline.columns, features):
129
+ moved, worst = _changed(baseline[column], moved_frame[column], tolerance)
130
+ is_declared = source in declared.get(feature, ())
131
+ if moved and not is_declared:
132
+ kind = LEAK
133
+ elif is_declared and not moved:
134
+ kind = BYPASS
135
+ else:
136
+ kind = OK
137
+ findings.append(
138
+ Finding(
139
+ feature=feature,
140
+ source=source,
141
+ kind=kind,
142
+ declared=is_declared,
143
+ moved=moved,
144
+ max_abs_change=worst,
145
+ )
146
+ )
147
+
148
+ return Report(
149
+ findings=findings, features=features, sources=list(sources), notes=notes
150
+ )
@@ -0,0 +1,64 @@
1
+ """Ways to move when a source becomes knowable.
2
+
3
+ A perturbation changes *availability*, never values. That distinction is what
4
+ makes the test sound: if a feature's number changes after a perturbation, the
5
+ feature read something whose timing moved, and timing is the only thing that
6
+ moved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pandas as pd
12
+
13
+ __all__ = ["delay", "advance", "use_column"]
14
+
15
+
16
+ def _check(frame: pd.DataFrame, column: str) -> None:
17
+ if column not in frame.columns:
18
+ raise KeyError(
19
+ f"No availability column {column!r}. Columns present: {list(frame.columns)}"
20
+ )
21
+ if not pd.api.types.is_datetime64_any_dtype(frame[column]):
22
+ raise TypeError(
23
+ f"Availability column {column!r} is {frame[column].dtype}, not a datetime. "
24
+ "Parse it with pd.to_datetime first."
25
+ )
26
+
27
+
28
+ def delay(frame: pd.DataFrame, column: str, by: pd.Timedelta) -> pd.DataFrame:
29
+ """The same rows, knowable `by` later.
30
+
31
+ The safe default. Delaying a source can only remove information, so a
32
+ feature that legitimately reads it will change and one that does not cannot.
33
+ """
34
+ _check(frame, column)
35
+ out = frame.copy()
36
+ out[column] = out[column] + by
37
+ return out
38
+
39
+
40
+ def advance(frame: pd.DataFrame, column: str, by: pd.Timedelta) -> pd.DataFrame:
41
+ """The same rows, knowable `by` earlier -- look-ahead, injected on purpose.
42
+
43
+ Use when you want to measure what a leak would be *worth* rather than
44
+ whether one exists.
45
+ """
46
+ _check(frame, column)
47
+ out = frame.copy()
48
+ out[column] = out[column] - by
49
+ return out
50
+
51
+
52
+ def use_column(frame: pd.DataFrame, column: str, source: str) -> pd.DataFrame:
53
+ """Availability taken from another column.
54
+
55
+ Models the specific mistake of treating a record as knowable when the period
56
+ it describes ended, rather than when it was published: `use_column(facts,
57
+ "available_at", "period_end")`.
58
+ """
59
+ _check(frame, column)
60
+ if source not in frame.columns:
61
+ raise KeyError(f"No column {source!r} to take availability from.")
62
+ out = frame.copy()
63
+ out[column] = out[source]
64
+ return out
@@ -0,0 +1,109 @@
1
+ """The verdict, and how it prints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ __all__ = ["Finding", "Report"]
8
+
9
+ LEAK = "leak"
10
+ BYPASS = "bypass"
11
+ OK = "ok"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Finding:
16
+ """One (feature, source) pair, and what the perturbation did to it."""
17
+
18
+ feature: str
19
+ source: str
20
+ kind: str
21
+ declared: bool
22
+ moved: bool
23
+ max_abs_change: float
24
+
25
+ def __str__(self) -> str:
26
+ if self.kind == LEAK:
27
+ return (
28
+ f"{self.feature} moved when {self.source} was perturbed, and does not "
29
+ f"declare it (max change {self.max_abs_change:.3g})"
30
+ )
31
+ if self.kind == BYPASS:
32
+ return (
33
+ f"{self.feature} declares {self.source} but did not move when its clock "
34
+ f"did -- it reads the source without consulting availability, or the "
35
+ f"declaration is stale"
36
+ )
37
+ return f"{self.feature} / {self.source}: as declared"
38
+
39
+
40
+ @dataclass
41
+ class Report:
42
+ findings: list[Finding] = field(default_factory=list)
43
+ features: list[str] = field(default_factory=list)
44
+ sources: list[str] = field(default_factory=list)
45
+ notes: list[str] = field(default_factory=list)
46
+
47
+ @property
48
+ def leaks(self) -> list[Finding]:
49
+ """Undeclared dependencies. These are the bugs."""
50
+ return [f for f in self.findings if f.kind == LEAK]
51
+
52
+ @property
53
+ def bypassed(self) -> list[Finding]:
54
+ """Declared dependencies that did not respond to their own clock.
55
+
56
+ Either the declaration is stale, or the feature reaches the source by a
57
+ path that ignores its availability column -- which is how look-ahead
58
+ usually gets in. Not a failure on its own; worth reading.
59
+ """
60
+ return [f for f in self.findings if f.kind == BYPASS]
61
+
62
+ @property
63
+ def clean(self) -> bool:
64
+ return not self.leaks
65
+
66
+ def raise_for_leaks(self) -> None:
67
+ """For use in a test suite: turn a leak into a failure."""
68
+ if self.leaks:
69
+ listed = "\n".join(f" - {f}" for f in self.leaks)
70
+ raise AssertionError(f"{len(self.leaks)} undeclared dependencies:\n{listed}")
71
+
72
+ def __str__(self) -> str:
73
+ width = max((len(f) for f in self.features), default=7)
74
+ lines = [
75
+ f"{len(self.features)} features x {len(self.sources)} sources",
76
+ "",
77
+ f"{'feature':<{width}} " + " ".join(f"{s:>12}" for s in self.sources),
78
+ ]
79
+ by_pair = {(f.feature, f.source): f for f in self.findings}
80
+ for feature in self.features:
81
+ cells = []
82
+ for source in self.sources:
83
+ found = by_pair.get((feature, source))
84
+ if found is None:
85
+ cells.append(f"{'-':>12}")
86
+ elif found.kind == LEAK:
87
+ cells.append(f"{'LEAK':>12}")
88
+ elif found.kind == BYPASS:
89
+ cells.append(f"{'bypass?':>12}")
90
+ elif found.declared:
91
+ cells.append(f"{'reads it':>12}")
92
+ else:
93
+ cells.append(f"{'exactly 0':>12}")
94
+ lines.append(f"{feature:<{width}} " + " ".join(cells))
95
+
96
+ lines.append("")
97
+ if self.leaks:
98
+ lines.append(f"{len(self.leaks)} undeclared dependencies:")
99
+ lines += [f" - {f}" for f in self.leaks]
100
+ else:
101
+ lines.append("No undeclared dependencies.")
102
+ if self.bypassed:
103
+ lines.append("")
104
+ lines.append("Declared, but did not respond to the source's clock:")
105
+ lines += [f" - {f}" for f in self.bypassed]
106
+ if self.notes:
107
+ lines.append("")
108
+ lines += self.notes
109
+ return "\n".join(lines)
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: leakprobe
3
+ Version: 0.1.0
4
+ Summary: Find features that read data they were never supposed to see. Temporal leakage detection by metamorphic testing.
5
+ Author: Raunak Sood
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/quantraunak/leakprobe
8
+ Project-URL: Issues, https://github.com/quantraunak/leakprobe/issues
9
+ Keywords: leakage,data-leakage,machine-learning,testing,time-series,point-in-time,feature-engineering
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pandas>=2.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.4; extra == "dev"
23
+ Requires-Dist: numpy>=1.26; extra == "dev"
24
+ Provides-Extra: examples
25
+ Requires-Dist: numpy>=1.26; extra == "examples"
26
+ Requires-Dist: openpyxl>=3.1; extra == "examples"
27
+ Dynamic: license-file
28
+
29
+ # leakprobe
30
+
31
+ [![CI](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml/badge.svg)](https://github.com/quantraunak/leakprobe/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/leakprobe.svg)](https://pypi.org/project/leakprobe/) [![Python](https://img.shields.io/pypi/pyversions/leakprobe.svg)](https://pypi.org/project/leakprobe/)
32
+
33
+
34
+ **Find features that read data they were never supposed to see.**
35
+
36
+ Temporal leakage is the most expensive quiet bug in applied ML. A feature reads
37
+ something that wasn't knowable yet, nothing throws, no number looks implausible,
38
+ and your model gets better. You find out in production, if you find out at all.
39
+
40
+ `leakprobe` finds it without needing to know the right answer. It needs one
41
+ thing you already know: **a change to your data that your features must be
42
+ invariant to.**
43
+
44
+ ```bash
45
+ pip install leakprobe
46
+ ```
47
+
48
+ ```python
49
+ import leakprobe as lp
50
+
51
+ report = lp.check(
52
+ compute=build_features, # (sources) -> DataFrame of features
53
+ sources={"events": events, "tickets": tickets},
54
+ timestamps={"events": "occurred_at", "tickets": "resolved_at"},
55
+ declared={
56
+ "total_spend": ["events"],
57
+ "event_count": ["events"],
58
+ "tickets_resolved": ["tickets"],
59
+ "avg_severity": ["tickets"],
60
+ },
61
+ )
62
+ report.raise_for_leaks() # fails your test suite if anything leaked
63
+ ```
64
+
65
+ ```
66
+ 4 features x 2 sources
67
+
68
+ feature events tickets
69
+ total_spend reads it exactly 0
70
+ event_count reads it exactly 0
71
+ tickets_resolved exactly 0 reads it
72
+ avg_severity exactly 0 bypass?
73
+
74
+ No undeclared dependencies.
75
+
76
+ Declared, but did not respond to the source's clock:
77
+ - avg_severity declares tickets but did not move when its clock did -- it
78
+ reads the source without consulting availability, or the declaration is stale
79
+ ```
80
+
81
+ That last line is a real bug. `avg_severity` filters tickets on `opened_at`
82
+ instead of `resolved_at`, so tickets that were still open at scoring time leak
83
+ in — exactly the ones that predict churn. Nothing about the code looks wrong.
84
+
85
+ ## Try it on real data
86
+
87
+ ```bash
88
+ pip install leakprobe openpyxl
89
+ python examples/online_retail.py
90
+ ```
91
+
92
+ The UCI Online Retail set: 397,924 real orders and 8,905 returns across 4,372
93
+ customers of a UK gift retailer, 2010-2011. Six ordinary per-customer features
94
+ built as of a cutoff, two of them wrong in the two ways temporal leakage
95
+ actually happens. Neither raises. Both are caught:
96
+
97
+ ```
98
+ feature orders returns
99
+ total_spend reads it exactly 0
100
+ order_count reads it exactly 0
101
+ recency_days reads it exactly 0
102
+ return_count exactly 0 reads it
103
+ avg_unit_price bypass? exactly 0
104
+ net_spend reads it LEAK
105
+
106
+ 1 undeclared dependencies:
107
+ - net_spend moved when returns was perturbed, and does not declare it (max change 7.46e+03)
108
+
109
+ Declared, but did not respond to the source's clock:
110
+ - avg_unit_price declares orders but did not move when its clock did
111
+ ```
112
+
113
+ `avg_unit_price` is missing one cutoff filter, so it averages invoices that had
114
+ not happened yet. `net_spend` reaches into the returns table without declaring
115
+ it, silently inheriting that table's latency. One dropped subscript and one
116
+ undeclared read -- the two shapes this bug takes in production.
117
+
118
+ ## How it works
119
+
120
+ Four steps, and no ground truth anywhere in them.
121
+
122
+ 1. **Move when a source became knowable.** Not its values — only its
123
+ availability timestamp. `delay` pushes it later, which can only ever remove
124
+ information.
125
+ 2. **Recompute every feature.**
126
+ 3. **Compare, exactly.** A feature that genuinely cannot read that source gets
127
+ the identical input arrays through the identical code and returns bit-identical
128
+ floats. Its difference is `0.0`, not `1e-15`. So any movement at all is proof
129
+ of a dependency, not a number you have to squint at.
130
+ 4. **Check what moved against what you declared.** Anything in one list and not
131
+ the other is the finding.
132
+
133
+ Two kinds of finding:
134
+
135
+ - **`leak`** — moved, but doesn't declare the source. It has a dependency you
136
+ didn't know about. In a temporal pipeline, an unknown dependency on *when*
137
+ data arrived is look-ahead.
138
+ - **`bypass?`** — declares the source but didn't move when that source's clock
139
+ did. It's reaching the data by a path that ignores availability, which is how
140
+ look-ahead usually gets in. Not a failure on its own. Worth reading.
141
+
142
+ Before any of that, `check` runs `compute` twice on untouched inputs and refuses
143
+ to continue if the two runs disagree. A nondeterministic pipeline makes every
144
+ result below it noise, so that's a hard error rather than a warning.
145
+
146
+ ## Declaring nothing
147
+
148
+ You don't have to write the `declared` map. Leave it out and every real timing
149
+ dependency is reported:
150
+
151
+ ```python
152
+ report = lp.check(compute, sources, timestamps, declared={})
153
+ for f in report.leaks:
154
+ print(f.feature, "reads", f.source)
155
+ ```
156
+
157
+ That's the fastest way to answer "what does this pipeline actually depend on"
158
+ for code you inherited, which is usually a shorter list than the author believed.
159
+
160
+ ## In CI
161
+
162
+ ```python
163
+ def test_no_temporal_leakage():
164
+ lp.check(build_features, SOURCES, TIMESTAMPS, DECLARED).raise_for_leaks()
165
+ ```
166
+
167
+ The declaration map becomes the thing code review argues about, which is where
168
+ that argument belongs.
169
+
170
+ ## Perturbations
171
+
172
+ | | |
173
+ |---|---|
174
+ | `delay(frame, col, by)` | knowable later. The safe default: removes information only. |
175
+ | `advance(frame, col, by)` | knowable earlier. Injects look-ahead on purpose, to measure what a leak is worth. |
176
+ | `use_column(frame, col, other)` | availability taken from another column. Models "treated as knowable when the period ended, not when it was published." |
177
+
178
+ ```python
179
+ report = lp.check(..., perturb=lp.advance, by=pd.Timedelta(days=90))
180
+ ```
181
+
182
+ ## What it will not catch
183
+
184
+ **Dependencies that don't flow through a timestamp.** The perturbation moves
185
+ availability, so it reveals as-of joins, merges on a date, and windows anchored
186
+ to one. A feature that reads a source's values with no reference to when they
187
+ arrived is invariant to it and will not be flagged. In practice this costs less
188
+ than it sounds: look-ahead *is* a dependency on timing, so the leaks worth
189
+ catching are the detectable ones. The boundary is asserted in the test suite so
190
+ it can't quietly stop being true.
191
+
192
+ **Sources with no clock.** Pass `None` and static tables are skipped, with a
193
+ note saying dependencies on them went untested.
194
+
195
+ **Leakage across rows rather than time** — target encoding fit on the full
196
+ dataset, a scaler fit before the split. Different bug, different tool.
197
+
198
+ ## Where this came from
199
+
200
+ The technique is metamorphic testing, which software testing has used for
201
+ decades and research code almost never does. This is an extraction of a check
202
+ built for a quantitative finance pipeline, where the sources are stock prices
203
+ and regulatory filings and the question is whether a factor read an earnings
204
+ figure before it was published.
205
+
206
+ It caught a real one. A factor called `turnover_1m` was classified as
207
+ price-and-volume — it's built from volume, it lives in `price.py`, and every
208
+ human who looked at it filed it under prices. It divides by shares outstanding,
209
+ which comes off a filing. It was the only member of its group that moved when
210
+ the filing calendar shifted, while eleven genuine price factors held at exactly
211
+ zero. That measurement is written up in
212
+ [bias-fingerprints](https://github.com/quantraunak/bias-fingerprints).
213
+
214
+ ## Install
215
+
216
+ ```bash
217
+ pip install leakprobe # pandas is the only dependency
218
+ ```
219
+
220
+ Python 3.10+.
221
+
222
+ ## Licence
223
+
224
+ MIT.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/leakprobe/__init__.py
5
+ src/leakprobe/core.py
6
+ src/leakprobe/perturb.py
7
+ src/leakprobe/report.py
8
+ src/leakprobe.egg-info/PKG-INFO
9
+ src/leakprobe.egg-info/SOURCES.txt
10
+ src/leakprobe.egg-info/dependency_links.txt
11
+ src/leakprobe.egg-info/requires.txt
12
+ src/leakprobe.egg-info/top_level.txt
13
+ tests/test_core.py
14
+ tests/test_perturb.py
@@ -0,0 +1,9 @@
1
+ pandas>=2.0
2
+
3
+ [dev]
4
+ pytest>=7.4
5
+ numpy>=1.26
6
+
7
+ [examples]
8
+ numpy>=1.26
9
+ openpyxl>=3.1
@@ -0,0 +1 @@
1
+ leakprobe
@@ -0,0 +1,178 @@
1
+ """The claims the tool rests on, each broken on purpose."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ import leakprobe as lc
8
+
9
+
10
+ @pytest.fixture
11
+ def sources():
12
+ days = pd.date_range("2024-01-01", periods=120, freq="D")
13
+ rng = np.random.default_rng(0)
14
+ prices = pd.DataFrame(
15
+ {
16
+ "available_at": days,
17
+ "price": 100 + rng.normal(0, 1, len(days)).cumsum(),
18
+ "volume": rng.integers(1_000, 5_000, len(days)),
19
+ }
20
+ )
21
+ quarters = pd.to_datetime(["2023-12-31", "2024-03-31"])
22
+ filings = pd.DataFrame(
23
+ {
24
+ "period_end": quarters,
25
+ "available_at": quarters + pd.Timedelta(days=34),
26
+ "earnings": [4.0, 5.0],
27
+ "shares": [1_000.0, 1_100.0],
28
+ }
29
+ )
30
+ return {"prices": prices, "filings": filings}
31
+
32
+
33
+ def _as_of(filings, dates, column):
34
+ """Latest value knowable on each date. The whole point is that this respects
35
+ `available_at`, so moving it moves the answer."""
36
+ ordered = filings.sort_values("available_at")
37
+ idx = np.searchsorted(ordered["available_at"].to_numpy(), dates.to_numpy(), side="right") - 1
38
+ out = np.where(idx >= 0, ordered[column].to_numpy()[np.clip(idx, 0, None)], np.nan)
39
+ return pd.Series(out, index=range(len(dates)))
40
+
41
+
42
+ def build(sources):
43
+ prices, filings = sources["prices"], sources["filings"]
44
+ dates = prices["available_at"]
45
+ return pd.DataFrame(
46
+ {
47
+ "momentum": prices["price"].pct_change(20),
48
+ "earnings_yield": _as_of(filings, dates, "earnings") / prices["price"],
49
+ # Looks like a price feature. Divides by a filed number.
50
+ "turnover": prices["volume"] / _as_of(filings, dates, "shares"),
51
+ }
52
+ )
53
+
54
+
55
+ def test_price_only_feature_is_exactly_unchanged(sources):
56
+ report = lc.check(
57
+ build, sources,
58
+ timestamps={"prices": "available_at", "filings": "available_at"},
59
+ declared={"momentum": ["prices"], "earnings_yield": ["filings", "prices"],
60
+ "turnover": ["prices", "filings"]},
61
+ )
62
+ hit = next(f for f in report.findings if f.feature == "momentum" and f.source == "filings")
63
+ assert not hit.moved
64
+ assert hit.max_abs_change == 0.0, "not approximately zero -- exactly zero"
65
+ assert report.clean
66
+
67
+
68
+ def test_undeclared_filing_dependency_is_caught(sources):
69
+ """turnover divides volume by shares outstanding, so it reads a filing.
70
+ Declared as price-only, which is the mistake this tool exists to catch."""
71
+ report = lc.check(
72
+ build, sources,
73
+ timestamps={"prices": "available_at", "filings": "available_at"},
74
+ declared={"momentum": ["prices"], "earnings_yield": ["filings", "prices"],
75
+ "turnover": ["prices"]},
76
+ )
77
+ assert [f.feature for f in report.leaks] == ["turnover"]
78
+ assert report.leaks[0].source == "filings"
79
+ assert not report.clean
80
+ with pytest.raises(AssertionError, match="turnover"):
81
+ report.raise_for_leaks()
82
+
83
+
84
+ def test_declaring_nothing_flags_every_timing_dependency(sources):
85
+ """Everything that consults a timestamp is caught. `momentum` is not, and
86
+ that is the method's boundary rather than a miss: it reads price *values*
87
+ and never asks when they arrived, so shifting availability uniformly cannot
88
+ move it. See test_values_read_without_consulting_time_are_invisible."""
89
+ report = lc.check(
90
+ build, sources,
91
+ timestamps={"prices": "available_at", "filings": "available_at"},
92
+ declared={},
93
+ )
94
+ assert {f.feature for f in report.leaks} == {"earnings_yield", "turnover"}
95
+
96
+
97
+ def test_declared_but_clock_insensitive_is_reported_separately(sources):
98
+ report = lc.check(
99
+ build, sources,
100
+ timestamps={"prices": "available_at", "filings": "available_at"},
101
+ declared={"momentum": ["prices", "filings"], "earnings_yield": ["filings", "prices"],
102
+ "turnover": ["prices", "filings"]},
103
+ )
104
+ assert set((f.feature, f.source) for f in report.bypassed) == {
105
+ ("momentum", "filings"),
106
+ ("momentum", "prices"),
107
+ }
108
+ assert report.clean, "a stale declaration is not a leak"
109
+
110
+
111
+ def test_nondeterministic_compute_is_refused(sources):
112
+ def unstable(src):
113
+ return pd.DataFrame({"x": np.random.default_rng().normal(size=len(src["prices"]))})
114
+
115
+ with pytest.raises(RuntimeError, match="not deterministic"):
116
+ lc.check(unstable, sources,
117
+ timestamps={"prices": "available_at", "filings": "available_at"},
118
+ declared={})
119
+
120
+
121
+ def test_unknown_source_in_declared_is_refused(sources):
122
+ with pytest.raises(KeyError, match="unknown source"):
123
+ lc.check(build, sources,
124
+ timestamps={"prices": "available_at", "filings": "available_at"},
125
+ declared={"momentum": ["typo_source"]})
126
+
127
+
128
+ def test_missing_timestamp_column_is_refused(sources):
129
+ with pytest.raises(KeyError, match="availability column"):
130
+ lc.check(build, sources, timestamps={"prices": "available_at"}, declared={})
131
+
132
+
133
+ def test_nan_in_the_same_place_is_not_a_change(sources):
134
+ """A feature that is NaN for its first 20 rows in both runs has not moved."""
135
+ report = lc.check(
136
+ build, sources,
137
+ timestamps={"prices": "available_at", "filings": "available_at"},
138
+ declared={"momentum": ["prices"], "earnings_yield": ["filings", "prices"],
139
+ "turnover": ["prices", "filings"]},
140
+ )
141
+ assert build(sources)["momentum"].isna().sum() > 0
142
+ assert report.clean
143
+
144
+
145
+ def test_values_read_without_consulting_time_are_invisible(sources):
146
+ """The limitation, asserted so it cannot quietly stop being true.
147
+
148
+ The perturbation moves availability, so it can only reveal dependencies that
149
+ flow through a timestamp -- an as-of join, a merge on a date, a window
150
+ anchored to one. A feature that reads a source's values directly, with no
151
+ reference to when they arrived, is invariant to it and will not be flagged.
152
+
153
+ That boundary is not much of a loss in practice: look-ahead is by definition
154
+ a dependency on timing, so the leaks worth catching are the detectable ones.
155
+ """
156
+ def timeless(src):
157
+ # Reads filings values, never their availability. Undetectable here.
158
+ return pd.DataFrame({"mean_earnings": [src["filings"]["earnings"].mean()]})
159
+
160
+ report = lc.check(
161
+ timeless, sources,
162
+ timestamps={"prices": "available_at", "filings": "available_at"},
163
+ declared={},
164
+ )
165
+ assert report.clean
166
+ assert report.leaks == []
167
+
168
+
169
+ def test_source_without_a_clock_is_skipped_and_said_so(sources):
170
+ static = pd.DataFrame({"region": ["us", "eu"]})
171
+ report = lc.check(
172
+ lambda src: pd.DataFrame({"x": [len(src["static"])]}),
173
+ {**sources, "static": static},
174
+ timestamps={"prices": "available_at", "filings": "available_at", "static": None},
175
+ declared={},
176
+ )
177
+ assert any("no availability column" in n for n in report.notes)
178
+ assert all(f.source != "static" for f in report.findings)
@@ -0,0 +1,38 @@
1
+ import pandas as pd
2
+ import pytest
3
+
4
+ from leakprobe import advance, delay, use_column
5
+
6
+
7
+ @pytest.fixture
8
+ def facts():
9
+ ends = pd.to_datetime(["2024-03-31", "2024-06-30"])
10
+ return pd.DataFrame({"period_end": ends, "available_at": ends + pd.Timedelta(days=34)})
11
+
12
+
13
+ def test_delay_moves_availability_only(facts):
14
+ out = delay(facts, "available_at", pd.Timedelta(days=30))
15
+ assert (out["available_at"] - facts["available_at"] == pd.Timedelta(days=30)).all()
16
+ pd.testing.assert_series_equal(out["period_end"], facts["period_end"])
17
+
18
+
19
+ def test_advance_is_the_inverse(facts):
20
+ by = pd.Timedelta(days=7)
21
+ pd.testing.assert_frame_equal(advance(delay(facts, "available_at", by), "available_at", by), facts)
22
+
23
+
24
+ def test_use_column_models_the_period_end_join(facts):
25
+ out = use_column(facts, "available_at", "period_end")
26
+ pd.testing.assert_series_equal(out["available_at"], facts["period_end"], check_names=False)
27
+
28
+
29
+ def test_input_is_never_mutated(facts):
30
+ before = facts.copy()
31
+ delay(facts, "available_at", pd.Timedelta(days=1))
32
+ pd.testing.assert_frame_equal(facts, before)
33
+
34
+
35
+ def test_non_datetime_column_is_refused(facts):
36
+ bad = facts.assign(available_at=[1, 2])
37
+ with pytest.raises(TypeError, match="not a datetime"):
38
+ delay(bad, "available_at", pd.Timedelta(days=1))