kt-audit 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.
kt_audit/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from .audit import audit_frame, audit_path
2
+ from .config import AuditConfig, load_config
3
+ from .errors import AuditError
4
+ from .schema import Finding, Report
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "AuditConfig",
10
+ "AuditError",
11
+ "Finding",
12
+ "Report",
13
+ "audit_frame",
14
+ "audit_path",
15
+ "load_config",
16
+ ]
kt_audit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
kt_audit/audit.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from typing import cast
6
+
7
+ import pandas as pd
8
+
9
+ from .checks.alignment import check_alignment
10
+ from .checks.multikc import check_multikc
11
+ from .checks.temporal import check_temporal
12
+ from .config import AuditConfig
13
+ from .errors import AuditError
14
+ from .schema import Finding, Report, Verdict, prepare_frame
15
+
16
+ SCHEMA_VERSION = "0.1"
17
+ _SEVERITY_ORDER = {"error": 0, "warn": 1, "info": 2}
18
+
19
+
20
+ def audit_frame(df: pd.DataFrame, config: AuditConfig | None = None) -> Report:
21
+ config = config or AuditConfig()
22
+ prepared = prepare_frame(df, config)
23
+ findings: list[Finding] = []
24
+ if config.temporal:
25
+ findings.extend(check_temporal(prepared, config))
26
+ if config.multikc:
27
+ findings.extend(check_multikc(prepared, config))
28
+ metrics: dict[str, dict[str, object]] = {}
29
+ if config.alignment:
30
+ alignment_findings, metrics = check_alignment(prepared, config)
31
+ findings.extend(alignment_findings)
32
+ findings.sort(key=_finding_sort_key)
33
+ return Report(
34
+ schema_version=SCHEMA_VERSION,
35
+ generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
36
+ input=_input_summary(prepared),
37
+ findings=tuple(findings),
38
+ metrics=metrics,
39
+ verdict=_verdict(findings),
40
+ )
41
+
42
+
43
+ def audit_path(path: str | Path, config: AuditConfig | None = None) -> Report:
44
+ path = Path(path)
45
+ if not path.exists():
46
+ raise AuditError(f"input not found: {path}")
47
+ suffix = path.suffix.lower()
48
+ if suffix in {".jsonl", ".ndjson"}:
49
+ df = pd.read_json(path, lines=True)
50
+ elif suffix == ".csv":
51
+ df = pd.read_csv(path)
52
+ else:
53
+ raise AuditError(f"unsupported input format: {suffix}; use .csv or .jsonl")
54
+ return audit_frame(df, config)
55
+
56
+
57
+ def _finding_sort_key(finding: Finding) -> tuple[int, str, str]:
58
+ return (_SEVERITY_ORDER[finding.severity], finding.check, finding.message)
59
+
60
+
61
+ def _verdict(findings: list[Finding]) -> Verdict:
62
+ severities = {finding.severity for finding in findings}
63
+ if "error" in severities:
64
+ return "fail"
65
+ if "warn" in severities:
66
+ return "warn"
67
+ return "pass"
68
+
69
+
70
+ def _input_summary(df: pd.DataFrame) -> dict[str, object]:
71
+ splits = {
72
+ str(key): int(value) for key, value in df["split"].value_counts().sort_index().items()
73
+ }
74
+ has_items = bool(df["item_id"].notna().any())
75
+ return {
76
+ "rows": len(df),
77
+ "users": int(cast(int, df["user_id"].nunique())),
78
+ "items": int(cast(int, df["item_id"].nunique())) if has_items else None,
79
+ "folds": int(cast(int, df["_fold"].nunique())),
80
+ "splits": splits,
81
+ }
@@ -0,0 +1,5 @@
1
+ from .alignment import check_alignment
2
+ from .multikc import check_multikc
3
+ from .temporal import check_temporal
4
+
5
+ __all__ = ["check_alignment", "check_multikc", "check_temporal"]
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+
8
+ from ..schema import jsonable
9
+
10
+
11
+ def iter_folds(df: pd.DataFrame) -> Iterator[tuple[str, pd.DataFrame]]:
12
+ for fold, group in df.groupby("_fold", sort=True, dropna=False):
13
+ yield str(fold), group
14
+
15
+
16
+ def fold_example(fold: str, example: dict[str, Any]) -> dict[str, object]:
17
+ values = {key: jsonable(value) for key, value in example.items()}
18
+ if fold != "__all__":
19
+ return {"fold": fold, **values}
20
+ return values
21
+
22
+
23
+ def examples_with_fold(
24
+ frame: pd.DataFrame,
25
+ columns: tuple[str, ...],
26
+ max_examples: int,
27
+ ) -> tuple[dict[str, object], ...]:
28
+ examples = []
29
+ for _, row in frame.head(max_examples).iterrows():
30
+ values = {column: row[column] for column in columns if column in row.index}
31
+ examples.append(fold_example(str(row["_fold"]), values))
32
+ return tuple(examples)
33
+
34
+
35
+ def examples_from_frame(
36
+ frame: pd.DataFrame,
37
+ columns: tuple[str, ...],
38
+ max_examples: int,
39
+ fold: str,
40
+ ) -> tuple[dict[str, object], ...]:
41
+ examples = []
42
+ for _, row in frame.head(max_examples).iterrows():
43
+ values = {column: row[column] for column in columns if column in row.index}
44
+ examples.append(fold_example(fold, values))
45
+ return tuple(examples)
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.metrics import accuracy_score, roc_auc_score
8
+
9
+ from ..config import AuditConfig
10
+ from ..schema import SPLITS, Finding
11
+ from ._common import examples_with_fold, fold_example, iter_folds
12
+
13
+
14
+ def check_alignment(
15
+ df: pd.DataFrame,
16
+ config: AuditConfig,
17
+ ) -> tuple[list[Finding], dict[str, dict[str, object]]]:
18
+ if "y_pred" not in df.columns:
19
+ return (
20
+ [
21
+ Finding(
22
+ "alignment",
23
+ "info",
24
+ "y_pred is missing; metric checks skipped",
25
+ 0,
26
+ (),
27
+ )
28
+ ],
29
+ {},
30
+ )
31
+
32
+ findings: list[Finding] = []
33
+ findings.extend(_invalid_findings(df, config))
34
+ findings.extend(_duplicate_findings(df, config))
35
+ findings.extend(_constant_findings(df))
36
+ metrics = _metrics(df)
37
+ findings.extend(_reported_findings(metrics, config))
38
+ return findings, metrics
39
+
40
+
41
+ def _invalid_findings(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
42
+ pred = df["y_pred"].astype("float64")
43
+ true = df["y_true"].astype("float64")
44
+ invalid_pred = ~np.isfinite(pred) | (pred < 0) | (pred > 1)
45
+ invalid_true = ~true.isin([0.0, 1.0])
46
+ findings: list[Finding] = []
47
+ if bool(invalid_pred.any()):
48
+ examples = examples_with_fold(
49
+ cast(pd.DataFrame, df.loc[invalid_pred]),
50
+ ("_row", "user_id", "item_id", "y_pred"),
51
+ config.max_examples,
52
+ )
53
+ findings.append(
54
+ Finding(
55
+ "alignment",
56
+ "error",
57
+ "y_pred has missing, non-finite, or out-of-range values",
58
+ int(np.count_nonzero(invalid_pred)),
59
+ examples,
60
+ )
61
+ )
62
+ if bool(invalid_true.any()):
63
+ examples = examples_with_fold(
64
+ cast(pd.DataFrame, df.loc[invalid_true]),
65
+ ("_row", "user_id", "item_id", "y_true"),
66
+ config.max_examples,
67
+ )
68
+ findings.append(
69
+ Finding(
70
+ "alignment",
71
+ "error",
72
+ "y_true is not binary",
73
+ int(np.count_nonzero(invalid_true.to_numpy())),
74
+ examples,
75
+ )
76
+ )
77
+ return findings
78
+
79
+
80
+ def _duplicate_findings(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
81
+ keys = _key_columns(df)
82
+ extra = df.duplicated(subset=keys, keep="first")
83
+ if not bool(extra.any()):
84
+ return []
85
+ examples = examples_with_fold(
86
+ cast(pd.DataFrame, df.loc[df.duplicated(subset=keys, keep=False)]),
87
+ ("_row", "user_id", "item_id", "timestamp"),
88
+ config.max_examples,
89
+ )
90
+ return [
91
+ Finding(
92
+ "alignment",
93
+ "error",
94
+ "duplicate interaction keys",
95
+ int(np.count_nonzero(extra.to_numpy())),
96
+ examples,
97
+ )
98
+ ]
99
+
100
+
101
+ def _constant_findings(df: pd.DataFrame) -> list[Finding]:
102
+ findings: list[Finding] = []
103
+ for fold, group in iter_folds(df):
104
+ if group["y_pred"].nunique(dropna=True) <= 1:
105
+ findings.append(
106
+ Finding(
107
+ "alignment",
108
+ "warn",
109
+ "predictions are constant within this fold",
110
+ 1,
111
+ (fold_example(fold, {"n": len(group)}),),
112
+ )
113
+ )
114
+ return findings
115
+
116
+
117
+ def _key_columns(df: pd.DataFrame) -> list[str]:
118
+ if bool(df["item_id"].notna().any()):
119
+ return ["_fold", "user_id", "item_id", "timestamp"]
120
+ if {"sequence_id", "position"}.issubset(df.columns):
121
+ return ["_fold", "user_id", "sequence_id", "position"]
122
+ return ["_fold", "user_id", "timestamp"]
123
+
124
+
125
+ def _metrics(df: pd.DataFrame) -> dict[str, dict[str, object]]:
126
+ valid = df.dropna(subset=["y_true", "y_pred"]).copy()
127
+ pred = valid["y_pred"].astype("float64")
128
+ true = valid["y_true"].astype("float64")
129
+ mask = np.isfinite(pred) & pred.between(0, 1) & true.isin([0.0, 1.0])
130
+ valid = cast(pd.DataFrame, valid.loc[mask])
131
+ metrics: dict[str, dict[str, object]] = {}
132
+ for split in SPLITS:
133
+ part = cast(pd.DataFrame, valid[valid["split"] == split])
134
+ if part.empty:
135
+ continue
136
+ y = cast(pd.Series, part["y_true"]).astype(int)
137
+ p = cast(pd.Series, part["y_pred"]).astype(float)
138
+ auc = float(roc_auc_score(y, p)) if y.nunique() == 2 else None
139
+ acc = float(accuracy_score(y, (p >= 0.5).astype(int)))
140
+ metrics[split] = {"auc": auc, "acc": acc, "n": len(part)}
141
+ return metrics
142
+
143
+
144
+ def _reported_findings(
145
+ metrics: dict[str, dict[str, object]],
146
+ config: AuditConfig,
147
+ ) -> list[Finding]:
148
+ reported = config.reported_auc
149
+ if reported is None:
150
+ return []
151
+ test_auc = metrics.get("test", {}).get("auc")
152
+ if not isinstance(test_auc, (int, float)):
153
+ return [
154
+ Finding(
155
+ "alignment",
156
+ "warn",
157
+ "reported_auc is set but test AUC cannot be computed",
158
+ 0,
159
+ (),
160
+ )
161
+ ]
162
+ delta = abs(float(test_auc) - reported)
163
+ if delta <= config.auc_tolerance:
164
+ return []
165
+ return [
166
+ Finding(
167
+ "alignment",
168
+ "error",
169
+ "reported test AUC differs from recomputed AUC",
170
+ 1,
171
+ (
172
+ {
173
+ "reported_auc": reported,
174
+ "recomputed_auc": float(test_auc),
175
+ "delta": delta,
176
+ },
177
+ ),
178
+ )
179
+ ]
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from ..config import AuditConfig
9
+ from ..schema import Finding
10
+ from ._common import fold_example, iter_folds
11
+
12
+
13
+ def check_multikc(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
14
+ findings: list[Finding] = []
15
+ for fold, group in iter_folds(df):
16
+ findings.extend(_check_fold(fold, group, config))
17
+ return findings
18
+
19
+
20
+ def _check_fold(fold: str, group: pd.DataFrame, config: AuditConfig) -> list[Finding]:
21
+ if bool(group["item_id"].isna().all()):
22
+ return [
23
+ Finding(
24
+ "multi_kc",
25
+ "info",
26
+ "item_id is missing; multi-KC leakage check skipped",
27
+ 0,
28
+ (),
29
+ )
30
+ ]
31
+ working = group.dropna(subset=["item_id"])
32
+ findings = _missing_findings(group)
33
+ if working.empty:
34
+ return findings
35
+ item_splits, item_kcs, kc_splits = _aggregate(working)
36
+ overlap_items, cross_items = _leaks(item_splits, item_kcs, kc_splits)
37
+ findings.extend(
38
+ _leak_findings(
39
+ overlap_items,
40
+ cross_items,
41
+ item_kcs,
42
+ item_splits,
43
+ kc_splits,
44
+ fold,
45
+ config,
46
+ )
47
+ )
48
+ return findings
49
+
50
+
51
+ def _missing_findings(group: pd.DataFrame) -> list[Finding]:
52
+ missing = int(np.count_nonzero(group["item_id"].isna().to_numpy()))
53
+ if not missing:
54
+ return []
55
+ return [
56
+ Finding(
57
+ "multi_kc",
58
+ "warn",
59
+ "rows with missing item_id were skipped",
60
+ missing,
61
+ (),
62
+ )
63
+ ]
64
+
65
+
66
+ def _aggregate(working: pd.DataFrame) -> tuple[pd.Series, pd.Series, pd.Series]:
67
+ item_splits = cast(
68
+ pd.Series,
69
+ working.groupby("item_id")["split"].agg(lambda values: tuple(sorted(set(values)))),
70
+ )
71
+ item_kcs = cast(pd.Series, working.groupby("item_id")["kc_ids"].agg(_union_kcs))
72
+ exploded = working.explode("kc_ids")
73
+ kc_splits = cast(
74
+ pd.Series,
75
+ exploded.groupby("kc_ids")["split"].agg(lambda values: frozenset(values)),
76
+ )
77
+ return item_splits, item_kcs, kc_splits
78
+
79
+
80
+ def _leaks(
81
+ item_splits: pd.Series,
82
+ item_kcs: pd.Series,
83
+ kc_splits: pd.Series,
84
+ ) -> tuple[list[str], list[str]]:
85
+ overlap_items: list[str] = []
86
+ cross_items: list[str] = []
87
+ for item_id, kcs in item_kcs.items():
88
+ splits = set(item_splits[item_id])
89
+ if not {"train", "test"}.issubset(splits):
90
+ continue
91
+ overlap_items.append(str(item_id))
92
+ train_only = [kc for kc in kcs if set(kc_splits[kc]) == {"train"}]
93
+ test_only = [kc for kc in kcs if set(kc_splits[kc]) == {"test"}]
94
+ if train_only and test_only:
95
+ cross_items.append(str(item_id))
96
+ return overlap_items, cross_items
97
+
98
+
99
+ def _leak_findings(
100
+ overlap_items: list[str],
101
+ cross_items: list[str],
102
+ item_kcs: pd.Series,
103
+ item_splits: pd.Series,
104
+ kc_splits: pd.Series,
105
+ fold: str,
106
+ config: AuditConfig,
107
+ ) -> list[Finding]:
108
+ findings: list[Finding] = []
109
+ if overlap_items:
110
+ examples = tuple(
111
+ _example(item, item_kcs, item_splits, kc_splits, fold)
112
+ for item in overlap_items[: config.max_examples]
113
+ )
114
+ findings.append(
115
+ Finding(
116
+ "multi_kc",
117
+ "warn",
118
+ "the same item appears in train and test",
119
+ len(overlap_items),
120
+ examples,
121
+ )
122
+ )
123
+ if cross_items:
124
+ examples = tuple(
125
+ _example(item, item_kcs, item_splits, kc_splits, fold)
126
+ for item in cross_items[: config.max_examples]
127
+ )
128
+ findings.append(
129
+ Finding(
130
+ "multi_kc",
131
+ "error",
132
+ "an item has train-only and test-only knowledge components",
133
+ len(cross_items),
134
+ examples,
135
+ )
136
+ )
137
+ return findings
138
+
139
+
140
+ def _union_kcs(values: pd.Series) -> tuple[str, ...]:
141
+ return tuple(sorted({kc for value in values for kc in value}))
142
+
143
+
144
+ def _example(
145
+ item_id: str,
146
+ item_kcs: pd.Series,
147
+ item_splits: pd.Series,
148
+ kc_splits: pd.Series,
149
+ fold: str,
150
+ ) -> dict[str, object]:
151
+ kcs = item_kcs[item_id]
152
+ return fold_example(
153
+ fold,
154
+ {
155
+ "item_id": item_id,
156
+ "splits": list(item_splits[item_id]),
157
+ "kc_ids": list(kcs),
158
+ "kc_splits": {kc: sorted(kc_splits[kc]) for kc in kcs},
159
+ },
160
+ )
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ import pandas as pd
6
+
7
+ from ..config import AuditConfig
8
+ from ..schema import Finding
9
+ from ._common import examples_from_frame, fold_example, iter_folds
10
+
11
+
12
+ def check_temporal(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
13
+ findings: list[Finding] = []
14
+ for fold, group in iter_folds(df):
15
+ findings.extend(_check_fold(fold, group, config))
16
+ return findings
17
+
18
+
19
+ def _check_fold(fold: str, group: pd.DataFrame, config: AuditConfig) -> list[Finding]:
20
+ train = cast(pd.DataFrame, group[group["split"] == "train"])
21
+ evaluation = cast(pd.DataFrame, group[group["split"].isin(["valid", "test"])])
22
+ if train.empty or evaluation.empty:
23
+ return []
24
+ findings = _global_findings(fold, train, evaluation)
25
+ findings.extend(_overlap_findings(fold, group, config))
26
+ findings.extend(_violation_findings(fold, train, evaluation, config))
27
+ return findings
28
+
29
+
30
+ def _global_findings(
31
+ fold: str,
32
+ train: pd.DataFrame,
33
+ evaluation: pd.DataFrame,
34
+ ) -> list[Finding]:
35
+ train_max: Any = train["timestamp"].max()
36
+ eval_min: Any = evaluation["timestamp"].min()
37
+ if train_max < eval_min:
38
+ return []
39
+ example = fold_example(fold, {"train_max": train_max, "eval_min": eval_min})
40
+ return [
41
+ Finding(
42
+ "temporal_split",
43
+ "error",
44
+ "training events are not strictly before evaluation events",
45
+ 1,
46
+ (example,),
47
+ )
48
+ ]
49
+
50
+
51
+ def _overlap_findings(
52
+ fold: str,
53
+ group: pd.DataFrame,
54
+ config: AuditConfig,
55
+ ) -> list[Finding]:
56
+ split_counts = group.groupby("user_id")["split"].nunique()
57
+ multi = cast(pd.Series, split_counts[split_counts > 1])
58
+ if multi.empty:
59
+ return []
60
+ users = (
61
+ group[group["user_id"].isin(multi.index.tolist())]
62
+ .groupby("user_id")["split"]
63
+ .agg(lambda values: tuple(sorted(set(values))))
64
+ )
65
+ examples = tuple(
66
+ fold_example(fold, {"user_id": user, "splits": splits})
67
+ for user, splits in users.head(config.max_examples).items()
68
+ )
69
+ return [
70
+ Finding(
71
+ "temporal_split",
72
+ "warn",
73
+ "users appear in more than one split",
74
+ len(multi),
75
+ examples,
76
+ )
77
+ ]
78
+
79
+
80
+ def _violation_findings(
81
+ fold: str,
82
+ train: pd.DataFrame,
83
+ evaluation: pd.DataFrame,
84
+ config: AuditConfig,
85
+ ) -> list[Finding]:
86
+ train_max_by_user = train.groupby("user_id")["timestamp"].max()
87
+ eval_min_by_user = evaluation.groupby("user_id")["timestamp"].min()
88
+ merged = pd.DataFrame({"train_max": train_max_by_user, "eval_min": eval_min_by_user}).dropna()
89
+ violations = cast(pd.DataFrame, merged[merged["train_max"] >= merged["eval_min"]])
90
+ if violations.empty:
91
+ return []
92
+ examples = examples_from_frame(
93
+ violations.reset_index(),
94
+ ("user_id", "train_max", "eval_min"),
95
+ config.max_examples,
96
+ fold,
97
+ )
98
+ return [
99
+ Finding(
100
+ "temporal_split",
101
+ "error",
102
+ "a user's training events overlap or follow evaluation events",
103
+ len(violations),
104
+ examples,
105
+ )
106
+ ]
kt_audit/cli.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .audit import audit_path
10
+ from .config import load_config
11
+ from .errors import AuditError
12
+ from .report import report_to_markdown, write_json
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="kt-audit",
18
+ description="Audit knowledge-tracing evaluation before you trust the AUC.",
19
+ )
20
+ subparsers = parser.add_subparsers(dest="command", required=True)
21
+ run = subparsers.add_parser("run", help="audit a CSV or JSONL file")
22
+ run.add_argument("input", type=Path, help="input CSV or JSONL")
23
+ run.add_argument(
24
+ "--out",
25
+ type=Path,
26
+ default=Path("kt-audit-report"),
27
+ help="output directory (default: kt-audit-report)",
28
+ )
29
+ run.add_argument("--config", type=Path, default=None, help="JSON config")
30
+ run.add_argument("--no-temporal", action="store_true", help="skip temporal split check")
31
+ run.add_argument("--no-multikc", action="store_true", help="skip multi-KC check")
32
+ run.add_argument("--no-alignment", action="store_true", help="skip prediction alignment check")
33
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
34
+ return parser
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> int:
38
+ parser = build_parser()
39
+ args = parser.parse_args(argv)
40
+ try:
41
+ config = load_config(args.config)
42
+ config = replace(
43
+ config,
44
+ temporal=config.temporal and not args.no_temporal,
45
+ multikc=config.multikc and not args.no_multikc,
46
+ alignment=config.alignment and not args.no_alignment,
47
+ )
48
+ report = audit_path(args.input, config)
49
+ except (AuditError, FileNotFoundError, ValueError) as exc:
50
+ print(f"kt-audit: {exc}", file=sys.stderr)
51
+ return 2
52
+
53
+ args.out.mkdir(parents=True, exist_ok=True)
54
+ write_json(report, args.out / "report.json")
55
+ (args.out / "report.md").write_text(report_to_markdown(report), encoding="utf-8")
56
+ errors = sum(finding.severity == "error" for finding in report.findings)
57
+ warnings = sum(finding.severity == "warn" for finding in report.findings)
58
+ print(f"verdict: {report.verdict}")
59
+ print(f"findings: {errors} error, {warnings} warn")
60
+ print(f"report: {args.out / 'report.md'}")
61
+ return 1 if report.verdict == "fail" else 0
kt_audit/config.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, fields
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ from .errors import AuditError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AuditConfig:
13
+ kc_separator: str = "|"
14
+ temporal: bool = True
15
+ multikc: bool = True
16
+ alignment: bool = True
17
+ max_examples: int = 3
18
+ reported_auc: float | None = None
19
+ auc_tolerance: float = 0.01
20
+
21
+ def __post_init__(self) -> None:
22
+ if not self.kc_separator:
23
+ raise ValueError("kc_separator must not be empty")
24
+ if self.max_examples < 1:
25
+ raise ValueError("max_examples must be >= 1")
26
+ if self.auc_tolerance < 0:
27
+ raise ValueError("auc_tolerance must be >= 0")
28
+ if self.reported_auc is not None and not 0 <= self.reported_auc <= 1:
29
+ raise ValueError("reported_auc must be in [0, 1]")
30
+
31
+
32
+ def load_config(path: str | Path | None) -> AuditConfig:
33
+ if path is None:
34
+ return AuditConfig()
35
+ path = Path(path)
36
+ try:
37
+ raw = json.loads(path.read_text(encoding="utf-8"))
38
+ except FileNotFoundError as exc:
39
+ raise AuditError(f"config not found: {path}") from exc
40
+ except json.JSONDecodeError as exc:
41
+ raise AuditError(f"invalid JSON in {path}: {exc}") from exc
42
+ if not isinstance(raw, dict):
43
+ raise AuditError("config must be a JSON object")
44
+ allowed = {item.name for item in fields(AuditConfig)}
45
+ unknown = sorted(set(raw) - allowed)
46
+ if unknown:
47
+ raise AuditError(f"unknown config keys: {', '.join(unknown)}")
48
+ try:
49
+ return AuditConfig(**cast(dict[str, Any], raw))
50
+ except (TypeError, ValueError) as exc:
51
+ raise AuditError(f"invalid config: {exc}") from exc
kt_audit/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class AuditError(ValueError):
2
+ """Raised when the input or configuration cannot be audited."""
kt_audit/py.typed ADDED
File without changes
kt_audit/report.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .schema import Finding, Report, jsonable
7
+
8
+
9
+ def finding_to_dict(finding: Finding) -> dict[str, object]:
10
+ return {
11
+ "check": finding.check,
12
+ "severity": finding.severity,
13
+ "message": finding.message,
14
+ "count": finding.count,
15
+ "examples": jsonable(finding.examples),
16
+ }
17
+
18
+
19
+ def report_to_dict(report: Report) -> dict[str, object]:
20
+ return {
21
+ "schema_version": report.schema_version,
22
+ "generated_at": report.generated_at,
23
+ "verdict": report.verdict,
24
+ "input": jsonable(report.input),
25
+ "metrics": jsonable(report.metrics),
26
+ "findings": [finding_to_dict(finding) for finding in report.findings],
27
+ }
28
+
29
+
30
+ def write_json(report: Report, path: Path) -> None:
31
+ payload = json.dumps(report_to_dict(report), indent=2, ensure_ascii=False)
32
+ path.write_text(payload + "\n", encoding="utf-8")
33
+
34
+
35
+ def report_to_markdown(report: Report) -> str:
36
+ lines = _header_lines(report)
37
+ lines.extend(["", "## Findings"])
38
+ if not report.findings:
39
+ lines.extend(["", "No findings."])
40
+ for finding in report.findings:
41
+ lines.extend(_finding_lines(finding))
42
+ lines.extend(_metrics_lines(report.metrics))
43
+ lines.extend(_limitation_lines())
44
+ return "\n".join(lines) + "\n"
45
+
46
+
47
+ def _header_lines(report: Report) -> list[str]:
48
+ lines = [
49
+ "# kt-audit report",
50
+ "",
51
+ f"**Verdict: {report.verdict.upper()}**",
52
+ "",
53
+ f"- generated: {report.generated_at}",
54
+ f"- schema: {report.schema_version}",
55
+ f"- rows: {report.input.get('rows')}",
56
+ f"- users: {report.input.get('users')}",
57
+ f"- items: {report.input.get('items')}",
58
+ ]
59
+ splits = report.input.get("splits")
60
+ if isinstance(splits, dict):
61
+ rendered = ", ".join(f"{key}={value}" for key, value in sorted(splits.items()))
62
+ lines.append(f"- splits: {rendered}")
63
+ return lines
64
+
65
+
66
+ def _finding_lines(finding: Finding) -> list[str]:
67
+ lines = [
68
+ "",
69
+ f"### {finding.severity.upper()} · {finding.check}",
70
+ "",
71
+ f"{finding.message} (count: {finding.count})",
72
+ ]
73
+ if finding.examples:
74
+ payload = json.dumps(jsonable(finding.examples), indent=2, ensure_ascii=False)
75
+ lines.extend(["", "```json", payload, "```"])
76
+ return lines
77
+
78
+
79
+ def _metrics_lines(metrics: dict[str, dict[str, object]]) -> list[str]:
80
+ if not metrics:
81
+ return []
82
+ lines = [
83
+ "",
84
+ "## Metrics",
85
+ "",
86
+ "| Split | n | AUC | Accuracy |",
87
+ "| --- | ---: | ---: | ---: |",
88
+ ]
89
+ for split in sorted(metrics):
90
+ metric = metrics[split]
91
+ lines.append(
92
+ f"| {split} | {metric.get('n')} | {_format_metric(metric.get('auc'))} "
93
+ f"| {_format_metric(metric.get('acc'))} |"
94
+ )
95
+ return lines
96
+
97
+
98
+ def _limitation_lines() -> list[str]:
99
+ return [
100
+ "",
101
+ "## Limitations",
102
+ "",
103
+ "- Findings are heuristics, not causal proof.",
104
+ "- The multi-KC check needs `item_id`; it is skipped when missing.",
105
+ "- The temporal check assumes timestamps are comparable within a user.",
106
+ "- A passing report does not prove that a model is valid.",
107
+ ]
108
+
109
+
110
+ def _format_metric(value: object) -> str:
111
+ if value is None:
112
+ return "n/a"
113
+ if isinstance(value, float):
114
+ return f"{value:.4f}"
115
+ return str(value)
kt_audit/schema.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Literal, cast
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from .config import AuditConfig
12
+ from .errors import AuditError
13
+
14
+ Severity = Literal["error", "warn", "info"]
15
+ Verdict = Literal["pass", "warn", "fail"]
16
+ SPLITS: tuple[str, ...] = ("train", "valid", "test")
17
+ _SPLIT_ALIASES = {
18
+ "train": "train",
19
+ "valid": "valid",
20
+ "validation": "valid",
21
+ "val": "valid",
22
+ "test": "test",
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Finding:
28
+ check: str
29
+ severity: Severity
30
+ message: str
31
+ count: int = 0
32
+ examples: tuple[dict[str, object], ...] = ()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Report:
37
+ schema_version: str
38
+ generated_at: str
39
+ input: dict[str, object]
40
+ findings: tuple[Finding, ...]
41
+ metrics: dict[str, dict[str, object]]
42
+ verdict: Verdict
43
+
44
+
45
+ def _is_missing(value: object) -> bool:
46
+ if value is None or value is pd.NA:
47
+ return True
48
+ if isinstance(value, (list, tuple, set, dict)):
49
+ return False
50
+ try:
51
+ return bool(pd.isna(value))
52
+ except (TypeError, ValueError):
53
+ return False
54
+
55
+
56
+ def normalize_split(value: object) -> str:
57
+ if _is_missing(value):
58
+ raise AuditError("split is missing")
59
+ text = str(value).strip().lower()
60
+ if text not in _SPLIT_ALIASES:
61
+ raise AuditError(f"invalid split: {value!r}")
62
+ return _SPLIT_ALIASES[text]
63
+
64
+
65
+ def parse_kc_ids(value: object, separator: str) -> tuple[str, ...]:
66
+ if isinstance(value, str):
67
+ text = value.strip()
68
+ if text.startswith(("[", "{")):
69
+ try:
70
+ parsed = json.loads(text)
71
+ except json.JSONDecodeError as exc:
72
+ raise AuditError(f"invalid kc_ids JSON: {value!r}") from exc
73
+ if not isinstance(parsed, list):
74
+ raise AuditError(f"kc_ids JSON must be a list: {value!r}")
75
+ values = parsed
76
+ else:
77
+ values = text.split(separator)
78
+ elif isinstance(value, (list, tuple, set)):
79
+ values = list(value)
80
+ elif _is_missing(value):
81
+ raise AuditError("kc_ids is missing")
82
+ else:
83
+ raise AuditError(f"kc_ids must be a string or list: {value!r}")
84
+ cleaned = tuple(dict.fromkeys(str(item).strip() for item in values if str(item).strip()))
85
+ if not cleaned:
86
+ raise AuditError(f"kc_ids is empty: {value!r}")
87
+ return cleaned
88
+
89
+
90
+ def jsonable(value: object) -> object:
91
+ if isinstance(value, pd.Timestamp):
92
+ return cast(pd.Timestamp, value).isoformat()
93
+ if isinstance(value, np.datetime64):
94
+ return cast(pd.Timestamp, pd.Timestamp(value)).isoformat()
95
+ if isinstance(value, (list, tuple, set, frozenset)):
96
+ return [jsonable(item) for item in value]
97
+ if isinstance(value, dict):
98
+ return {str(key): jsonable(item) for key, item in value.items()}
99
+ if isinstance(value, np.integer):
100
+ return int(value)
101
+ if isinstance(value, np.floating):
102
+ return float(value)
103
+ if isinstance(value, np.bool_):
104
+ return bool(value)
105
+ if _is_missing(value):
106
+ return None
107
+ return value
108
+
109
+
110
+ def example_from_row(row: pd.Series, columns: tuple[str, ...]) -> dict[str, object]:
111
+ return {column: jsonable(row[column]) for column in columns if column in row.index}
112
+
113
+
114
+ def prepare_frame(df: pd.DataFrame, config: AuditConfig) -> pd.DataFrame:
115
+ if df.empty:
116
+ raise AuditError("input has no rows")
117
+ missing = [column for column in ("user_id", "timestamp", "split", "kc_ids") if column not in df]
118
+ if missing:
119
+ raise AuditError(f"missing required columns: {', '.join(missing)}")
120
+
121
+ out = df.copy().reset_index(drop=True)
122
+ out["_row"] = np.arange(1, len(out) + 1)
123
+ out["user_id"] = _to_string(_series(out, "user_id"), "user_id", required=True)
124
+ out["split"] = _map_rows(_series(out, "split"), normalize_split, "split")
125
+ out["kc_ids"] = _map_rows(
126
+ _series(out, "kc_ids"),
127
+ lambda value: parse_kc_ids(value, config.kc_separator),
128
+ "kc_ids",
129
+ )
130
+ out["timestamp"] = _parse_timestamps(_series(out, "timestamp"))
131
+
132
+ if "item_id" in out.columns:
133
+ out["item_id"] = _to_string(_series(out, "item_id"), "item_id", required=False)
134
+ else:
135
+ out["item_id"] = pd.Series(pd.NA, index=out.index, dtype="string")
136
+
137
+ if "fold" in out.columns:
138
+ out["_fold"] = _to_string(_series(out, "fold"), "fold", required=False).fillna("__all__")
139
+ else:
140
+ out["_fold"] = "__all__"
141
+
142
+ if "sequence_id" in out.columns:
143
+ out["sequence_id"] = _to_string(
144
+ _series(out, "sequence_id"),
145
+ "sequence_id",
146
+ required=False,
147
+ )
148
+ if "position" in out.columns:
149
+ out["position"] = pd.to_numeric(_series(out, "position"), errors="coerce")
150
+
151
+ has_pred = "y_pred" in out.columns
152
+ has_true = "y_true" in out.columns
153
+ if has_pred and not has_true:
154
+ raise AuditError("y_pred requires y_true")
155
+ if has_true:
156
+ out["y_true"] = pd.to_numeric(_series(out, "y_true"), errors="coerce")
157
+ if has_pred:
158
+ out["y_pred"] = pd.to_numeric(_series(out, "y_pred"), errors="coerce")
159
+ return out
160
+
161
+
162
+ def _series(df: pd.DataFrame, name: str) -> pd.Series:
163
+ return cast(pd.Series, df[name])
164
+
165
+
166
+ def _to_string(series: pd.Series, name: str, *, required: bool) -> pd.Series:
167
+ if required and bool(series.isna().any()):
168
+ raise AuditError(f"{name} contains missing values")
169
+ return series.astype("string")
170
+
171
+
172
+ def _map_rows(
173
+ series: pd.Series,
174
+ func: Callable[[object], object],
175
+ name: str,
176
+ ) -> pd.Series:
177
+ values: list[object] = []
178
+ for row_number, value in enumerate(series, start=1):
179
+ try:
180
+ values.append(func(value))
181
+ except AuditError as exc:
182
+ raise AuditError(f"row {row_number} column {name}: {exc}") from exc
183
+ return pd.Series(values, index=series.index, dtype="object")
184
+
185
+
186
+ def _parse_timestamps(series: pd.Series) -> pd.Series:
187
+ if bool(series.isna().any()):
188
+ raise AuditError("timestamp contains missing values")
189
+ if pd.api.types.is_numeric_dtype(series):
190
+ return series.astype("float64")
191
+ try:
192
+ parsed = pd.to_datetime(series, errors="raise", utc=True)
193
+ except (TypeError, ValueError) as exc:
194
+ raise AuditError(f"invalid timestamp: {exc}") from exc
195
+ if bool(parsed.isna().any()):
196
+ raise AuditError("timestamp contains unparseable values")
197
+ return parsed
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: kt-audit
3
+ Version: 0.1.0
4
+ Summary: Audit knowledge-tracing evaluation before you trust the AUC.
5
+ Project-URL: Homepage, https://github.com/summeriver13/kt-audit
6
+ Project-URL: Repository, https://github.com/summeriver13/kt-audit
7
+ Project-URL: Issues, https://github.com/summeriver13/kt-audit/issues
8
+ Author: Xia Jiang
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: benchmark,education,evaluation,knowledge-tracing,leakage
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: numpy>=1.26
24
+ Requires-Dist: pandas>=2.1
25
+ Requires-Dist: scikit-learn>=1.4
26
+ Provides-Extra: dev
27
+ Requires-Dist: basedpyright>=1.17; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
29
+ Requires-Dist: pytest>=8; extra == 'dev'
30
+ Requires-Dist: ruff>=0.6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # kt-audit
34
+
35
+ **Before you trust an AUC, audit the evaluation.**
36
+
37
+ `kt-audit` is a small, opinionated audit tool for knowledge-tracing (KT) experiments. It does not train models. It checks whether the evaluation setup behind a reported AUC is trustworthy.
38
+
39
+ Status: **alpha (0.1.0)**. The scope is intentionally narrow; see [docs/charter.md](docs/charter.md) for the scope and non-goals.
40
+
41
+ ## Why
42
+
43
+ KT papers and libraries report AUC, but the evaluation protocol is often under-specified:
44
+
45
+ - Are train events strictly before validation/test events for each user?
46
+ - Does the same item appear in train and test?
47
+ - When an item has multiple knowledge components (KCs), are some KCs train-only and others test-only?
48
+ - Do the reported metrics match a recomputation from the released predictions?
49
+
50
+ `kt-audit` turns those questions into a machine-readable report you can attach to a paper artifact, a PR, or a model release.
51
+
52
+ ## Install
53
+
54
+ Not on PyPI yet. From source:
55
+
56
+ ```bash
57
+ git clone https://github.com/summeriver13/kt-audit
58
+ cd kt-audit
59
+ uv sync
60
+ uv run kt-audit --help
61
+ ```
62
+
63
+ Or with pip:
64
+
65
+ ```bash
66
+ pip install -e .
67
+ ```
68
+
69
+ Requires Python 3.10+.
70
+
71
+ ## Quickstart
72
+
73
+ ```bash
74
+ uv run kt-audit run examples/leaky.csv --out /tmp/kt-audit-report
75
+ ```
76
+
77
+ Output:
78
+
79
+ ```text
80
+ verdict: fail
81
+ findings: 6 error, 2 warn
82
+ report: /tmp/kt-audit-report/report.md
83
+ ```
84
+
85
+ The command writes:
86
+
87
+ - `report.json` — stable, machine-readable.
88
+ - `report.md` — human-readable, with examples and limitations.
89
+
90
+ Exit codes: `0` pass/warn, `1` fail, `2` input/config error.
91
+
92
+ ## What it checks
93
+
94
+ | Check | Catches | Default |
95
+ | --- | --- | --- |
96
+ | `temporal_split` | train timestamps after validation/test; users across splits | error / warn |
97
+ | `multi_kc` | same item with train-only and test-only KCs | error |
98
+ | `alignment` | missing/duplicate/out-of-range predictions; constant predictions; reported AUC mismatch | error / warn |
99
+
100
+ Each finding has a `severity`, a `count`, and up to three examples.
101
+
102
+ ## Input
103
+
104
+ One row per interaction or prediction. CSV or JSONL.
105
+
106
+ | Column | Required | Notes |
107
+ | --- | :--: | --- |
108
+ | `user_id` | yes | non-null |
109
+ | `timestamp` | yes | ISO8601 or numeric; comparable within a user |
110
+ | `split` | yes | `train` / `valid` / `test` |
111
+ | `kc_ids` | yes | pipe-separated KCs or JSON array |
112
+ | `item_id` | no | needed for multi-KC check |
113
+ | `y_true` | no | 0/1; required with `y_pred` |
114
+ | `y_pred` | no | [0,1]; omit to skip metric checks |
115
+ | `fold` | no | checks run per fold |
116
+ | `sequence_id`, `position` | no | reserved for sequence checks |
117
+
118
+ See [docs/data-contract.md](docs/data-contract.md) for the full contract.
119
+
120
+ ## Python API
121
+
122
+ ```python
123
+ from kt_audit import AuditConfig, audit_path
124
+
125
+ report = audit_path("events.csv", AuditConfig(reported_auc=0.80))
126
+ print(report.verdict)
127
+ for finding in report.findings:
128
+ print(finding.severity, finding.check, finding.count)
129
+ ```
130
+
131
+ ## Design constraints
132
+
133
+ - Runtime dependencies: `numpy`, `pandas`, `scikit-learn`.
134
+ - CLI uses `argparse`; config uses JSON.
135
+ - Each check is a pure function over a prepared `DataFrame`.
136
+ - Synthetic examples only; no dataset is bundled.
137
+ - Findings are heuristics, not causal proof.
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ uv sync --extra dev
143
+ uv run ruff check .
144
+ uv run ruff format --check .
145
+ uv run basedpyright
146
+ uv run pytest --cov=kt_audit --cov-report=term-missing
147
+ bash scripts/wheel-smoke.sh
148
+ ```
149
+
150
+ ## Release
151
+
152
+ See [docs/release.md](docs/release.md). TestPyPI and PyPI publish through GitHub Actions trusted publishing.
153
+
154
+ ## Roadmap
155
+
156
+ - v0.2: pyKT adapter, bootstrap AUC intervals, HTML report.
157
+ - v0.3: duplicate interactions, cold-start, sequence truncation, label leakage.
158
+
159
+ ## AI disclosure
160
+
161
+ AI tools are used in development. The maintainer reviews every check definition, test, and release. Findings generated by the tool are deterministic and do not call an LLM.
162
+
163
+ ## Contributing
164
+
165
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The v0.1 scope is frozen to the three checks above.
166
+
167
+ ## License
168
+
169
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ kt_audit/__init__.py,sha256=3YMGd4msQtq1hRtjMpw3tK9BXJ1iRWdE0Syh5qNgvxc,315
2
+ kt_audit/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ kt_audit/audit.py,sha256=NvRXC8ErLxSuiy3GY-erSaR3DgaiJcqgH0OPINcoxTc,2698
4
+ kt_audit/cli.py,sha256=knCQr9Nf_jJOvcs4QzpgmefDc6fEcVCeb16z2vOfaDM,2452
5
+ kt_audit/config.py,sha256=9dMJiPAA2e-xEluK5dNjEaIVY81yyV1OOGUOsrcthtk,1737
6
+ kt_audit/errors.py,sha256=u_CUNg6CxePNPjPe2n9cHvn1x0YrlHUIa8GCcXcWDHo,98
7
+ kt_audit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ kt_audit/report.py,sha256=J5IH-6hs-Ki6dHSoRbeuvb2ADfZZZeuCUWFezNKz0Pk,3451
9
+ kt_audit/schema.py,sha256=FZqjFfce_W9DlvPb-QwfmzIVoKzmmCnarKsWd0j6NcU,6460
10
+ kt_audit/checks/__init__.py,sha256=wYQlGZn-einHubcbr7TRMFKo04Uw7s3Nyo9mjPX1iOA,177
11
+ kt_audit/checks/_common.py,sha256=_TL9dre3mXK81AQHaHOFtzCwhl9s3IoMimeVZURZbjI,1334
12
+ kt_audit/checks/alignment.py,sha256=rYipLa5-mc2IW6yqre1swU10Chys0e--NUq14I9jBog,5618
13
+ kt_audit/checks/multikc.py,sha256=GaBj4OUfIOA2oJZCfLxFjakCbMblBUb-U7yFDYZt1UM,4533
14
+ kt_audit/checks/temporal.py,sha256=XaJrMqkBE3yWvyB3rOkUnRm0zWoAexOZpsk3ervTR9k,3193
15
+ kt_audit-0.1.0.dist-info/METADATA,sha256=yiqAS2hb-uhIW5v8XXTijwZVwjnFjg9QQ4CJy1JB_pk,5255
16
+ kt_audit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ kt_audit-0.1.0.dist-info/entry_points.txt,sha256=0mkO5jFlNxsm2QjnHBNKhsWX6j24XToQi7aD3cEUs34,47
18
+ kt_audit-0.1.0.dist-info/licenses/LICENSE,sha256=Es5McTiFUOG7ZXX6wUvNrgnLJBHt6iZHZjhz9PYbx9g,1066
19
+ kt_audit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kt-audit = kt_audit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xia Jiang
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.