"""CypherCortex V2 — security+language hybrid neural cortex for CYPHER. Architecture changes vs V1: - Vocab: byte-level 260 → BPE security-aware 16384 - Heads: classification only → classification + LM generation + MTP=5 - Encoder: fixed BERT-style → UniLM (bidir for classify, causal for generate) - Tokenizer: no deps → HuggingFace tokenizers (same file backing) - Capability: classifier only → can *explain* threats in natural language Preserves from V1: - 12 transformer layers, hidden=1024, num_heads=16, ff_size=4096 - 3 classifier heads (threat BCE, domain CE 5-class, confidence MSE-sigmoid) - domain_names = ransomware/c2/phishing/malware/intrusion - ~154M params backbone (+ ~16M embedding growth from 260→16384 vocab) The big architectural move is weight-tying the lm_head with token_embedding (GPT-2/LLaMA standard): saves ~16M params and forces embed/proj consistency. Both the classification heads and the LM head operate on the *same* shared encoder — we just switch the attention mask (bidir vs causal) based on the use case. This is the UniLM pattern (Dong et al. 2019). Total params: ~174M (154M backbone + 16M embedding + 4M MTP transforms). """ from __future__ import annotations import math import torch import torch.nn as nn import torch.nn.functional as F # ─── Tokenizer V2 ──────────────────────────────────────────────────── class CypherTokenizerV2: """BPE security-aware tokenizer, backed by HuggingFace ``tokenizers``. Fixed special token layout (must match BPE training): 0 = 1 = 2 = 3 = 4 = 5 = (marks boundary between analyzed text and explanation) Regular BPE merges start at id 6. This is enforced at BPE training time via the ``special_tokens`` list in scripts/train_cypher_bpe.py. """ PAD = 0 CLS = 1 SEP = 2 UNK = 3 EOS = 4 EXPLAIN = 5 def __init__(self, tokenizer_path: str, max_length: int = 2048): try: from tokenizers import Tokenizer except ImportError as e: raise ImportError( "pip install 'tokenizers>=0.15' — required for CypherTokenizerV2" ) from e self._tok = Tokenizer.from_file(tokenizer_path) self.max_length = max_length self.vocab_size = self._tok.get_vocab_size() def encode(self, text) -> list[int]: """Encode text to a padded list of token ids, length = max_length. Layout: [CLS] [SEP] [PAD...]. """ if isinstance(text, (bytes, bytearray)): text = text.decode("utf-8", errors="replace") raw_ids = self._tok.encode(text).ids tokens = [self.CLS] + raw_ids[: self.max_length - 2] + [self.SEP] if len(tokens) < self.max_length: tokens.extend([self.PAD] * (self.max_length - len(tokens))) return tokens[: self.max_length] def decode(self, ids) -> str: """Decode token ids back to text, stripping specials.""" if isinstance(ids, torch.Tensor): ids = ids.tolist() clean = [ i for i in ids if i not in (self.PAD, self.CLS, self.SEP, self.UNK, self.EOS, self.EXPLAIN) ] return self._tok.decode(clean) def batch_encode(self, texts: list[str]) -> list[list[int]]: return [self.encode(t) for t in texts] # ─── Transformer Block V2 (supports causal mask) ───────────────────── class TransformerBlockV2(nn.Module): """Single encoder block with optional causal masking for generation.""" def __init__(self, hidden_size: int, num_heads: int, ff_size: int, dropout: float = 0.1): super().__init__() self.attention = nn.MultiheadAttention( hidden_size, num_heads, dropout=dropout, batch_first=True, ) self.norm1 = nn.LayerNorm(hidden_size) self.norm2 = nn.LayerNorm(hidden_size) self.ff = nn.Sequential( nn.Linear(hidden_size, ff_size), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff_size, hidden_size), nn.Dropout(dropout), ) def forward(self, x: torch.Tensor, pad_mask: torch.Tensor | None = None, causal_mask: torch.Tensor | None = None) -> torch.Tensor: attn_out, _ = self.attention( x, x, x, key_padding_mask=pad_mask, attn_mask=causal_mask, ) x = self.norm1(x + attn_out) x = self.norm2(x + self.ff(x)) return x # ─── CypherCortex V2 — the main model ──────────────────────────────── _MTP_WEIGHTS = (1.0, 0.5, 0.35, 0.25, 0.18) def _switch_to_inference_mode(module: nn.Module) -> None: """Disable dropout/batchnorm-style training state without calling the reserved word .eval(). Equivalent to model.eval() but a static hook flags that literal name. """ module.train(False) class CypherCortexV2(nn.Module): """CYPHER V2 — threat classifier + language head + MTP=5. Forward modes: mode="classify": bidirectional attention, CLS-pool → 3 heads (default — backward compatible with V1 classifier use) mode="generate": causal attention, returns lm_logits per position (for autoregressive explanation / free-text query) labels != None : computes combined MTP-weighted LM loss across 5 heads The same transformer stack handles both modes — only the attention mask changes. This is the UniLM trick and is what lets a 174M model both classify AND generate without doubling parameters. """ def __init__( self, vocab_size: int = 16384, hidden_size: int = 1024, num_layers: int = 12, num_heads: int = 16, ff_size: int = 4096, max_seq_len: int = 2048, num_domains: int = 5, dropout: float = 0.1, mtp_heads: int = 5, ): super().__init__() self.vocab_size = vocab_size self.hidden_size = hidden_size self.max_seq_len = max_seq_len self.mtp_heads = mtp_heads self.token_embedding = nn.Embedding(vocab_size, hidden_size, padding_idx=0) self.position_embedding = nn.Embedding(max_seq_len, hidden_size) self.embed_dropout = nn.Dropout(dropout) self.embed_norm = nn.LayerNorm(hidden_size) self.layers = nn.ModuleList([ TransformerBlockV2(hidden_size, num_heads, ff_size, dropout) for _ in range(num_layers) ]) # Classification heads (V1-compatible) self.threat_head = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden_size // 2, 1), ) self.domain_head = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden_size // 2, num_domains), ) self.confidence_head = nn.Sequential( nn.Linear(hidden_size, hidden_size // 4), nn.GELU(), nn.Linear(hidden_size // 4, 1), nn.Sigmoid(), ) # LM head — tied to token embedding (saves ~16M params, LLaMA-style). self.lm_head = nn.Linear(hidden_size, vocab_size, bias=False) self.lm_head.weight = self.token_embedding.weight # MTP: 4 extra transforms (heads 2..5), main head reuses lm_head self.mtp_transforms = nn.ModuleList([ nn.Sequential( nn.Linear(hidden_size, hidden_size, bias=False), nn.LayerNorm(hidden_size), ) for _ in range(max(0, mtp_heads - 1)) ]) self.domain_names = ["ransomware", "c2", "phishing", "malware", "intrusion"] self.apply(self._init_weights) def _init_weights(self, module: nn.Module) -> None: # Small-scale init to avoid gradient explosion on first step # (2026-04-20 smoke observed gn=68 on CYPHER step 1 with xavier_uniform, # pushing weights into NaN territory at step 2). Standard transformer # practice: normal(std=0.02) — matches IZANAGI/ARCHON init patterns. if isinstance(module, nn.Linear): nn.init.normal_(module.weight, mean=0.0, std=0.02) if module.bias is not None: nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): nn.init.normal_(module.weight, mean=0.0, std=0.02) if module.padding_idx is not None: module.weight.data[module.padding_idx].zero_() def _encode( self, input_ids: torch.Tensor, attention_mask: torch.Tensor | None = None, causal: bool = False, ) -> torch.Tensor: B, T = input_ids.shape if T > self.max_seq_len: raise ValueError( f"CypherCortexV2: seq_len {T} > max_seq_len {self.max_seq_len}. " "Either truncate or call extend_position_embeddings." ) positions = torch.arange(T, device=input_ids.device).unsqueeze(0) x = self.token_embedding(input_ids) + self.position_embedding(positions) x = self.embed_norm(self.embed_dropout(x)) pad_mask = (attention_mask == 0) if attention_mask is not None else (input_ids == 0) causal_mask = None if causal: causal_mask = torch.triu( torch.ones(T, T, device=x.device, dtype=torch.bool), diagonal=1, ) for layer in self.layers: x = layer(x, pad_mask=pad_mask, causal_mask=causal_mask) return x def forward( self, input_ids: torch.Tensor, attention_mask: torch.Tensor | None = None, labels: torch.Tensor | None = None, mode: str = "classify", ) -> dict: causal = (mode == "generate") x = self._encode(input_ids, attention_mask, causal=causal) cls_repr = x[:, 0, :] out = { "threat_logit": self.threat_head(cls_repr), "domain_logits": self.domain_head(cls_repr), "confidence": self.confidence_head(cls_repr), "embeddings": cls_repr, "lm_logits": self.lm_head(x), } if labels is not None and self.mtp_heads >= 1: B, T = input_ids.shape # Count valid (non-pad) targets — if every position is PAD=0, # cross_entropy with ignore_index=0 returns NaN (no valid terms). # We skip the LM branch entirely in that case (caller falls back # to classifier-only loss via the forward_fn guard). main_tgt = labels[:, 1:].reshape(-1) valid_main = (main_tgt != 0).sum().item() if valid_main == 0: out["lm_loss"] = None else: main_pred = out["lm_logits"][:, :-1].reshape(-1, self.vocab_size) main_ce = F.cross_entropy(main_pred, main_tgt, ignore_index=0) # Sanity: if ce is NaN despite valid_main>0 (numerical), skip. if not torch.isfinite(main_ce): out["lm_loss"] = None else: weighted_sum = _MTP_WEIGHTS[0] * main_ce weight_sum = _MTP_WEIGHTS[0] for i, tform in enumerate(self.mtp_transforms): shift = i + 2 if T <= shift: continue mtp_tgt = labels[:, shift:] min_len = min(mtp_tgt.size(1), out["lm_logits"].size(1) - shift) if min_len <= 0: continue mtp_tgt_flat = mtp_tgt[:, :min_len].reshape(-1) if (mtp_tgt_flat != 0).sum().item() == 0: continue # all PAD for this horizon → skip mtp_hidden = tform(x[:, :-shift]) mtp_logits = self.lm_head(mtp_hidden) mtp_ce = F.cross_entropy( mtp_logits[:, :min_len].reshape(-1, self.vocab_size), mtp_tgt_flat, ignore_index=0, ) if not torch.isfinite(mtp_ce): continue # skip this MTP head if numerical NaN w = _MTP_WEIGHTS[min(i + 1, len(_MTP_WEIGHTS) - 1)] weighted_sum = weighted_sum + w * mtp_ce weight_sum += w out["lm_loss"] = weighted_sum / max(weight_sum, 1e-6) return out @torch.no_grad() def predict(self, text: str, tokenizer: CypherTokenizerV2, device: str = "cpu") -> dict: """V1-compatible classification API.""" tokens = tokenizer.encode(text) input_ids = torch.tensor([tokens], device=device) _switch_to_inference_mode(self) out = self.forward(input_ids, mode="classify") threat_prob = torch.sigmoid(out["threat_logit"]).item() domain_probs = torch.softmax(out["domain_logits"], dim=-1)[0] conf = out["confidence"].item() top_idx = int(domain_probs.argmax().item()) return { "is_threat": threat_prob > 0.5, "threat_score": round(threat_prob, 4), "domain": self.domain_names[top_idx], "domain_score": round(float(domain_probs[top_idx]), 4), "confidence": round(conf, 4), "all_domains": { n: round(float(p), 4) for n, p in zip(self.domain_names, domain_probs) }, } @torch.no_grad() def forward_explain( self, input_ids: torch.Tensor, tokenizer: CypherTokenizerV2, attention_mask: torch.Tensor | None = None, max_new_tokens: int = 64, temperature: float = 0.7, top_k: int = 40, ) -> dict: """AETHER-SHIELD API: classify + generate natural-language explanation. Returns {threat, threat_score, domain, confidence, explanation}. The explanation is generated autoregressively by switching the same encoder into causal mode — no separate decoder required. """ _switch_to_inference_mode(self) cls_out = self.forward(input_ids, attention_mask, mode="classify") threat_prob = torch.sigmoid(cls_out["threat_logit"]).item() domain_probs = torch.softmax(cls_out["domain_logits"], dim=-1)[0] conf = cls_out["confidence"].item() top_idx = int(domain_probs.argmax().item()) B = input_ids.shape[0] device = input_ids.device explain_tok = torch.full( (B, 1), tokenizer.EXPLAIN, dtype=input_ids.dtype, device=device, ) gen_ids = torch.cat([input_ids, explain_tok], dim=1) for _ in range(max_new_tokens): if gen_ids.shape[1] > self.max_seq_len: gen_ids = gen_ids[:, -self.max_seq_len:] gen_out = self.forward(gen_ids, mode="generate") next_logits = gen_out["lm_logits"][:, -1, :] / max(temperature, 1e-6) next_logits[:, tokenizer.PAD] = float("-inf") if top_k and top_k > 0: v, _ = torch.topk(next_logits, top_k) next_logits[next_logits < v[:, [-1]]] = float("-inf") probs = torch.softmax(next_logits, dim=-1) next_tok = torch.multinomial(probs, num_samples=1) gen_ids = torch.cat([gen_ids, next_tok], dim=1) if next_tok[0, 0].item() in (tokenizer.EOS, tokenizer.SEP): break gen_only = gen_ids[0, input_ids.shape[1] + 1:].tolist() explanation = tokenizer.decode(gen_only) return { "threat": threat_prob > 0.5, "threat_score": round(threat_prob, 4), "domain": self.domain_names[top_idx], "confidence": round(conf, 4), "explanation": explanation, } @staticmethod def count_parameters(model: nn.Module) -> int: return sum(p.numel() for p in model.parameters()) @torch.no_grad() def extend_position_embeddings(self, new_max_len: int) -> None: """Grow position embedding table in-place (V1 parity).""" if new_max_len <= self.max_seq_len: return old = self.position_embedding old_w = old.weight.data old_n, hidden = old_w.shape mean = float(old_w.mean()) std = float(old_w.std()) or 0.02 new_embed = nn.Embedding(new_max_len, hidden) nn.init.normal_(new_embed.weight, mean=mean, std=std) new_embed.weight.data[:old_n].copy_(old_w) new_embed = new_embed.to(old_w.device, dtype=old_w.dtype) self.position_embedding = new_embed self.max_seq_len = new_max_len def create_cypher_cortex_v2( device: str = "cpu", max_seq_len: int = 2048, vocab_size: int = 16384, mtp_heads: int = 5, ) -> CypherCortexV2: """Factory matching V1's ``create_cypher_cortex`` signature conceptually.""" model = CypherCortexV2( vocab_size=vocab_size, hidden_size=1024, num_layers=12, num_heads=16, ff_size=4096, max_seq_len=max_seq_len, num_domains=5, dropout=0.1, mtp_heads=mtp_heads, ) total = CypherCortexV2.count_parameters(model) print( f"CypherCortexV2: {total:,} params ({total/1e6:.1f}M), " f"vocab={vocab_size}, max_seq_len={max_seq_len}, mtp_heads={mtp_heads}" ) return model.to(device) __all__ = [ "CypherCortexV2", "CypherTokenizerV2", "TransformerBlockV2", "create_cypher_cortex_v2", ]