Skip to content

Commit 7a4e654

Browse files
committed
feat(cli): write metadata sidecars
1 parent 05aafa6 commit 7a4e654

8 files changed

Lines changed: 75 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- JSON output for `quorabust-report --format json`.
1111
- Precision, recall, F1, and configurable threshold sweeps in model reports.
1212
- `POST /predict?explain=true` feature-value explanations for scored pairs.
13+
- `quorabust-train --metadata-out` for JSON metadata sidecars.
1314

1415
### Fixed
1516
- Capped NumPy below 2.5 so Mypy can parse dependency stubs with the configured

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ quorabust-train --csv data/raw/train.csv --out models/quorabust.pkl
5959
python -m quorabust --csv data/raw/train.csv --out models/quorabust.pkl # equivalent
6060
```
6161

62-
Options: `--max-rows N`, `--eval-fraction 0.1` (default), `--eval-fraction 0` to train on all rows without a holdout, `--seed`, `--feature-backend {tfidf,embedding}`, `--embedding-model …`, `--registry-dir` (JSONL registry).
62+
Options: `--max-rows N`, `--eval-fraction 0.1` (default), `--eval-fraction 0` to train on all rows without a holdout, `--seed`, `--feature-backend {tfidf,embedding}`, `--embedding-model …`, `--registry-dir` (JSONL registry), `--metadata-out` (JSON sidecar for reviewing artifact lineage without loading the pickle).
6363

6464
### Generate a model card
6565

@@ -123,7 +123,7 @@ Design notes: [docs/NOTES.md](docs/NOTES.md). Contributing: [CONTRIBUTING.md](CO
123123

124124
## Enterprise / operations
125125

126-
Governance (security policy, Dependabot, audits), containers, serving, and release expectations are summarized in [docs/ENTERPRISE.md](docs/ENTERPRISE.md) and [docs/SCALING.md](docs/SCALING.md). Saved model pickles include `meta` (CSV checksum, git revision, package version, feature means for drift, metrics); only load trusted artifacts.
126+
Governance (security policy, Dependabot, audits), containers, serving, and release expectations are summarized in [docs/ENTERPRISE.md](docs/ENTERPRISE.md) and [docs/SCALING.md](docs/SCALING.md). Saved model pickles include `meta` (CSV checksum, git revision, package version, feature means for drift, metrics); only load trusted artifacts. Use `quorabust-train --metadata-out ...` when you only need lineage and metrics.
127127

128128
## License
129129

docs/ENTERPRISE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616

1717
Training writes `csv_sha256`, `git_revision`, `quorabust_version`, `feature_schema`, `reference_feature_means` (for drift checks), and metric fields into the pickle `meta` dict. Treat `.pkl` files as **trusted** (pickle); load only from controlled storage.
1818

19+
Use `quorabust-train --metadata-out models/quorabust.meta.json` to write the same
20+
lineage and metric metadata as JSON. Reviewers and release tooling can inspect that
21+
sidecar without loading executable pickle content. The sidecar is not a replacement for
22+
the model artifact; it is a safer inspection path. For untrusted artifact distribution,
23+
prefer a non-pickle format such as `skops` or ONNX in a future release.
24+
1925
## Serving and SLOs
2026

2127
- **`quorabust-serve`**: FastAPI with `/health`, `/ready`, `/predict`, `/metrics` (Prometheus). Configure **`QUORABUST_MODEL_PATH`** and optional **`QUORABUST_MODEL_B`** for A/B; clients may send **`X-Quorabust-Variant: b`**.

docs/REPORTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ run in CI without the Kaggle dataset.
1313
quorabust-train \
1414
--csv examples/smoke_pairs.csv \
1515
--out /tmp/quorabust-smoke.pkl \
16+
--metadata-out /tmp/quorabust-smoke.meta.json \
1617
--eval-fraction 0 \
1718
--seed 7
1819

@@ -47,6 +48,7 @@ training:
4748
quorabust-train \
4849
--csv data/raw/train.csv \
4950
--out models/quorabust.pkl \
51+
--metadata-out models/quorabust.meta.json \
5052
--eval-fraction 0.1 \
5153
--seed 42
5254

src/quorabust/cli.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from quorabust.drift import feature_means_from_matrix
1212
from quorabust.lineage import git_revision, sha256_file
1313
from quorabust.model import eval_classification_metrics, train_duplicate_classifier
14-
from quorabust.persist import save_classifier
14+
from quorabust.persist import save_classifier, save_metadata_sidecar
1515
from quorabust.registry import append_model_record
1616

1717

@@ -73,6 +73,12 @@ def main(argv: list[str] | None = None) -> int:
7373
default=None,
7474
help="If set, append a JSONL record under this directory after training",
7575
)
76+
p.add_argument(
77+
"--metadata-out",
78+
type=Path,
79+
default=None,
80+
help="If set, write artifact metadata JSON here without requiring pickle loading",
81+
)
7682
args = p.parse_args(argv)
7783

7884
if not args.csv.is_file():
@@ -139,6 +145,8 @@ def main(argv: list[str] | None = None) -> int:
139145
meta["reference_feature_means"] = feature_means_from_matrix(feat_names, X_ref)
140146

141147
save_classifier(args.out, builder, clf, meta=meta)
148+
if args.metadata_out is not None:
149+
save_metadata_sidecar(args.metadata_out, meta)
142150
if args.registry_dir is not None:
143151
append_model_record(
144152
args.registry_dir,

src/quorabust/persist.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import pickle
45
from pathlib import Path
56
from typing import Any
@@ -25,3 +26,14 @@ def load_classifier(path: str | Path) -> tuple[Any, XGBClassifier, dict[str, Any
2526
with Path(path).open("rb") as f:
2627
data = pickle.load(f)
2728
return data["builder"], data["clf"], data.get("meta", {})
29+
30+
31+
def save_metadata_sidecar(
32+
path: str | Path,
33+
meta: dict[str, Any],
34+
) -> Path:
35+
"""Write artifact metadata as JSON so reviewers do not need to load a pickle."""
36+
p = Path(path)
37+
p.parent.mkdir(parents=True, exist_ok=True)
38+
p.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
39+
return p

tests/test_cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
import pandas as pd
24

35
from quorabust.cli import main
@@ -22,6 +24,34 @@ def test_cli_trains_and_writes_pkl(tmp_path):
2224
assert out.is_file() and out.stat().st_size > 100
2325

2426

27+
def test_cli_writes_metadata_sidecar(tmp_path):
28+
csv = tmp_path / "train.csv"
29+
_write_synthetic_csv(csv)
30+
out = tmp_path / "model.pkl"
31+
meta = tmp_path / "model.meta.json"
32+
33+
assert (
34+
main(
35+
[
36+
"--csv",
37+
str(csv),
38+
"--out",
39+
str(out),
40+
"--metadata-out",
41+
str(meta),
42+
"--eval-fraction",
43+
"0",
44+
]
45+
)
46+
== 0
47+
)
48+
49+
payload = json.loads(meta.read_text(encoding="utf-8"))
50+
assert payload["n_train"] > 0
51+
assert payload["feature_backend"] == "tfidf"
52+
assert "eval_accuracy" in payload
53+
54+
2555
def test_cli_rejects_bad_columns(tmp_path):
2656
csv = tmp_path / "bad.csv"
2757
pd.DataFrame({"a": [1]}).to_csv(csv, index=False)

tests/test_persist.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import json
2+
13
import numpy as np
24
import pandas as pd
35

46
from quorabust.model import predict_proba_duplicate, train_duplicate_classifier
5-
from quorabust.persist import load_classifier, save_classifier
7+
from quorabust.persist import load_classifier, save_classifier, save_metadata_sidecar
68

79

810
def _df():
@@ -29,3 +31,13 @@ def test_save_load_roundtrip(tmp_path):
2931
p1 = predict_proba_duplicate(builder, clf, q1, q2)
3032
p2 = predict_proba_duplicate(b2, c2, q1, q2)
3133
assert np.allclose(p1, p2)
34+
35+
36+
def test_save_metadata_sidecar_writes_json(tmp_path):
37+
path = tmp_path / "model.meta.json"
38+
written = save_metadata_sidecar(path, {"run": "test", "eval_accuracy": 0.9})
39+
assert written == path
40+
assert json.loads(path.read_text(encoding="utf-8")) == {
41+
"run": "test",
42+
"eval_accuracy": 0.9,
43+
}

0 commit comments

Comments
 (0)