"""Reference scorer for a will-it-bang release inference bundle. Usage: uv run predict.py --bundle ~/.cache/will-it-bang/release-v1 --input row.json uv run predict.py --bundle DIR --input rows.jsonl Input JSON object(s) may include `full_text` plus any Layer-A fields from `feature_schema.json`. Missing fields use schema defaults. MiniLM is computed from `full_text` when raw `emb_0..383` are absent. """ from __future__ import annotations import argparse import json import re from pathlib import Path from typing import Any import joblib import lightgbm as lgb import numpy as np import pandas as pd def _load_json_rows(path: Path) -> list[dict[str, Any]]: text = path.read_text() if path.suffix == ".jsonl": return [json.loads(line) for line in text.splitlines() if line.strip()] payload = json.loads(text) if isinstance(payload, list): return payload if isinstance(payload, dict): return [payload] raise SystemExit(f"Unsupported JSON in {path}") def _minilm_embed(texts: list[str], encoder_id: str) -> np.ndarray: from sentence_transformers import SentenceTransformer model = SentenceTransformer(encoder_id) emb = model.encode(texts, show_progress_bar=False, normalize_embeddings=False) return np.asarray(emb, dtype=np.float64) def _raw_emb_matrix(rows: list[dict[str, Any]], n_dim: int) -> np.ndarray | None: cols = [f"emb_{i}" for i in range(n_dim)] if not all(c in rows[0] for c in cols): return None return np.asarray([[float(r.get(c, 0.0)) for c in cols] for r in rows], dtype=np.float64) def build_feature_matrix( rows: list[dict[str, Any]], *, bundle: Path, ) -> tuple[np.ndarray, list[str]]: schema = json.loads((bundle / "feature_schema.json").read_text()) constants = json.loads((bundle / "constants.json").read_text()) feature_names: list[str] = list(schema["feature_names"]) defaults = {f["name"]: float(f["default"]) for f in schema["features"]} pca = joblib.load(bundle / "emb_pca.joblib") tfidf = joblib.load(bundle / "tfidf.joblib") svd = joblib.load(bundle / "text_svd.joblib") direction = np.load(bundle / "res_emb_cav_direction.npy") coef = np.load(bundle / "res_emb_baseline_coef.npy") baselines = json.loads((bundle / "res_emb_baselines.json").read_text()) baseline_names: list[str] = list(baselines["names"]) n_raw = int(constants["encoder_dims"]) emb = _raw_emb_matrix(rows, n_raw) if emb is None: texts = [str(r.get("full_text", "")) for r in rows] if not any(texts): raise SystemExit("Need full_text or emb_0..emb_{n-1} on each row") emb = _minilm_embed(texts, constants["encoder_id"]) if emb.shape[1] != n_raw: raise SystemExit(f"Encoder dim {emb.shape[1]} != {n_raw}") # Layer-A frame for residual baselines + booster columns. frame = pd.DataFrame(rows) for name, default in defaults.items(): if name not in frame.columns and not re.fullmatch(r"emb_\d+", name) and not name.startswith( "tfidf_" ): frame[name] = default elif name in frame.columns: frame[name] = pd.to_numeric(frame[name], errors="coerce").fillna(default) # Residualize raw MiniLM with shipped OLS coeffs, then CAV projection. z_cols: list[np.ndarray] = [] for name in baseline_names: if name == "intercept": z_cols.append(np.ones(len(rows), dtype=np.float64)) continue col = frame[name].to_numpy(dtype=np.float64) if name in frame.columns else np.zeros(len(rows)) z_cols.append(np.nan_to_num(col, nan=0.0, posinf=0.0, neginf=0.0)) z = np.column_stack(z_cols) if z.shape[1] != coef.shape[0]: raise SystemExit( f"Residual baseline width {z.shape[1]} != coef rows {coef.shape[0]} " f"(names={baseline_names})" ) resid = emb - z @ coef proj = resid @ direction.reshape(-1) frame["res_emb_cav_proj"] = proj.astype(np.float32) frame["res_emb_cav_proj_sq"] = (proj * proj).astype(np.float32) emb_pca = pca.transform(emb.astype(np.float32)) emb_pca_df = pd.DataFrame( emb_pca, columns=[f"emb_{i}" for i in range(emb_pca.shape[1])] ) texts = [str(r.get("full_text", "")) for r in rows] tfidf_z = svd.transform(tfidf.transform(texts)).astype(np.float32) tfidf_df = pd.DataFrame( tfidf_z, columns=[f"tfidf_{i}" for i in range(tfidf_z.shape[1])] ) drop_emb = [c for c in frame.columns if re.fullmatch(r"emb_\d+", c)] if drop_emb: frame = frame.drop(columns=drop_emb) frame = pd.concat( [frame.reset_index(drop=True), emb_pca_df, tfidf_df], axis=1, ) x = np.column_stack( [ pd.to_numeric(frame[c], errors="coerce").fillna(defaults.get(c, 0.0)).to_numpy( dtype=np.float32 ) if c in frame.columns else np.full(len(rows), defaults.get(c, 0.0), dtype=np.float32) for c in feature_names ] ) return x, feature_names def predict_rows(bundle: Path, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: x, _names = build_feature_matrix(rows, bundle=bundle) model = lgb.Booster(model_file=str(bundle / "model_level.txt")) scores = np.asarray(model.predict(x), dtype=np.float64) out: list[dict[str, Any]] = [] for row, score in zip(rows, scores, strict=True): item = { "y_pred": float(score), "y_pred_eng_rate": float(np.expm1(np.clip(score, -1.0, 30.0))), } if "tweet_id" in row: item["tweet_id"] = row["tweet_id"] out.append(item) return out def main() -> None: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--bundle", type=Path, required=True, help="Release bundle directory") p.add_argument("--input", type=Path, required=True, help="JSON or JSONL feature rows") p.add_argument("--output", type=Path, default=None, help="Write predictions JSON") args = p.parse_args() rows = _load_json_rows(args.input) preds = predict_rows(args.bundle, rows) text = json.dumps(preds, indent=2) + "\n" if args.output: args.output.write_text(text) print(f"Wrote {len(preds)} predictions → {args.output}") else: print(text, end="") if __name__ == "__main__": main()