splittrace 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.
splittrace/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Audit declared sample provenance, not a guarantee of leakage absence."""
2
+ from .core import audit, map_interval
3
+
4
+ __all__ = ["audit", "map_interval"]
splittrace/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import csv
3
+ import hashlib
4
+ import html
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .core import audit
9
+
10
+
11
+ def read_rows(path, samples=False):
12
+ path = Path(path)
13
+ if path.suffix.lower() == ".json":
14
+ rows = json.loads(path.read_text(encoding="utf-8"))
15
+ if not isinstance(rows, list) or any(not isinstance(r, dict) for r in rows):
16
+ raise ValueError("JSON input must be a list of objects")
17
+ return rows
18
+ if path.suffix.lower() != ".csv":
19
+ raise ValueError("inputs must be .csv or .json")
20
+ with path.open(encoding="utf-8-sig", newline="") as handle:
21
+ rows = [{k: v for k, v in row.items() if v != ""} for row in csv.DictReader(handle)]
22
+ if samples:
23
+ for row in rows:
24
+ row["parent_ids"] = json.loads(row.get("parent_ids", "[]"))
25
+ for key in ("start", "end"):
26
+ if key in row:
27
+ row[key] = float(row[key])
28
+ return rows
29
+
30
+
31
+ def html_report(report):
32
+ """Standalone, script-free report; all user-controlled content is escaped."""
33
+ body = html.escape(json.dumps(report, ensure_ascii=False, indent=2))
34
+ return ('<!doctype html><html lang="en"><meta charset="utf-8">'
35
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
36
+ '<title>SplitTrace audit</title><style>body{font:16px system-ui;max-width:1000px;'
37
+ 'margin:40px auto;padding:0 20px}pre{white-space:pre-wrap;overflow-wrap:anywhere;'
38
+ 'font-size:13px}h1{font-size:24px}</style><h1>SplitTrace: ' + report["status"] +
39
+ '</h1><p>Metadata-only audit. PASS does not certify absence of all leakage. '
40
+ 'This report may contain sample identifiers; review before sharing.</p><pre>' + body + '</pre></html>')
41
+
42
+
43
+ def main(argv=None):
44
+ parser = argparse.ArgumentParser(description="Audit existing signal-ML splits and declared sample lineage.")
45
+ parser.add_argument("samples", help="CSV or JSON list of provenance nodes")
46
+ parser.add_argument("assignments", help="CSV or JSON list: sample_id, split, fold")
47
+ parser.add_argument("--policy", required=True, choices=["cross_subject", "within_recording_future"])
48
+ parser.add_argument("--lineage-complete", action="store_true", help="Assert all parent relationships are supplied")
49
+ parser.add_argument("--output", type=Path)
50
+ parser.add_argument("--html", type=Path)
51
+ args = parser.parse_args(argv)
52
+ try:
53
+ report = audit(read_rows(args.samples, True), read_rows(args.assignments),
54
+ policy=args.policy, lineage_complete=args.lineage_complete)
55
+ report["input_sha256"] = {name: hashlib.sha256(Path(path).read_bytes()).hexdigest()
56
+ for name,path in [("samples",args.samples),("assignments",args.assignments)]}
57
+ encoded = json.dumps(report, ensure_ascii=False, indent=2)
58
+ if args.output:
59
+ args.output.write_text(encoded + "\n", encoding="utf-8")
60
+ if args.html:
61
+ args.html.write_text(html_report(report), encoding="utf-8")
62
+ print(encoded)
63
+ return {"PASS": 0, "FAIL": 1, "UNKNOWN": 2}[report["status"]]
64
+ except (ValueError, TypeError, KeyError, OSError) as error:
65
+ print(json.dumps({"status": "INVALID_INPUT", "error": str(error)}))
66
+ return 2
67
+
68
+
69
+ if __name__ == "__main__":
70
+ raise SystemExit(main())
splittrace/core.py ADDED
@@ -0,0 +1,194 @@
1
+ """Small, deterministic metadata auditor with explicit coverage limits.
2
+
3
+ Intervals must already refer to ORIGINAL recording coordinates. No signal data
4
+ is inspected and no implicit conversion between clocks or sample rates occurs.
5
+ """
6
+ from collections import deque
7
+ from itertools import combinations
8
+ from math import isfinite
9
+
10
+ ROLES = ("train", "validation", "test")
11
+ RULES = ("sample_identity", "subject", "lineage", "interval", "chronology")
12
+
13
+
14
+ def map_interval(start, end, *, origin_offset, original_units_per_local_unit):
15
+ """Explicit affine mapping to original coordinates (e.g. resampled indices).
16
+
17
+ Only positive linear transforms are supported. Caller supplies provenance;
18
+ this does not infer clocks, filtering support, padding or nonlinear warps.
19
+ """
20
+ values = (start, end, origin_offset, original_units_per_local_unit)
21
+ if any(isinstance(v, bool) or not isinstance(v, (float, int)) or not isfinite(v) for v in values):
22
+ raise ValueError("mapping values must be finite numbers")
23
+ if start >= end or original_units_per_local_unit <= 0:
24
+ raise ValueError("mapping needs start < end and a positive scale")
25
+ mapped = (origin_offset + start * original_units_per_local_unit,
26
+ origin_offset + end * original_units_per_local_unit)
27
+ if not all(isfinite(v) for v in mapped) or mapped[0] >= mapped[1]:
28
+ raise ValueError("mapping overflow or collapsed interval")
29
+ return mapped
30
+
31
+
32
+ def _id(value, name):
33
+ if not isinstance(value, str) or not value.strip():
34
+ raise ValueError(f"{name} must be a nonempty string")
35
+ return value
36
+
37
+
38
+ def _nodes(samples):
39
+ nodes = {}
40
+ for item in samples:
41
+ item = dict(item)
42
+ sid = _id(item.get("sample_id"), "sample_id")
43
+ if sid in nodes:
44
+ raise ValueError(f"duplicate sample_id: {sid}")
45
+ kind = item.setdefault("kind", "sample")
46
+ if kind not in ("sample", "recording"):
47
+ raise ValueError("kind must be sample or recording")
48
+ parents = item.setdefault("parent_ids", [])
49
+ if not isinstance(parents, list) or len(set(map(str, parents))) != len(parents):
50
+ raise ValueError("parent_ids must be a list without duplicates")
51
+ for parent in parents:
52
+ _id(parent, "parent_id")
53
+ for key in ("subject_id", "recording_id", "coordinate_system"):
54
+ if item.get(key) is not None:
55
+ _id(item[key], key)
56
+ start, end = item.get("start"), item.get("end")
57
+ if (start is None) != (end is None):
58
+ raise ValueError("start and end must be supplied together")
59
+ if start is not None:
60
+ if any(isinstance(v, bool) or not isinstance(v, (float, int)) or not isfinite(v)
61
+ for v in (start, end)) or start >= end:
62
+ raise ValueError("interval must have finite numeric start < end")
63
+ if not item.get("recording_id") or not item.get("coordinate_system"):
64
+ raise ValueError("interval needs recording_id and coordinate_system")
65
+ nodes[sid] = item
66
+ if not nodes:
67
+ raise ValueError("samples must not be empty")
68
+ # Iterative topological ordering: no recursion-limit failure on long chains.
69
+ children = {sid: [] for sid in nodes}
70
+ indegree = {}
71
+ for sid, node in nodes.items():
72
+ indegree[sid] = len(node["parent_ids"])
73
+ for parent in node["parent_ids"]:
74
+ if parent not in nodes:
75
+ raise ValueError(f"missing parent {parent} referenced by {sid}")
76
+ children[parent].append(sid)
77
+ ready = deque(sorted(sid for sid, count in indegree.items() if count == 0))
78
+ order = []
79
+ while ready:
80
+ sid = ready.popleft()
81
+ order.append(sid)
82
+ for child in sorted(children[sid]):
83
+ indegree[child] -= 1
84
+ if indegree[child] == 0:
85
+ ready.append(child)
86
+ if len(order) != len(nodes):
87
+ raise ValueError("cycle in parent_ids")
88
+ # One deterministic witness path per ancestor, including the node itself.
89
+ paths = {}
90
+ for sid in order:
91
+ paths[sid] = {sid: [sid]}
92
+ for parent in sorted(nodes[sid]["parent_ids"]):
93
+ for ancestor, path in paths[parent].items():
94
+ paths[sid].setdefault(ancestor, [sid] + path)
95
+ return nodes, paths
96
+
97
+
98
+ def audit(samples, assignments, *, policy, lineage_complete=False, max_findings=1000):
99
+ """Return JSON-serializable findings for each independent fold.
100
+
101
+ policy: cross_subject OR within_recording_future.
102
+ lineage_complete: user's declaration that all parent relationships are given.
103
+ Max findings limits returned examples, NEVER the underlying failure counts.
104
+ Unknown metadata fails closed (UNKNOWN) unless a proven violation makes FAIL.
105
+ All comparisons are within each fold; ordinary cross-fold reuse is allowed.
106
+ """
107
+ if policy not in ("cross_subject", "within_recording_future"):
108
+ raise ValueError("explicit policy must be cross_subject or within_recording_future")
109
+ if not isinstance(lineage_complete, bool):
110
+ raise ValueError("lineage_complete must be boolean")
111
+ if isinstance(max_findings, bool) or not isinstance(max_findings, int) or max_findings < 0:
112
+ raise ValueError("max_findings must be a nonnegative integer")
113
+ nodes, paths = _nodes(samples)
114
+ folds, seen = {}, set()
115
+ for row in assignments:
116
+ sid = _id(row.get("sample_id"), "assignment sample_id")
117
+ fold = _id(row.get("fold", "0"), "fold")
118
+ role = row.get("split")
119
+ if sid not in nodes or nodes[sid]["kind"] != "sample":
120
+ raise ValueError(f"assignment must reference a sample: {sid}")
121
+ if role not in ROLES:
122
+ raise ValueError("split must be train, validation or test")
123
+ key = (fold, role, sid)
124
+ if key in seen:
125
+ raise ValueError(f"duplicate assignment: {key}")
126
+ seen.add(key)
127
+ folds.setdefault(fold, {}).setdefault(role, []).append(sid)
128
+ if not folds:
129
+ raise ValueError("assignments must not be empty")
130
+ findings, counts, fold_results = [], {r: 0 for r in RULES}, []
131
+ for fold, groups in sorted(folds.items()):
132
+ if "train" not in groups or not any(r in groups for r in ("validation", "test")):
133
+ raise ValueError(f"fold {fold} needs train and an evaluation partition")
134
+ state = {r: {"violations": 0, "unknown_pairs": 0} for r in RULES}
135
+
136
+ def emit(rule, left, right, left_role, right_role, evidence):
137
+ counts[rule] += 1
138
+ state[rule]["violations"] += 1
139
+ if len(findings) < max_findings:
140
+ findings.append(dict(rule=rule, fold=fold, left=left, right=right,
141
+ left_split=left_role, right_split=right_role, evidence=evidence))
142
+
143
+ for left_role, right_role in combinations([r for r in ROLES if r in groups], 2):
144
+ for left in sorted(groups[left_role]):
145
+ for right in sorted(groups[right_role]):
146
+ a, b = nodes[left], nodes[right]
147
+ args = (left, right, left_role, right_role)
148
+ if left == right:
149
+ emit("sample_identity", *args, {"sample_id": left})
150
+ if policy == "cross_subject":
151
+ if a.get("subject_id") is None or b.get("subject_id") is None:
152
+ state["subject"]["unknown_pairs"] += 1
153
+ elif a["subject_id"] == b["subject_id"]:
154
+ emit("subject", *args, {"subject_id": a["subject_id"]})
155
+ common = sorted(set(paths[left]) & set(paths[right]))
156
+ # A whole recording may legitimately feed both partitions.
157
+ # Shared example ancestors (e.g. one augmented epoch) may not.
158
+ example_ancestors = [s for s in common if nodes[s]["kind"] == "sample"]
159
+ if example_ancestors:
160
+ ancestor = min(example_ancestors, key=lambda s: (len(paths[left][s]) + len(paths[right][s]), s))
161
+ emit("lineage", *args, {"ancestor": ancestor,
162
+ "left_path": paths[left][ancestor], "right_path": paths[right][ancestor]})
163
+ if not lineage_complete:
164
+ state["lineage"]["unknown_pairs"] += 1
165
+ complete = all(n.get(k) is not None for n in (a, b)
166
+ for k in ("recording_id", "coordinate_system", "start", "end"))
167
+ comparable = complete and a["recording_id"] == b["recording_id"] and a["coordinate_system"] == b["coordinate_system"]
168
+ if not complete or (a.get("recording_id") == b.get("recording_id") and not comparable):
169
+ state["interval"]["unknown_pairs"] += 1
170
+ elif comparable:
171
+ start, end = max(a["start"], b["start"]), min(a["end"], b["end"])
172
+ if start < end:
173
+ emit("interval", *args, {"recording_id": a["recording_id"],
174
+ "coordinate_system": a["coordinate_system"],
175
+ "overlap_start": start, "overlap_end": end, "overlap_length": end-start})
176
+ if policy == "within_recording_future":
177
+ if not comparable:
178
+ state["chronology"]["unknown_pairs"] += 1
179
+ elif a["end"] > b["start"]:
180
+ emit("chronology", *args, {"earlier_split_end": a["end"],
181
+ "later_split_start": b["start"], "recording_id": a["recording_id"]})
182
+ for rule, details in state.items():
183
+ skip = (rule == "subject" and policy != "cross_subject") or (rule == "chronology" and policy != "within_recording_future")
184
+ details["status"] = "SKIP" if skip else "FAIL" if details["violations"] else "UNKNOWN" if details["unknown_pairs"] else "PASS"
185
+ fold_results.append({"fold": fold, "partition_sizes": {r:len(ids) for r,ids in groups.items()}, "checks": state})
186
+ statuses = [check["status"] for f in fold_results for check in f["checks"].values()]
187
+ verdict = "FAIL" if "FAIL" in statuses else "UNKNOWN" if "UNKNOWN" in statuses else "PASS"
188
+ return {"schema_version": 1, "policy": policy, "lineage_complete": lineage_complete,
189
+ "status": verdict, "folds": fold_results, "violation_counts": counts,
190
+ "findings": findings, "findings_truncated": sum(counts.values()) > len(findings),
191
+ "limitations": ["Checks declared metadata only; PASS is not proof of absence of all leakage.",
192
+ "Coordinates must already be mapped to the original recording; no implicit conversion.",
193
+ "No nested-CV, signal similarity, fitted-preprocessing or target-availability audit.",
194
+ "Quadratic pair scan: prototype for small manifests, not large-scale production."]}
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: splittrace
3
+ Version: 0.1.0
4
+ Summary: Metadata-first audit of derived samples across existing ML splits
5
+ Author: Ryu Koda
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/R-Koda/splittrace
8
+ Project-URL: Issues, https://github.com/R-Koda/splittrace/issues
9
+ Keywords: data-leakage,cross-validation,eeg,provenance,machine-learning
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # SplitTrace — trace data leakage back to its source
20
+
21
+ [![Tests](https://github.com/R-Koda/splittrace/actions/workflows/tests.yml/badge.svg)](https://github.com/R-Koda/splittrace/actions/workflows/tests.yml)
22
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
23
+
24
+ **Experimental Python library for auditing existing EEG and sensor-data splits.**
25
+
26
+ Audit existing train/validation/test assignments for EEG and sensor experiments.
27
+ Trace derived examples back through declared parent IDs, inspect intervals in
28
+ original-recording coordinates, and return evidence for each violation.
29
+
30
+ Different filenames and different pixel values can still come from the same
31
+ source epoch. SplitTrace accepts declared parent relationships and reports the
32
+ path that connects a training example to an evaluation example:
33
+
34
+ ```text
35
+ train: a → image-01 → epoch-01 ← b :test
36
+ ^ shared source example
37
+ ```
38
+
39
+ It also checks subject mixing, overlapping source intervals and, under an explicit
40
+ policy, chronological ordering. Missing metadata is **UNKNOWN**, not a clean bill
41
+ of health. All bundled examples are synthetic; no patient data is included.
42
+
43
+ **Early prototype:** small manifests only, with no real-user validation yet.
44
+ The distribution name and import name are both `splittrace`.
45
+
46
+ 日本語の試用手順は [PILOT.md](https://github.com/R-Koda/splittrace/blob/main/PILOT.md)。実行済みの比較結果は
47
+ [results/comparison.html](https://github.com/R-Koda/splittrace/blob/main/results/comparison.html)、派生元追跡の出力例は
48
+ [results/derived-audit.html](https://github.com/R-Koda/splittrace/blob/main/results/derived-audit.html) を開いてください。
49
+
50
+ ## Install from PyPI / pipでインストール
51
+
52
+ Python 3.10 or later. Create and activate a virtual environment first:
53
+
54
+ ```sh
55
+ python3 -m venv .venv
56
+ ```
57
+
58
+ macOS / Linux: `source .venv/bin/activate`
59
+
60
+ Windows PowerShell: `.venv\Scripts\Activate.ps1`
61
+
62
+ ```sh
63
+ python -m pip install splittrace
64
+ splittrace --help
65
+ ```
66
+
67
+ The help screen confirms that the command is available. Install only the tools you need.
68
+ [PyPI package](https://pypi.org/project/splittrace/) · [Source and examples](https://github.com/R-Koda/splittrace)
69
+
70
+ サンプルCSV・Notebookなどはpipでは配置されません。公開デモはブラウザで開けます。自分でサンプルを実行する場合はGitHubの「Code → Download ZIP」で取得・展開し、READMEの相対パスをそのフォルダー内から指定してください。
71
+
72
+ ## Article & live demo / 紹介記事と公開デモ
73
+
74
+ - [個別ガイド / SplitTrace:データ分割の混入を派生元から点検する](https://ryu-koda.com/blog/splittrace-data-leakage-check/)
75
+ - [紹介記事:データを分ける・評価する・公開する。Pythonの小さな確認ツールを3つ作りました](https://ryu-koda.com/blog/python-tools-splittrace-eventscore-notebook/) — Japanese overview of SplitTrace, EventScore Explorer, and Notebook Publish Check.
76
+ - [公開デモをブラウザで開く / Open the live demo](https://ryu-koda.com/articles/python-tools/splittrace-demo.html) — Synthetic data only; no installation required.
77
+
78
+ 公開デモは合成データの出力例です。自分のファイルをアップロードする画面ではありません。
79
+ The hosted demo is a sample report, not a file-upload service.
80
+
81
+ ## Run without installing anything
82
+
83
+ Python 3.10+; the core and CLI use only the standard library.
84
+
85
+ ```sh
86
+ git clone https://github.com/R-Koda/splittrace.git
87
+ cd splittrace
88
+ ```
89
+
90
+ From the repository directory:
91
+
92
+ ```sh
93
+ PYTHONPATH=src python3 -m splittrace.cli \
94
+ examples/derived-samples.json examples/derived-assignments.json \
95
+ --policy cross_subject --lineage-complete \
96
+ --output results/local-audit.json --html results/local-audit.html
97
+ ```
98
+
99
+ This intentionally leaking example exits **1**; that is the expected result,
100
+ not an installation error. The report contains the paths
101
+ `a → image-01 → epoch-01` and `b → epoch-01`. No signal values are read.
102
+ CLI exit codes: `0` = all applicable checks PASS; `1` = a declared policy violation;
103
+ `2` = UNKNOWN coverage or invalid input (inspect `status` to distinguish them).
104
+
105
+ For an editable install in your own virtual environment:
106
+
107
+ ```sh
108
+ python3 -m venv .venv
109
+ .venv/bin/python -m pip install -e .
110
+ .venv/bin/splittrace --help
111
+ ```
112
+
113
+ ## Python API
114
+
115
+ ```python
116
+ from splittrace import audit, map_interval
117
+
118
+ samples = [
119
+ {"sample_id": "epoch-01"},
120
+ {"sample_id": "image-01", "parent_ids": ["epoch-01"]},
121
+ {"sample_id": "a", "parent_ids": ["image-01"]},
122
+ {"sample_id": "b", "parent_ids": ["epoch-01"]},
123
+ ]
124
+ assignments = [
125
+ {"sample_id": "a", "split": "train", "fold": "0"},
126
+ {"sample_id": "b", "split": "test", "fold": "0"},
127
+ ]
128
+ report = audit(samples, assignments, policy="cross_subject", lineage_complete=True)
129
+ assert report["status"] == "FAIL"
130
+ # Other checks remain UNKNOWN when their required metadata is absent.
131
+
132
+ # Convert a local, resampled interval to original sample coordinates explicitly:
133
+ start, end = map_interval(0, 128, origin_offset=256, original_units_per_local_unit=2)
134
+ assert (start, end) == (256, 512)
135
+ ```
136
+
137
+ ## Data contract
138
+
139
+ Samples are dictionaries / JSON objects. CSV is also accepted; its `parent_ids`
140
+ cell must be a JSON array, quoted following normal CSV rules. Blank optional CSV
141
+ cells are missing metadata. Numeric interval cells are parsed as floats.
142
+
143
+ - `sample_id`: nonempty, globally unique string. Reference nodes may exist without
144
+ assignment to a split. Parent references must resolve; cycles are rejected.
145
+ - `parent_ids`: list of directly contributing source-example IDs. Declare all
146
+ parents for mixtures. Examples sharing a sample ancestor are flagged across
147
+ partitions. This is a conservative **source-example separation policy**, not a
148
+ statistical proof that all shared ancestry invalidates every evaluation.
149
+ - `kind`: `sample` (default) or `recording`. A whole raw recording used as a graph
150
+ parent should be `recording`; merely sharing that parent does not fail lineage.
151
+ Interval or subject checks still apply. Do not label an actual shared training
152
+ example as a recording to bypass the check.
153
+ - `subject_id`: canonical ID; namespace IDs that are only unique within a site.
154
+ - `recording_id`: canonical original-recording ID, not a derived filename.
155
+ - `coordinate_system`: explicit common coordinate label, e.g.
156
+ `original-samples-256hz` or `original-seconds`.
157
+ - `start`, `end`: finite values in that original coordinate system, `[start,end)`.
158
+ Touching intervals are not overlapping. Values are not rounded implicitly.
159
+
160
+ The library does not automatically inherit time intervals or subject metadata
161
+ through arbitrary transformations. Attach canonical metadata to assigned leaves.
162
+ `map_interval` only implements a supplied positive affine mapping. Nonlinear
163
+ warps, filter support, padding and unknown clock shifts require user-provided
164
+ provenance or must remain unverified.
165
+
166
+ Assignments contain `sample_id`, `split` (`train`, `validation`, `test`) and `fold`
167
+ (string, default `"0"`). Each fold needs train and an evaluation partition. All
168
+ partition pairs within a fold are checked. Ordinary reuse across independent
169
+ folds is allowed; nested-CV semantics are not implemented.
170
+
171
+ ## Policies and coverage
172
+
173
+ - `cross_subject`: subject IDs must not cross partitions. No global time ordering
174
+ is required. Shared source examples and raw interval overlap are also checked.
175
+ - `within_recording_future`: subject sharing is allowed; within each common
176
+ recording/coordinate system, train must end no later than validation/test
177
+ starts, and validation must precede test. Cross-recording chronological claims
178
+ are UNKNOWN because clocks are not assumed comparable. This narrowly scoped
179
+ policy does not audit future-target availability or preprocessing fit history.
180
+
181
+ `lineage_complete=True` is a declaration by the caller, not something the library
182
+ can prove. Omitting it makes lineage coverage UNKNOWN even if no shared ancestor
183
+ was found. Missing subject or interval metadata makes affected checks UNKNOWN.
184
+ A proven violation makes the overall result FAIL even if other checks are UNKNOWN.
185
+ PASS means only that the supplied metadata satisfies the applicable rules.
186
+
187
+ Results include per-fold checks, unknown-pair counts, violation counts and evidence
188
+ paths. `max_findings` limits returned examples, not failure counts. HTML/JSON
189
+ reports include identifiers and paths; keep them local or redact before sharing.
190
+ The tool performs no network requests, uploads, model training or automatic edits.
191
+
192
+ ## Validation performed
193
+
194
+ On 2026-11-09, Python 3.14.7:
195
+
196
+ - 23 standard-library tests passed, including malformed manifests, parent cycles,
197
+ multiple parents, interval boundaries, coordinate mapping, missing metadata,
198
+ ordinary K-fold reuse, validation/test contamination and HTML escaping.
199
+ - 7 synthetic scenarios matched their expected PASS/FAIL/UNKNOWN outcomes.
200
+ - Compared against **leakfence 0.5.0**, NumPy 2.5.4 in an isolated environment.
201
+ Subject mixing and interval overlap were detected by both tools. Clean splits
202
+ were not flagged. The full machine-readable output is `results/comparison.json`.
203
+
204
+ In the derived-source case, leakfence's native input has no parent-graph field.
205
+ Calling it with only the available native metadata produced no flag; that is not
206
+ a safety verdict. **Manually grouping leaves by source also made leakfence detect
207
+ the problem.** Our demonstrated distinction is accepting the parent graph and
208
+ returning the chain of evidence, not a uniquely powerful detection algorithm.
209
+
210
+ The comparison uses the same available supported metadata, with the manual
211
+ source-group adapter shown separately. It is a small functional comparison, not
212
+ an independent benchmark, speed test, accuracy improvement, or proof of demand.
213
+ Deepchecks and other competitors were not executed in this prototype comparison.
214
+
215
+ Reproduce tests and the nearest-competitor comparison:
216
+
217
+ ```sh
218
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
219
+ python3 -m venv /tmp/splittrace-comparison
220
+ /tmp/splittrace-comparison/bin/python -m pip install -r requirements-comparison.txt
221
+ /tmp/splittrace-comparison/bin/python scripts/compare.py
222
+ ```
223
+
224
+ ## Deliberate limits
225
+
226
+ Pairwise scanning is quadratic in partition sizes; ancestor-path storage also
227
+ grows with graph size/depth. Use small manifests. There is no performance claim.
228
+ No approximate content matching, nested-CV audit, persistent exception management,
229
+ automatic clock recovery, preprocessing tracing, causal/label leakage analysis or
230
+ proof of independence is implemented. Synthetic tests are not real-user validation.
231
+
232
+ Feedback is welcome from EEG, wearable-sensor and other windowed-signal projects:
233
+ can you supply source IDs/coordinates, and does a provenance path help you fix a
234
+ split? See [CONTRIBUTING.md](https://github.com/R-Koda/splittrace/blob/main/CONTRIBUTING.md) and the Japanese [trial guide](https://github.com/R-Koda/splittrace/blob/main/PILOT.md).
235
+
236
+ ## References
237
+
238
+ - [leakfence](https://github.com/ptapal/leakfence)
239
+ - [Deepchecks train-test validation](https://docs.deepchecks.com/stable/api/generated/deepchecks.tabular.suites.train_test_validation.html)
240
+ - [bioLeak](https://github.com/selcukorkmaz/bioLeak)
241
+
242
+ This prototype contains no copied competitor implementation. Optional comparison
243
+ dependencies retain their own licenses and are installed separately.
244
+
245
+ ## License
246
+
247
+ [MIT](https://github.com/R-Koda/splittrace/blob/main/LICENSE). Copyright (c) 2026 Ryu Koda.
@@ -0,0 +1,9 @@
1
+ splittrace/__init__.py,sha256=Jwghan426MN0ra9fJY2R6Ewc_97WhFYXHIPbdWvjngA,151
2
+ splittrace/cli.py,sha256=pdNF3l11prR6sMQIcUeHjkv7VKMY5b0L0ujKIOTQlGI,3367
3
+ splittrace/core.py,sha256=gzSslCR1nKYgFSttouzVjDv8TzPyXNzapALRns5-Fh4,11000
4
+ splittrace-0.1.0.dist-info/licenses/LICENSE,sha256=g92amfOtr0v2qgLJkCaw1xM9vtUm6PuYqjNM5rUYReE,1065
5
+ splittrace-0.1.0.dist-info/METADATA,sha256=JAkcL4_kqt-TYd1h0xxbiGWq7nbrxzg32w5waRrieeU,12161
6
+ splittrace-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ splittrace-0.1.0.dist-info/entry_points.txt,sha256=o_z4-hEqty3zFUgigWDt9WNDjciBV1SgjtZhutiz42c,51
8
+ splittrace-0.1.0.dist-info/top_level.txt,sha256=yG-_ixhhEk_j318DZ-V3Vx-dly47HBH3FaQpePllsjk,11
9
+ splittrace-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ splittrace = splittrace.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryu Koda
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 @@
1
+ splittrace