"""DroidDetect loader — ModernBERT 4-class code-authorship detector. The published checkpoints are NOT `AutoModelForSequenceClassification`. Their config.json is a 4-line stub (`"model_type": "custom_model"`), so the wrapper has to be rebuilt by hand. Everything it needs is inferable from the tensor shapes, so this file hardcodes no dimensions: text_encoder.* ModernBERT encoder (768 hidden = Base, 1024 = Large) text_projection hidden -> proj_dim (128 on Base, 256 on Large) classifier proj_dim -> 4 classes DroidDetect-Large additionally ships a *second complete encoder* under `additional_loss.sentence_embedder.*` — 395M parameters used only for the training-time triplet loss. It is dropped on load, which halves resident memory with no effect on predictions. That is what makes Large fit the free CPU tier. """ import os # `spaces` must be imported BEFORE torch: on ZeroGPU it installs a CUDA # emulation layer so `.to("cuda")` at module level works without a real GPU # attached. Absent (a plain CPU Space, or local dev) the fallback below makes # the decorator a no-op, so one codebase runs on both. try: import spaces _gpu = spaces.GPU except ImportError: # not a ZeroGPU Space def _gpu(fn=None, **_kwargs): """No-op stand-in for @spaces.GPU.""" return fn if fn is not None else (lambda f: f) import torch import torch.nn as nn from huggingface_hub import hf_hub_download from transformers import AutoConfig, AutoModel, AutoTokenizer # HF sets this on ZeroGPU hardware. It is the switch for everything below. ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU")) DEVICE = "cuda" if ZERO_GPU else "cpu" # Seconds of GPU the decorated function may hold. Ours is one forward pass over # at most 2048 tokens — well under a second on the Blackwell slice. Asking for # less than the 60s default improves this Space's queue priority. GPU_DURATION = int(os.environ.get("GPU_DURATION", "30")) # Large is the default: +2 points of F1 over Base, and it still fits the free # CPU tier once the training-only twin encoder is dropped (~1.7 GB resident of # the 16 GB available). Set DROID_REPO=project-droid/DroidDetect-Base to trade # accuracy for ~4x faster inference. REPO = os.environ.get("DROID_REPO", "project-droid/DroidDetect-Large") # ModernBERT handles 8192 tokens, but on 2 shared vCPUs every token costs real # wall-clock. Contest submissions are a few hundred tokens; 2048 leaves large # headroom and bounds the worst case to a few seconds. Raise it only if you # actually see `truncated: true` in production. MAX_LENGTH = int(os.environ.get("MAX_LENGTH", "2048")) # How the encoder's token states are collapsed into one vector before the head. # The checkpoint does not record this and the model card does not say. Run # smoke_test.py once — it tries both and tells you which one this checkpoint # was trained with. See DEPLOY.md step 5. POOLING = os.environ.get("POOLING", "mean") # CPU Spaces get 2 vCPUs. Letting torch spawn a thread per core it *detects* on # a shared host causes contention that makes inference slower, not faster. # Irrelevant on ZeroGPU, where the forward pass runs on the GPU. if not ZERO_GPU: torch.set_num_threads(int(os.environ.get("TORCH_THREADS", "2"))) # Label taxonomy + the coarse collapses live in labels.py so they can be tested # without torch. Re-exported here so callers have one import site. from labels import ( # noqa: E402 (kept with the other detector constants) BINARY_LABELS, BINARY_OF, LABELS, TERNARY_LABELS, TERNARY_MEANING, TERNARY_OF, collapse, ) __all__ = [ "BINARY_LABELS", "BINARY_OF", "LABELS", "MAX_LENGTH", "POOLING", "REPO", "TERNARY_LABELS", "TERNARY_MEANING", "TERNARY_OF", "classify", "collapse", "load_model", ] # hidden size -> the backbone that produced it BACKBONES = { 768: "answerdotai/ModernBERT-base", 1024: "answerdotai/ModernBERT-large", } # Checkpoint filenames to try, in order. WEIGHT_FILES = ("model.safetensors", "pytorch_model.bin") class TLModel(nn.Module): """Encoder -> projection -> classifier, matching the checkpoint's layout.""" def __init__(self, encoder, hidden, proj_dim, num_classes): super().__init__() self.text_encoder = encoder self.text_projection = nn.Linear(hidden, proj_dim) self.classifier = nn.Linear(proj_dim, num_classes) def forward(self, input_ids, attention_mask, pooling=POOLING): states = self.text_encoder( input_ids=input_ids, attention_mask=attention_mask, ).last_hidden_state if pooling == "cls": pooled = states[:, 0] else: # Mean over real tokens only — padding must not drag the vector. mask = attention_mask.unsqueeze(-1).to(states.dtype) pooled = (states * mask).sum(1) / mask.sum(1).clamp(min=1e-9) embedding = self.text_projection(pooled) return self.classifier(embedding), embedding tokenizer = None model = None def _download_state(): """Fetch the checkpoint, preferring safetensors. Returns a state dict.""" last = None for name in WEIGHT_FILES: try: path = hf_hub_download(repo_id=REPO, filename=name) except Exception as err: # not published in this format — try the next last = err continue if name.endswith(".safetensors"): from safetensors.torch import load_file return load_file(path, device="cpu") return torch.load(path, map_location="cpu", weights_only=True) raise RuntimeError(f"No usable checkpoint found in {REPO}: {last}") def load_model(): """Download and build the detector. Idempotent; safe to call repeatedly.""" global tokenizer, model if model is not None: return state = _download_state() # Training-only twin encoder — never used at inference. Dropping it takes # Large from ~790M resident parameters to ~395M. state = {k: v for k, v in state.items() if not k.startswith("additional_loss.")} proj_dim, hidden = state["text_projection.weight"].shape num_classes = state["classifier.weight"].shape[0] backbone = BACKBONES.get(hidden) if backbone is None: raise RuntimeError(f"Unrecognized encoder hidden size {hidden} in {REPO}") # from_config, not from_pretrained: the checkpoint carries every encoder # weight already, so pulling the base model would be a wasted download. config = AutoConfig.from_pretrained(backbone) # ModernBERT torch.compile()s part of itself when this is on. Must stay off # on BOTH targets: ZeroGPU does not support torch.compile at all, and on a # CPU Space it's a long first-request stall (or an outright failure on some # torch builds) for no throughput gain. config.reference_compile = False try: # Ask for SDPA explicitly so transformers never tries to import # flash-attn, which isn't installed and is CUDA-only anyway. encoder = AutoModel.from_config(config, attn_implementation="sdpa") except (TypeError, ValueError): # Older transformers without the kwarg — the CPU default is fine. encoder = AutoModel.from_config(config) net = TLModel(encoder, hidden, proj_dim, num_classes) # strict: a silently mismatched head would return confident nonsense. net.load_state_dict(state, strict=True) net.float() net.eval() # On ZeroGPU this must happen at startup, not inside the @spaces.GPU # function: CUDA transfers are optimised for placements done during # startup, and lazy moves are explicitly discouraged. Outside a GPU # function this is absorbed by the CUDA emulation layer; inside one, the # weights are already really there. net.to(DEVICE) tokenizer = AutoTokenizer.from_pretrained(REPO) model = net @_gpu(duration=GPU_DURATION) @torch.no_grad() def classify(code_text, pooling=None): """Return (probabilities dict, embedding list, token count, truncated). On ZeroGPU this whole body runs in a forked worker with a real GPU attached. Everything it returns is plain Python (dict / list / int / bool) so it survives the process boundary. """ load_model() encoded = tokenizer( [code_text], truncation=True, max_length=MAX_LENGTH, return_tensors="pt", ) input_ids = encoded["input_ids"].to(DEVICE) attention_mask = encoded["attention_mask"].to(DEVICE) logits, embedding = model( input_ids=input_ids, attention_mask=attention_mask, pooling=pooling or POOLING, ) probs = torch.softmax(logits[0], dim=-1) return ( {label: round(float(p), 6) for label, p in zip(LABELS, probs)}, [round(float(v), 4) for v in embedding[0]], int(attention_mask.sum().item()), bool(input_ids.shape[1] >= MAX_LENGTH), ) # ZeroGPU needs the weights resident and placed on cuda at ROOT MODULE LEVEL, # before the first @spaces.GPU call — lazy placement inside the decorated # function is explicitly discouraged and much slower. On CPU we stay lazy: # app.py warm-loads in __main__ instead. if ZERO_GPU: load_model()