| """Inference plumbing for the Co-Pilot demo. |
| |
| Loads the slim multi-surface checkpoint, exposes one `predict` entry |
| point per cast member, and a token-by-token `stream_reasoning` generator |
| for the LM head. Everything is CPU-only, float32, greedy decode, fixed |
| seed: a demo must produce byte-identical output on every replay. |
| |
| Three things this module enforces (no autoreload, no surprises): |
| |
| 1. The frozen LFM2.5-350M base weights are reloaded fresh from HF |
| when the model is constructed. The slim checkpoint only carries |
| the trainable params (encoder + projector + LoRA + heads); it is |
| loaded with `strict=False`. |
| 2. The encoder is wrapped with `torch.inference_mode()` for every |
| call so no autograd memory accumulates. The Gradio process runs |
| many sequential inferences; we cannot leak. |
| 3. Every inference call seeds `torch.manual_seed(42)` before the |
| forward. The model has dropout layers; deterministic seeding makes |
| them no-ops in eval mode but also pins any future stochastic |
| decode behavior. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Iterator |
|
|
| import numpy as np |
| import torch |
| import yaml |
|
|
| from src.data.schema import SchemaConfig, load_schema |
|
|
| from encoder.src.data.mixed_modality import ( |
| MixedModalityBatch, |
| build_combined_attention_mask, |
| tokenize_texts, |
| ) |
| from encoder.src.data.synthetic_dispute_legitimacy import ( |
| FEATURE_COUNTRY, |
| FEATURE_CUSTOMER_MERCHANT_COUNT, |
| FEATURE_CVV, |
| FEATURE_AVS, |
| FEATURE_ENTRY_MODE, |
| FEATURE_MERCHANT_ID, |
| CVV_MATCH, |
| ENTRY_CNP, |
| LABEL_LIKELY, |
| LABEL_UNLIKELY, |
| LABEL_AMBIGUOUS, |
| RESERVED_OFFSET, |
| _approximate_amount_usd, |
| _decode_country_label, |
| ) |
| from encoder.src.data.synthetic_dispute_legitimacy import FEATURE_AMOUNT |
| from encoder.src.model.transaction_fm_multisurface import ( |
| TransactionMultiSurfaceModel, |
| build_transaction_multisurface, |
| ) |
|
|
|
|
| |
| |
| DEMO_SEED = 42 |
|
|
| |
| LABEL_NAMES: dict[int, str] = { |
| 0: "unlikely friendly fraud", |
| 1: "ambiguous", |
| 2: "likely friendly fraud", |
| } |
|
|
| |
| |
| LABEL_COLORS: dict[int, str] = { |
| 0: "#22c55e", |
| 1: "#eab308", |
| 2: "#ef4444", |
| } |
|
|
|
|
| @dataclass |
| class CastMember: |
| """One entry from `encoder/data/demo_cast.json`, used by the UI. |
| |
| Attributes correspond 1:1 with the JSON fields. The renderer reads |
| `display_name`, `complaint_text`, `tone`, `expected_label`; the |
| inference module reads `customer_idx`, `disputed_idx`, |
| `complaint_text`. |
| """ |
|
|
| pattern: str |
| display_name: str |
| customer_idx: int |
| disputed_idx: int |
| expected_label: int |
| expected_label_name: str |
| complaint_text: str |
| tone: str |
| description: str |
|
|
|
|
| @dataclass |
| class CopilotResult: |
| """Result of one inference call against the multi-surface model. |
| |
| Attributes: |
| score: float in [0, 1] — softmax probability of the predicted |
| class. Caller renders this as the headline number. |
| predicted_class: int in {0, 1, 2} — argmax of probability logits. |
| predicted_label: human-readable label string. |
| color: hex color for the band (mapped from predicted_class). |
| attribution_probs: (64,) float — per-position contribution |
| probabilities. Renderer thresholds / top-k's these. |
| top_k_positions: (k,) int — indices of the top-contributing |
| transactions, sorted by descending attribution probability. |
| disputed_idx: the disputed transaction's index, passed through |
| so the renderer can mark it with a star. |
| """ |
|
|
| score: float |
| predicted_class: int |
| predicted_label: str |
| color: str |
| attribution_probs: np.ndarray |
| top_k_positions: np.ndarray |
| disputed_idx: int |
|
|
|
|
| class CopilotModel: |
| """Encapsulates the loaded model, tokenizer, schema, histories, |
| and demo cast. Built once at app startup, then used for every |
| inference call. |
| |
| Why a class and not free functions: the model, tokenizer, and the |
| in-memory histories array are ~700 MB on disk. We load them once |
| and reuse, not per-request. |
| """ |
|
|
| def __init__( |
| self, |
| model: TransactionMultiSurfaceModel, |
| schema: SchemaConfig, |
| histories: np.ndarray, |
| cast: list[CastMember], |
| device: torch.device, |
| ) -> None: |
| self.model = model |
| self.schema = schema |
| self.histories = histories |
| self.cast = cast |
| self.device = device |
| |
| self.tokenizer = model.backbone.tokenizer |
|
|
| self.model.eval() |
|
|
| @classmethod |
| def from_paths( |
| cls, |
| checkpoint_path: Path, |
| model_config_path: Path, |
| schema_path: Path, |
| histories_path: Path, |
| cast_path: Path, |
| device: torch.device = torch.device("cpu"), |
| ) -> "CopilotModel": |
| """Construct the full inference stack from on-disk artifacts. |
| |
| Args: |
| checkpoint_path: slim checkpoint (.pt) from |
| `encoder/scripts/slim_checkpoint.py`. |
| model_config_path: same YAML the trainer used, so the |
| architecture matches what produced the checkpoint. |
| schema_path: parent's `data/schema.yaml`. |
| histories_path: parent's `data/synthetic/token_ids.npy`. |
| cast_path: `encoder/data/demo_cast.json`. |
| device: torch.device. CPU is the demo default; the |
| "this runs on a laptop" story is the pitch. |
| |
| Returns: |
| CopilotModel ready for `predict` and `stream_reasoning`. |
| """ |
| |
| schema = load_schema(schema_path) |
| histories = np.load(histories_path, mmap_mode="r") |
| cast = _load_cast(cast_path) |
|
|
| |
| with model_config_path.open() as f: |
| mcfg = yaml.safe_load(f) |
|
|
| |
| |
| |
| |
| dtype = torch.float32 if device.type == "cpu" else torch.bfloat16 |
|
|
| model = build_transaction_multisurface( |
| schema=schema, |
| model_path=mcfg["backbone"]["hf_path"], |
| encoder_cfg=mcfg.get("encoder"), |
| projector_cfg=mcfg.get("projector"), |
| head_cfg=mcfg.get("heads"), |
| lora_cfg=mcfg["backbone"].get("lora"), |
| dtype=dtype, |
| device_map=None if device.type == "cpu" else "auto", |
| ) |
|
|
| |
| |
| |
| |
| ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) |
| if not ckpt.get("model_state_dict_slim"): |
| raise ValueError( |
| f"Expected a slim checkpoint at {checkpoint_path}. " |
| f"Run encoder/scripts/slim_checkpoint.py first.", |
| ) |
| state = {k: v.to(dtype) if v.is_floating_point() else v |
| for k, v in ckpt["model_state_dict"].items()} |
| missing, unexpected = model.load_state_dict(state, strict=False) |
| |
| |
| if unexpected: |
| raise RuntimeError(f"Unexpected state_dict keys: {unexpected[:5]} ...") |
|
|
| model.to(device) |
| return cls(model=model, schema=schema, histories=histories, |
| cast=cast, device=device) |
|
|
| |
|
|
| @torch.inference_mode() |
| def predict(self, member: CastMember, top_k: int = 5) -> CopilotResult: |
| """Run the probability + attribution heads for one cast member. |
| |
| The LM head is NOT run here — call `stream_reasoning` separately |
| so the UI can render the score immediately, then stream the |
| reasoning underneath. |
| |
| Args: |
| member: which cast member to predict on. |
| top_k: how many attribution positions to surface for the |
| timeline glow. |
| |
| Returns: |
| CopilotResult — score, predicted class, attribution probs. |
| """ |
| torch.manual_seed(DEMO_SEED) |
| batch = self._build_batch(member) |
| out = self.model.predict(batch) |
|
|
| |
| |
| prob_logits = out["probability_logits"][0] |
| probs = torch.softmax(prob_logits.float(), dim=-1) |
| predicted_class = int(torch.argmax(probs).item()) |
| score = float(probs[predicted_class].item()) |
|
|
| |
| |
| attr_logits = out["attribution_logits"][0] |
| attr_probs = torch.sigmoid(attr_logits.float()).cpu().numpy() |
| top_k_positions = ( |
| torch.topk(attr_logits.float(), k=top_k, dim=-1).indices.cpu().numpy() |
| ) |
|
|
| return CopilotResult( |
| score=score, |
| predicted_class=predicted_class, |
| predicted_label=LABEL_NAMES[predicted_class], |
| color=LABEL_COLORS[predicted_class], |
| attribution_probs=attr_probs, |
| top_k_positions=top_k_positions, |
| disputed_idx=member.disputed_idx, |
| ) |
|
|
| def build_reasoning_text( |
| self, |
| member: CastMember, |
| result: "CopilotResult", |
| ) -> str: |
| """Render the analyst-facing reasoning deterministically. |
| |
| Doctrine reference: liquid-models-architecture §11 / Ottoguard |
| principle: "Structural validity is not the model's job; it is |
| the decoder's." The model produces (probability, attribution). |
| This function consumes those plus the customer's ground-truth |
| feature tokens to render a coherent paragraph. The LM head is |
| no longer trained for this surface (350M for generation is the |
| anti-pattern per liquid-finetuning-playbook §10), and the |
| template here mirrors the synthesizer's `_build_reasoning_text` |
| contract so the demo output matches what the corpus advertised. |
| |
| Args: |
| member: cast member being analyzed (for ground-truth signal). |
| result: CopilotResult containing predicted_class, score, |
| top_k_positions, attribution_probs. |
| |
| Returns: |
| One paragraph of reasoning text. Includes the score, the |
| top contributing positions, and a one-line recommendation. |
| """ |
| history = np.asarray(self.histories[member.customer_idx]) |
| return _render_reasoning( |
| history=history, |
| disputed_idx=member.disputed_idx, |
| predicted_class=result.predicted_class, |
| score=result.score, |
| top_k_positions=result.top_k_positions.tolist(), |
| ) |
|
|
| def stream_reasoning( |
| self, |
| member: CastMember, |
| result: "CopilotResult", |
| chunk_chars: int = 6, |
| ) -> Iterator[str]: |
| """Stream the templated reasoning chunk-by-chunk for the UI animation. |
| |
| The text is deterministic (built once), then yielded incrementally |
| so the UI can render the "model thinking" effect. The previous |
| implementation called the 350M LM head; that approach produced |
| bag-of-words output (350M is below the doctrine's generation |
| tier) and has been removed. |
| |
| Args: |
| member: cast member. |
| result: model prediction result. |
| chunk_chars: chars per yield. ~6 chars at ~50ms cadence |
| produces a smooth word-by-word reveal. |
| |
| Yields: |
| Cumulative substring at each step; the last yield is the |
| full reasoning text. |
| """ |
| text = self.build_reasoning_text(member, result) |
| for i in range(chunk_chars, len(text) + chunk_chars, chunk_chars): |
| yield text[: min(i, len(text))] |
|
|
| |
|
|
| def _build_batch(self, member: CastMember) -> MixedModalityBatch: |
| """Assemble a batch-of-1 MixedModalityBatch for inference. |
| |
| Pulls the customer's 64-tx history out of the in-memory |
| histories array, tokenizes the complaint text, and returns |
| a batch with no labels (predict-only). |
| """ |
| |
| |
| history = np.asarray(self.histories[member.customer_idx]).copy() |
| feature_ids = torch.from_numpy(history).long().unsqueeze(0) |
| feature_ids = feature_ids.to(self.device) |
|
|
| input_ids, attention_mask, lengths = tokenize_texts( |
| self.tokenizer, [member.complaint_text], max_length=256, |
| ) |
| input_ids = input_ids.to(self.device) |
| attention_mask = attention_mask.to(self.device) |
| lengths = lengths.to(self.device) |
|
|
| |
| |
| |
| |
| disputed_idx = torch.tensor( |
| [member.disputed_idx], dtype=torch.long, device=self.device, |
| ) |
|
|
| |
| |
| |
| return MixedModalityBatch( |
| feature_ids=feature_ids, |
| text_input_ids=input_ids, |
| text_attention_mask=attention_mask, |
| text_lengths=lengths, |
| head_target="probability", |
| disputed_idx=disputed_idx, |
| ) |
|
|
|
|
| def _render_reasoning( |
| history: np.ndarray, |
| disputed_idx: int, |
| predicted_class: int, |
| score: float, |
| top_k_positions: list[int], |
| ) -> str: |
| """Render analyst-facing reasoning from model output + ground-truth features. |
| |
| The model contributes: predicted_class, score, top_k_positions. |
| The history contributes: ground-truth feature tokens at the |
| disputed transaction and the top-k attribution positions. |
| |
| Why pull ground truth from the history at render time: the model's |
| job is the verdict; the explanation should cite specific, verifiable |
| facts about the customer's history rather than asking the LM head |
| to fabricate prose. This mirrors the Ottoguard principle ("structural |
| validity belongs to the decoder, not the model") and is what the |
| synthesizer trained the corpus against. |
| """ |
| disp_country_token = int(history[disputed_idx, FEATURE_COUNTRY]) |
| disp_country = _decode_country_label(disp_country_token) |
| disp_amount = _approximate_amount_usd(int(history[disputed_idx, FEATURE_AMOUNT])) |
| disp_cmc_raw = int(history[disputed_idx, FEATURE_CUSTOMER_MERCHANT_COUNT]) |
| disp_cmc = max(0, disp_cmc_raw - RESERVED_OFFSET) |
| disp_merchant = int(history[disputed_idx, FEATURE_MERCHANT_ID]) |
| disp_cvv = int(history[disputed_idx, FEATURE_CVV]) |
| disp_avs = int(history[disputed_idx, FEATURE_AVS]) |
|
|
| |
| |
| same_merchant_positions = [ |
| p for p in top_k_positions |
| if int(history[p, FEATURE_MERCHANT_ID]) == disp_merchant and p != disputed_idx |
| ] |
|
|
| |
| countries = history[:, FEATURE_COUNTRY] |
| mode_country_token = int(np.bincount(countries).argmax()) |
| geography_break = disp_country_token != mode_country_token |
|
|
| |
| window = history[max(0, disputed_idx - 5):disputed_idx, FEATURE_ENTRY_MODE] |
| cnp_count = int(np.sum(window == ENTRY_CNP)) |
|
|
| positions_str = ", ".join(str(p) for p in top_k_positions[:5]) |
| score_str = f"{score:.2f}" |
|
|
| if predicted_class == LABEL_LIKELY: |
| merchant_clause = ( |
| f"The customer has substantial prior history with this merchant " |
| f"(familiarity bucket {disp_cmc}/19)" |
| if disp_cmc >= 5 |
| else "The customer's history shows a recurring pattern at this merchant" |
| ) |
| return ( |
| f"Score {score_str} — likely friendly fraud. {merchant_clause}, " |
| f"and the disputed {disp_amount} charge sits inside the customer's " |
| f"established pattern. The model's top contributing transactions are " |
| f"positions {positions_str}" |
| + ( |
| f"; {len(same_merchant_positions)} of those are this same merchant." |
| if same_merchant_positions |
| else "." |
| ) |
| + " Recommend evidence request rather than auto-refund." |
| ) |
|
|
| if predicted_class == LABEL_UNLIKELY: |
| anomaly_clauses: list[str] = [] |
| if disp_cmc == 0: |
| anomaly_clauses.append("no prior history with this merchant") |
| if geography_break: |
| anomaly_clauses.append( |
| f"transaction occurred in {disp_country}, outside the " |
| f"customer's home country" |
| ) |
| if cnp_count >= 2: |
| anomaly_clauses.append( |
| f"{cnp_count} card-not-present transactions in the 5-tx window " |
| f"preceding the disputed charge — consistent with card-testing" |
| ) |
| if disp_cvv != CVV_MATCH: |
| anomaly_clauses.append("CVV did not match") |
| if not anomaly_clauses: |
| anomaly_clauses.append("the disputed transaction is anomalous relative to the customer's pattern") |
| anomaly_clause = "; ".join(anomaly_clauses) |
| return ( |
| f"Score {score_str} — unlikely friendly fraud (probable true unauthorized). " |
| f"The disputed {disp_amount} charge shows {anomaly_clause}. Top contributing " |
| f"transactions are positions {positions_str}. Recommend auto-resolve refund." |
| ) |
|
|
| |
| signal_clauses: list[str] = [] |
| if 1 <= disp_cmc <= 7: |
| signal_clauses.append( |
| f"some prior interactions with this merchant (familiarity bucket {disp_cmc}/19)" |
| ) |
| if geography_break: |
| signal_clauses.append(f"the disputed charge is in {disp_country}, not the customer's home country") |
| if disp_cmc == 0 and not geography_break: |
| signal_clauses.append("the merchant is unfamiliar but every other signal is clean") |
| if not signal_clauses: |
| signal_clauses.append("the signals are split between legitimate and friendly-fraud patterns") |
| signal_clause = "; ".join(signal_clauses) |
| return ( |
| f"Score {score_str} — ambiguous. The {disp_amount} charge shows mixed signals: " |
| f"{signal_clause}. Top contributing transactions are positions {positions_str}. " |
| f"Recommend specialist review before resolution." |
| ) |
|
|
|
|
| def _load_cast(cast_path: Path) -> list[CastMember]: |
| """Read demo_cast.json into typed CastMember objects. |
| |
| Errors clearly if a required field is missing — the cast file is |
| hand-curated and a typo there should not silently break the demo. |
| """ |
| with cast_path.open() as f: |
| raw = json.load(f) |
| cast: list[CastMember] = [] |
| for entry in raw["cast"]: |
| cast.append( |
| CastMember( |
| pattern=entry["pattern"], |
| display_name=entry["display_name"], |
| customer_idx=int(entry["customer_idx"]), |
| disputed_idx=int(entry["disputed_idx"]), |
| expected_label=int(entry["expected_label"]), |
| expected_label_name=entry["expected_label_name"], |
| complaint_text=entry["complaint_text"], |
| tone=entry["tone"], |
| description=entry["description"], |
| ), |
| ) |
| return cast |
|
|