"""Shared NLA sidecar schema — single source of truth for token metadata. Imported by both nla/datagen/ (writes sidecars) and nla/config.py (reads + asserts). This file IS the schema definition — field names and conventions here are the single source of truth for sidecar contents. Two sidecar conventions: - Dataset: {parquet_path}.nla_meta.yaml (kind: nla_dataset) - Model: {checkpoint_dir}/nla_meta.yaml (kind: nla_model) """ import math import re from dataclasses import dataclass from pathlib import Path from typing import Any, BinaryIO import numpy as np import pyarrow.parquet as pq import torch SIDECAR_SUFFIX = ".nla_meta.yaml" SIDECAR_BASENAME = "nla_meta.yaml" # Actor response payload tags. Datagen (stage3_build.py) wraps the AV-SFT # response column with these; training (nla_generate, nla_rm) parses them # back out. Single source of truth — if tags change, both sides update. EXPLANATION_OPEN = "" EXPLANATION_CLOSE = "" EXPLANATION_RE = re.compile( f"{re.escape(EXPLANATION_OPEN)}(.*?){re.escape(EXPLANATION_CLOSE)}", re.DOTALL, ) FAILED_EXTRACTION_REWARD = -2.0 def reconstruction_reward_from_mse(mse: float) -> float: """Paper reward for a valid reconstruction: higher is better.""" assert math.isfinite(mse) and mse > 0.0, f"invalid reconstruction MSE: {mse}" return -math.log(mse) def mse_from_reconstruction_reward(reward: float) -> float: """Inverse of reconstruction_reward_from_mse for reporting utilities.""" assert math.isfinite(reward), f"invalid reconstruction reward: {reward}" return math.exp(-reward) def wrap_explanation(text: str) -> str: """Wrap text in explanation tags for the AV-SFT response column. Datagen's stage3_build.py should use this for the `response` column so the format is locked to what extract_explanation can parse. """ return f"{EXPLANATION_OPEN}\n{text}\n{EXPLANATION_CLOSE}" def extract_explanation(response: str) -> str | None: """Extract payload between explanation tags; None on miss. Strips whitespace so the result matches what datagen's stage3_build used to fill the critic template (the raw api_explanation, no \n wrapper). Without this, RL queries the critic with \nfoo\n but AR-SFT trained it on foo — different tokens. """ m = EXPLANATION_RE.search(response) return m.group(1).strip() if m else None # Parquet column name — datagen writes it, NLADataSource + rollouts read it. ACTIVATION_COLUMN = "activation_vector" # Placeholder in parquet prompt column — datagen writes literal, # NLADataSource swaps it for the injection char at load time. INJECT_PLACEHOLDER = "" # multimodal_train_inputs dict keys — rollouts stash, train_actor + loss read. # String typo here = silent KeyError deep in training. MM_ACTIVATION_KEY = "nla_activation" MM_CRITIC_TOKENS_KEY = "nla_critic_tokens" MM_MSE_SCALE_KEY = "nla_mse_scale" # Sentinel for extraction.{injection_scale, mse_scale} — resolve to sqrt(d_model) # at load time. Lets sidecars say "use the default" without baking a float. SCALE_SQRT_D = "sqrt_d_model" def resolve_target_scale(raw: float | str | None, d_model: int) -> float | None: """Turn a scale value (from sidecar or CLI) into a concrete float or None. Accepts: - None / "raw" / "none" → None → no normalization (use raw vectors) - "sqrt_d_model" → sqrt(d_model) — ambient residual-stream scale - a float or float-string → that exact L2 norm Key-absent in sidecar is NOT None — config.py supplies "sqrt_d_model" as the default to .get(), so absent ⇒ normalize to sqrt(d). Explicit null (or "raw" from CLI) is the only way to opt out. """ if raw is None or raw in ("raw", "none"): return None if raw == SCALE_SQRT_D: return math.sqrt(d_model) if isinstance(raw, str): return float(raw) # ValueError on bad string — loud assert isinstance(raw, (int, float)), ( f"scale must be None/'raw', {SCALE_SQRT_D!r}, or a number; got {raw!r}" ) return float(raw) @dataclass class NLATokenMeta: """Token IDs pinned at dataset/model generation time. Training-side hook scans for injection_token_id + verifies neighbors. Critic extraction: position = last token of the prompt (before any padding or EOS the training-side tokenizer adds). `critic_suffix_ids` is the expected last-N tokens — training can verify the prompt ends with these as a one-time sanity check at dataset load, then just index `tokens[-1]` per-forward (no scanning, GPU-friendly). ALL IDs must match the live tokenizer — drift = silent wrong-position. """ injection_char: str injection_token_id: int injection_left_neighbor_id: int injection_right_neighbor_id: int critic_suffix_ids: list[int] | None = None def sidecar_path_for(path: str | Path) -> Path: """Resolve sidecar path for either a parquet file or a checkpoint directory. Handles the `@[slice]` path syntax (foo.parquet@[0:1000]) by stripping the slice suffix before appending SIDECAR_SUFFIX. Directory vs file detection uses is_dir() (exists + is-dir) OR a heuristic (no file extension) so write-paths to not-yet-created dirs work too. """ p = Path(str(path).split("@[")[0]) if p.is_dir() or (not p.exists() and p.suffix == ""): return p / SIDECAR_BASENAME return p.with_name(p.name + SIDECAR_SUFFIX) def normalize_activation(v: torch.Tensor, target_scale: float | None) -> torch.Tensor: """Scale vectors to target_scale L2-norm, or pass through if None. Used for TWO distinct purposes: - Actor injection (train_actor.py): scale = cfg.injection_scale. A tunable HYPERPARAMETER — affects what the model learns. - Critic MSE (loss.py): scale = cfg.mse_scale. Applied to BOTH pred and gold symmetrically. Just loss numerical stability. target_scale=None → no-op pass-through for either purpose. Idempotent. Zero vectors stay zero. Norm computed in fp32 for precision; single division v / (||v||_fp32 / scale). """ if target_scale is None: return v norm_fp32 = v.float().norm(dim=-1, keepdim=True).clamp_min(1e-12) return v / (norm_fp32 / target_scale).to(v.dtype) def compute_predict_mean_baselines( vectors: torch.Tensor, mse_scale: float | None ) -> tuple[float, float]: """Two predict-the-mean baseline MSEs for FVE logging: fve_nrm_meannorm baseline: MSE(v_norm, normalize(μ)). The critic's best achievable constant pred — its output ALSO gets normalized. ≈ 0.94 for Qwen7B layer-20. FVE>0 ⇒ critic is learning. critic_rand (shuffled targets) lands near this. fve_nrm baseline: MSE(v_norm, μ). Raw per-element variance of the normalized distribution. ≈ 0.72 for Qwen7B. Tighter baseline (critic can't literally output short μ under normalization) but matches the classical "fraction of variance explained" definition. Returns (meannorm_baseline, raw_variance_baseline). """ v_norm = normalize_activation(vectors.float(), mse_scale) mu = v_norm.mean(dim=0, keepdim=True) mu_normed = normalize_activation(mu, mse_scale) mse_meannorm = ((v_norm - mu_normed) ** 2).mean().item() mse_rawvar = ((v_norm - mu) ** 2).mean().item() return mse_meannorm, mse_rawvar def load_predict_mean_baselines( parquet_source: BinaryIO | str, mse_scale: float | None, max_rows: int = 50_000 ) -> tuple[float, float]: """Read activation_vector column from parquet, compute both baselines. max_rows caps memory — 50k × 3584 × fp32 ≈ 700MB. Sampling error on the variance estimate is O(1/√n) — tight at 50k. """ pf = pq.ParquetFile(parquet_source) rows = [] n = 0 for batch in pf.iter_batches(batch_size=8192, columns=[ACTIVATION_COLUMN]): # ListArray → flat values → reshape. Avoids to_pylist() which creates # millions of PyFloat objects (same GC pressure we fixed in data_source). col = batch.column(ACTIVATION_COLUMN) flat = col.flatten().to_numpy(zero_copy_only=False).astype(np.float32) chunk = flat.reshape(len(col), -1) rows.append(chunk) n += chunk.shape[0] if n >= max_rows: break V = torch.from_numpy(np.concatenate(rows, axis=0)[:max_rows]) return compute_predict_mean_baselines(V, mse_scale) def compute_canonical_neighbors( tokenizer: Any, actor_template: str, injection_char: str, injection_token_id: int, ) -> tuple[int, int]: """Tokenize the canonical actor prompt, return token IDs at inj_pos ± 1. datagen calls this to POPULATE neighbor fields in the sidecar. config.py calls this to VERIFY them against the live tokenizer. Both must use identical tokenization — this function is that contract. add_generation_prompt=True matches RL's prompt format. AV-SFT training appends an assistant message instead, but the neighbors are inside the user content () so the trailing chat-template scaffolding is identical. """ content = actor_template.format(injection_char=injection_char) enc = tokenizer.apply_chat_template( [{"role": "user", "content": content}], tokenize=True, add_generation_prompt=True, ) # transformers v5 returns a BatchEncoding (dict-like) here; v4 returned a # plain list[int]. Normalise so `enumerate` walks token ids, not dict keys. ids = enc["input_ids"] if hasattr(enc, "keys") else enc matches = [i for i, tid in enumerate(ids) if tid == injection_token_id] assert len(matches) == 1, ( f"injection token id {injection_token_id} ({injection_char!r}) appears " f"{len(matches)}× in canonical actor prompt (expected 1). Template: {content!r}" ) p = matches[0] assert 0 < p < len(ids) - 1, ( f"injection token at position {p} is at edge of sequence (len={len(ids)})" ) return ids[p - 1], ids[p + 1]