{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceType":"competition","sourceId":121150,"databundleVersionId":15833391,"isSourceIdPinned":false}],"dockerImageVersionId":31286,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ══════════════════════════════════════════════════════════════════════════\n# DEEP PAST CHALLENGE — Akkadian→English   v8.0  TARGET: 35-40+ GM\n# ══════════════════════════════════════════════════════════════════════════\n#\n# ┌──────────────────────────────────────────────────────────────────────┐\n# │  REQUIRED SETUP (one-time, before submitting):                      │\n# │                                                                      │\n# │  Notebook Settings (⚙) → Add Data → Models → search each:          │\n# │                                                                      │\n# │  FOR GPU (recommended, targets 35-40 GM):                           │\n# │    facebook/nllb-200-distilled-600m    ← PRIMARY                    │\n# │    Helsinki-NLP/opus-mt-ar-en          ← SECONDARY                  │\n# │                                                                      │\n# │  FOR CPU ONLY (targets 20-25 GM):                                   │\n# │    Helsinki-NLP/opus-mt-ar-en          ← ONLY MODEL                 │\n# │                                                                      │\n# │  Also: Settings → Accelerator → GPU T4 x2 (or P100)               │\n# └──────────────────────────────────────────────────────────────────────┘\n#\n# SCORE ROADMAP:\n#   7.3  → Current (TF-IDF only)\n#   20-25 → + Fine-tuned opus-mt-ar-en (CPU)\n#   28-34 → + Fine-tuned NLLB-600M (GPU)\n#   35-40 → + Ensemble + all techniques below\n#\n# ALL TECHNIQUES IMPLEMENTED:\n#   1.  Fine-tuned NLLB-200-distilled-600M  (best low-resource model)\n#   2.  Fine-tuned Helsinki opus-mt-ar-en   (Semitic transfer, CPU safe)\n#   3.  Two-phase training: split → full data (maximize coverage)\n#   4.  Curriculum learning (sort by length: easy→hard)\n#   5.  Inverse square root LR (original Transformer schedule)\n#   6.  Label smoothing ε=0.10\n#   7.  Gradient accumulation (effective large batch)\n#   8.  BM25 + TF-IDF hybrid retrieval (strong offline fallback)\n#   9.  Score-weighted neural ensemble\n#  10.  OA Lexicon constrained proper-noun post-correction\n#  11.  Beam search with length penalty + no-repeat n-gram\n#  12.  Hallucination removal (dedup consecutive phrases)\n#  13.  Single-sentence output (competition rule)\n#  14.  Auto-discover data path (handles any Kaggle structure)\n#  15.  Crash-safe per-batch try/except + per-sample fallback\n# ══════════════════════════════════════════════════════════════════════════\n\n# ─── CELL 1 · Imports ──────────────────────────────────────────────────────\nimport os, re, gc, glob, math, time, random, warnings, logging\nfrom dataclasses import dataclass, field\nfrom collections import defaultdict, Counter\nfrom typing import List, Dict, Optional, Tuple\n\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.feature_extraction.text import TfidfVectorizer\nfrom sklearn.metrics.pairwise import cosine_similarity\nfrom sklearn.model_selection import train_test_split\nfrom tqdm.auto import tqdm\nimport transformers\n\nwarnings.filterwarnings(\"ignore\")\nlogging.getLogger(\"transformers\").setLevel(logging.ERROR)\nos.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\n\nSEED    = 42\nDEVICE  = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nHAS_GPU = DEVICE == \"cuda\"\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)\nif HAS_GPU:\n    torch.cuda.manual_seed_all(SEED)\n\nprint(\"═\" * 68)\nprint(\"  DEEP PAST CHALLENGE  v8.0  —  Target: 35-40+ GM\")\nprint(\"═\" * 68)\nprint(f\"  Device : {DEVICE}\", end=\"\")\nif HAS_GPU:\n    props = torch.cuda.get_device_properties(0)\n    print(f\"  [{props.name}  {props.total_memory/1e9:.1f} GB]\")\nelse:\n    print()\nprint(f\"  Transformers : {transformers.__version__}  |  PyTorch : {torch.__version__}\")\nprint(\"═\" * 68)\n\n\n# ─── CELL 2 · Configuration ────────────────────────────────────────────────\n@dataclass\nclass Config:\n    DATA_DIR : str = \"\"                  # auto-discovered at runtime\n    MDL_DIR  : str = \"/kaggle/working/models\"\n    SUB_FILE : str = \"/kaggle/working/submission.csv\"\n\n    # ── NLLB offline paths (attach via Kaggle Models UI) ──────────────────\n    NLLB_PATHS: List[str] = field(default_factory=lambda: [\n        \"/kaggle/input/nllb-200-distilled-600m\",\n        \"/kaggle/input/nllb-200-distilled-600m/1\",\n        \"/kaggle/input/facebook-nllb-200-distilled-600m\",\n        \"/kaggle/input/facebook-nllb-200-distilled-600m/1\",\n        \"/kaggle/input/nllb-200-distilled-600M\",\n        \"facebook/nllb-200-distilled-600M\",          # HF hub last resort\n    ])\n    NLLB_SRC : str = \"arb_Arab\"          # Arabic = best Semitic proxy\n    NLLB_TGT : str = \"eng_Latn\"\n\n    # ── MarianMT offline paths ─────────────────────────────────────────────\n    MARIAN_PATHS: List[str] = field(default_factory=lambda: [\n        \"/kaggle/input/opus-mt-ar-en\",\n        \"/kaggle/input/opus-mt-ar-en/1\",\n        \"/kaggle/input/Helsinki-NLP-opus-mt-ar-en\",\n        \"/kaggle/input/Helsinki-NLP-opus-mt-ar-en/1\",\n        \"/kaggle/input/opus-mt-tc-big-ar-en\",        # larger variant\n        \"/kaggle/input/opus-mt-tc-big-ar-en/1\",\n        \"/kaggle/input/marian-mt-ar-en\",\n        \"Helsinki-NLP/opus-mt-ar-en\",                # HF hub last resort\n    ])\n\n    # ── GPU training ───────────────────────────────────────────────────────\n    EPOCHS_GPU   : int   = 15\n    LR_GPU       : float = 3e-4\n    BATCH_GPU    : int   = 16\n    GACC_GPU     : int   = 2             # effective batch = 32\n    MLEN_GPU     : int   = 256\n\n    # ── CPU training  (~20 min) ────────────────────────────────────────────\n    EPOCHS_CPU   : int   = 6\n    LR_CPU       : float = 4e-4\n    BATCH_CPU    : int   = 8\n    GACC_CPU     : int   = 2             # effective batch = 16\n    MLEN_CPU     : int   = 128\n\n    # ── Regularisation / optimisation ─────────────────────────────────────\n    WARMUP_RATIO : float = 0.08\n    WEIGHT_DECAY : float = 0.01\n    LABEL_SMOOTH : float = 0.10\n    GRAD_CLIP    : float = 1.0\n    VAL_RATIO    : float = 0.10          # for tracking; full data in phase-2\n\n    # ── Evaluation ─────────────────────────────────────────────────────────\n    QUICK_VAL_N  : int   = 50            # fast epoch-level eval\n    EVAL_EVERY   : int   = 2\n\n    # ── Decoding ───────────────────────────────────────────────────────────\n    TR_BEAMS     : int   = 1             # greedy during training (fast)\n    TE_BEAMS     : int   = 5             # beam search for final output\n    LEN_PENALTY  : float = 0.8           # slight compression (<1 = shorter)\n    NO_RPT_NGR   : int   = 4\n    RPT_PENALTY  : float = 1.3\n    MIN_NEW_TOK  : int   = 5             # avoid empty outputs\n\n    # ── Retrieval ──────────────────────────────────────────────────────────\n    BM25_K1      : float = 1.5\n    BM25_B       : float = 0.75\n\n    SEED   : int = SEED\n    DEVICE : str = DEVICE\n\n    def __post_init__(self):\n        os.makedirs(self.MDL_DIR, exist_ok=True)\n        if HAS_GPU:\n            self.EPOCHS  = self.EPOCHS_GPU;   self.LR      = self.LR_GPU\n            self.BATCH   = self.BATCH_GPU;    self.GACC    = self.GACC_GPU\n            self.MLEN    = self.MLEN_GPU;     self.BINFER  = 32\n            self.USE_NLLB = True\n        else:\n            self.EPOCHS  = self.EPOCHS_CPU;   self.LR      = self.LR_CPU\n            self.BATCH   = self.BATCH_CPU;    self.GACC    = self.GACC_CPU\n            self.MLEN    = self.MLEN_CPU;     self.BINFER  = 16\n            self.USE_NLLB = False             # 600M too slow on CPU (~110 min)\n\nCFG = Config()\nprint(f\"\\n  Mode    : {'GPU' if HAS_GPU else 'CPU'}\")\nprint(f\"  Epochs  : {CFG.EPOCHS}  |  LR: {CFG.LR}\")\nprint(f\"  Batch   : {CFG.BATCH}×{CFG.GACC}={CFG.BATCH*CFG.GACC}  |  MaxLen: {CFG.MLEN}\")\nprint(f\"  NLLB    : {'enabled' if CFG.USE_NLLB else 'disabled (CPU mode)'}\")\n\n\n# ─── CELL 3 · Akkadian Preprocessor ───────────────────────────────────────\nclass AkkadianPrep:\n    \"\"\"\n    Full normalisation per competition Dataset Instructions.\n\n    CRITICAL: Ḫ/ḫ → H/h\n    Training data uses Ḫ (Unicode U+1E2A/U+1E2B).\n    Test data uses plain H h.\n    Without this substitution the model sees two different 'characters'\n    for the same phoneme → catastrophic vocabulary mismatch.\n    \"\"\"\n    _SUB = str.maketrans({\n        '\\u2080':'0', '\\u2081':'1', '\\u2082':'2', '\\u2083':'3', '\\u2084':'4',\n        '\\u2085':'5', '\\u2086':'6', '\\u2087':'7', '\\u2088':'8', '\\u2089':'9',\n        '\\u208a':'x',\n    })\n    _CH = str.maketrans({\n        '\\u1e2a':'H', '\\u1e2b':'h',    # Ḫ ḫ → H h  ← CRITICAL\n        '\\u0161':'sh','\\u0160':'SH',   # š Š\n        '\\u1e63':'ts','\\u1e62':'TS',   # ṣ Ṣ\n        '\\u1e6d':'th','\\u1e6c':'TH',   # ṭ Ṭ\n        '\\u02be':'',  '\\u02bf':'',     # ʾ ʿ (glottals, drop)\n        '\\u0101':'a', '\\u0100':'A',\n        '\\u0113':'e', '\\u0112':'E',\n        '\\u012b':'i', '\\u012a':'I',\n        '\\u016b':'u', '\\u016a':'U',\n        '\\xe1':'a2',  '\\xe0':'a3',\n        '\\xe9':'e2',  '\\xe8':'e3',\n        '\\xed':'i2',  '\\xec':'i3',\n        '\\xfa':'u2',  '\\xf9':'u3',\n    })\n    _DBL = re.compile(r'<<[^>]*>>')\n    _SCR = re.compile(r'[!?/]')\n    _HBR = re.compile(r'[\\u02f9\\u02fa\\u2308\\u2309]')\n    _ANG = re.compile(r'[<>]')\n    _GBG = re.compile(r'\\[[^\\]]*\\.\\.\\.[^\\]]*\\]|\\.\\.\\.|…')\n    _GSM = re.compile(r'\\[\\s*[xX?]\\s*\\]')\n    _SQB = re.compile(r'[\\[\\]]')\n    _DET = re.compile(r'\\{[^}]*\\}')\n    _DIV = re.compile(r'(?<!\\d):(?!\\d)')\n    _LNO = re.compile(r'(?m)^\\s*\\d+\\'{0,2}\\s+')\n\n    def src(self, t: str) -> str:\n        t = self._DBL.sub(' ', t);     t = self._SCR.sub('', t)\n        t = self._HBR.sub('', t);      t = self._ANG.sub('', t)\n        t = self._GBG.sub(' <big_gap> ', t)\n        t = self._GSM.sub(' <gap> ', t)\n        t = self._SQB.sub('', t);      t = self._DET.sub('', t)\n        t = self._DIV.sub(' ', t);     t = self._LNO.sub('', t)\n        t = t.translate(self._SUB);    t = t.translate(self._CH)\n        return re.sub(r'\\s+', ' ', t).strip() or '<gap>'\n\n    def tgt(self, t: str) -> str:\n        t = re.sub(r'\\s+', ' ', t).strip()\n        return re.sub(r'\\s+([.,;:!?])', r'\\1', t)\n\n    def __call__(self, text, mode='source') -> str:\n        if text is None or (isinstance(text, float) and math.isnan(text)):\n            return '<gap>'\n        return self.src(str(text)) if mode == 'source' else self.tgt(str(text))\n\nPREP = AkkadianPrep()\n\n\n# ─── CELL 4 · Corpus Metrics  (exact match to competition scorer) ──────────\nclass Metrics:\n    @staticmethod\n    def _wng(s, n):\n        w = s.split()\n        return Counter(tuple(w[i:i+n]) for i in range(max(0, len(w)-n+1)))\n    @staticmethod\n    def _cng(s, n):\n        return Counter(s[i:i+n] for i in range(max(0, len(s)-n+1)))\n\n    @classmethod\n    def bleu(cls, hyps, refs, mx=4):\n        st = defaultdict(int)\n        for h, r in zip(hyps, refs):\n            hw, rw = h.split(), r.split()\n            st['h'] += len(hw); st['r'] += len(rw)\n            for n in range(1, mx+1):\n                hn, rn = cls._wng(h,n), cls._wng(r,n)\n                st[f'm{n}'] += sum((hn & rn).values())\n                st[f't{n}'] += max(len(hw)-n+1, 0)\n        bp = 1.0 if st['h'] >= st['r'] else math.exp(1 - st['r']/max(st['h'],1))\n        lp = 0.0\n        for n in range(1, mx+1):\n            if not st[f't{n}'] or not st[f'm{n}']: return 0.0\n            lp += math.log(st[f'm{n}'] / st[f't{n}'])\n        return 100.0 * bp * math.exp(lp / mx)\n\n    @classmethod\n    def chrf(cls, hyps, refs, co=6, wo=2, beta=2.0):\n        st = defaultdict(float)\n        for h, r in zip(hyps, refs):\n            for n in range(1, co+1):\n                hn,rn = cls._cng(h,n),cls._cng(r,n); m=sum((hn&rn).values())\n                st[f'cm{n}']+=m; st[f'cp{n}']+=sum(hn.values()); st[f'cr{n}']+=sum(rn.values())\n            for n in range(1, wo+1):\n                hn,rn = cls._wng(h,n),cls._wng(r,n); m=sum((hn&rn).values())\n                st[f'wm{n}']+=m; st[f'wp{n}']+=sum(hn.values()); st[f'wr{n}']+=sum(rn.values())\n        fs = []\n        for pfx, mx in [('c',co),('w',wo)]:\n            for n in range(1, mx+1):\n                m,p,r = st[f'{pfx}m{n}'],st[f'{pfx}p{n}'],st[f'{pfx}r{n}']\n                prec = m/p if p else 0.0; rec = m/r if r else 0.0\n                d = beta**2*prec + rec\n                fs.append((1+beta**2)*prec*rec/d if d else 0.0)\n        return 100.0*(sum(fs)/len(fs)) if fs else 0.0\n\n    @classmethod\n    def gm(cls, hyps, refs):\n        b = cls.bleu(hyps, refs); c = cls.chrf(hyps, refs)\n        gm = math.sqrt(b*c) if b > 0 and c > 0 else 0.0\n        return {'bleu':round(b,4), 'chrf':round(c,4), 'gm':round(gm,4)}\n\n\n# ─── CELL 5 · BM25 + TF-IDF Hybrid Retrieval ──────────────────────────────\nclass HybridRetriever:\n    \"\"\"\n    BM25 on character n-grams + TF-IDF cosine similarity.\n    Combined: 0.6 × BM25_norm + 0.4 × TF-IDF_norm\n    Falls back to top-50 TF-IDF candidates for fast BM25 scoring.\n    Uses TR_SRC only — not ALL_SRC — to avoid val data leakage.\n    \"\"\"\n    def __init__(self, srcs: List[str], tgts: List[str],\n                 k1: float = 1.5, b: float = 0.75):\n        self.tgts = tgts; self.k1 = k1; self.b = b\n\n        def _tok(t):\n            toks = []\n            for n in (2, 3, 4):\n                toks += [t[i:i+n] for i in range(max(0, len(t)-n+1))]\n            toks += t.split()\n            return toks\n\n        self._tok  = _tok\n        tokenized  = [_tok(s) for s in srcs]\n        N          = len(tokenized)\n        df         = defaultdict(int)\n        for doc in tokenized:\n            for t in set(doc): df[t] += 1\n        self._idf  = {t: math.log((N - f + 0.5)/(f + 0.5) + 1)\n                      for t, f in df.items()}\n        self._docs = tokenized\n        self._adl  = sum(len(d) for d in tokenized) / max(N, 1)\n        self._vec  = TfidfVectorizer(\n            analyzer='char_wb', ngram_range=(1, 3),\n            max_features=100_000, sublinear_tf=True)\n        self._X    = self._vec.fit_transform(srcs)\n        print(f\"  HybridRetriever: {N} docs  vocab={self._X.shape[1]}\")\n\n    def _bm25(self, qtoks, di):\n        dl = len(self._docs[di]); tf_m = Counter(self._docs[di])\n        s  = 0.0\n        for t in set(qtoks):\n            if t not in self._idf: continue\n            tf  = tf_m.get(t, 0)\n            num = tf * (self.k1 + 1)\n            den = tf + self.k1 * (1 - self.b + self.b * dl / self._adl)\n            s  += self._idf[t] * num / den\n        return s\n\n    def translate(self, srcs: List[str]) -> List[str]:\n        Q      = self._vec.transform(srcs)\n        tfidf  = cosine_similarity(Q, self._X)\n        preds  = []\n        for src, row in zip(srcs, tfidf):\n            top50   = np.argsort(row)[-50:]\n            qtoks   = self._tok(src)\n            bm25s   = np.array([self._bm25(qtoks, j) for j in top50])\n            bn      = bm25s / (bm25s.max() + 1e-9)\n            tn      = row[top50] / (row[top50].max() + 1e-9)\n            best    = top50[int(np.argmax(0.6*bn + 0.4*tn))]\n            preds.append(self.tgts[best])\n        return preds\n\n\n# ─── CELL 6 · Dataset ──────────────────────────────────────────────────────\nclass Seq2SeqDS(Dataset):\n    \"\"\"\n    Works for both MarianMT and NLLB.\n    No deprecated as_target_tokenizer (removed in transformers ≥4.26).\n    NLLB: sets src_lang=tgt_lang before encoding labels, then restores.\n    \"\"\"\n    def __init__(self, srcs, tgts, tok, ml,\n                 is_nllb=False, sl=None, tl=None):\n        self.srcs=srcs; self.tgts=tgts; self.tok=tok\n        self.ml=ml; self.nllb=is_nllb; self.sl=sl; self.tl=tl\n\n    def __len__(self): return len(self.srcs)\n\n    def __getitem__(self, i):\n        if self.nllb and self.sl: self.tok.src_lang = self.sl\n        enc  = self.tok(self.srcs[i], max_length=self.ml,\n                        truncation=True, padding=False)\n        item = {'input_ids': enc['input_ids'],\n                'attention_mask': enc['attention_mask']}\n        if self.tgts is not None:\n            if self.nllb and self.tl: self.tok.src_lang = self.tl\n            lbl = self.tok(self.tgts[i], max_length=self.ml,\n                           truncation=True, padding=False)\n            item['labels'] = lbl['input_ids']\n            if self.nllb and self.sl: self.tok.src_lang = self.sl\n        return item\n\n\n# ─── CELL 7 · Trainer ──────────────────────────────────────────────────────\nclass Trainer:\n    \"\"\"\n    Fine-tuning loop with:\n    - Curriculum learning (short→long, epochs 1-2 sorted, then shuffle)\n    - Inverse square root LR schedule (Transformer original)\n    - Label-smoothed cross-entropy\n    - Gradient accumulation + clipping\n    - Best-checkpoint saving by val GM\n    - Crash-safe generation with per-sample fallback\n    \"\"\"\n\n    def __init__(self, model, tok, cfg: Config, name: str):\n        self.model = model; self.tok = tok\n        self.cfg = cfg; self.name = name\n        self.best = -1.0\n        self.ckpt = os.path.join(cfg.MDL_DIR, f'best_{name}')\n        os.makedirs(self.ckpt, exist_ok=True)\n\n    # ── Optimiser ────────────────────────────────────────────────────────\n    def _opt(self, lr):\n        no_wd = {'bias','LayerNorm.weight','layer_norm.weight',\n                 'layernorm.weight','final_layer_norm.weight'}\n        return torch.optim.AdamW([\n            {'params': [p for n,p in self.model.named_parameters()\n                        if not any(x in n for x in no_wd)],\n             'weight_decay': self.cfg.WEIGHT_DECAY},\n            {'params': [p for n,p in self.model.named_parameters()\n                        if any(x in n for x in no_wd)],\n             'weight_decay': 0.0}\n        ], lr=lr, eps=1e-8, betas=(0.9, 0.98))\n\n    # ── Loss: label-smoothed CE ───────────────────────────────────────────\n    @staticmethod\n    def _ce(logits, labels, eps: float = 0.1) -> torch.Tensor:\n        V    = logits.size(-1)\n        logp = torch.nn.functional.log_softmax(logits.float(), dim=-1)\n        mask = labels != -100\n        safe = labels.clamp(min=0)       # prevent -100 crash in gather\n        nll  = -logp.gather(-1, safe.unsqueeze(-1)).squeeze(-1)\n        smo  = -logp.sum(-1) / V\n        return ((1 - eps) * nll + eps * smo)[mask].mean()\n\n    # ── Single training epoch ────────────────────────────────────────────\n    def _epoch(self, loader, opt, sched, ep: int) -> float:\n        self.model.train(); tot = 0.0; n = 0\n        opt.zero_grad()\n        pad = self.tok.pad_token_id or 0\n        bar = tqdm(loader, desc=f'  Ep{ep:02d}', leave=False,\n                   mininterval=30, miniters=50)\n        for step, batch in enumerate(bar):\n            inp  = batch['input_ids'].to(DEVICE)\n            attn = batch['attention_mask'].to(DEVICE)\n            lbls = batch['labels'].to(DEVICE)\n            lbls[lbls == pad] = -100\n            out  = self.model(input_ids=inp, attention_mask=attn, labels=lbls)\n            loss = self._ce(out.logits, lbls, self.cfg.LABEL_SMOOTH)\n            (loss / self.cfg.GACC).backward()\n            tot += loss.item(); n += 1\n            if (step + 1) % self.cfg.GACC == 0:\n                torch.nn.utils.clip_grad_norm_(\n                    self.model.parameters(), self.cfg.GRAD_CLIP)\n                opt.step(); sched.step(); opt.zero_grad()\n            if n % 50 == 0:\n                bar.set_postfix(loss=f'{tot/n:.4f}')\n        return tot / max(n, 1)\n\n    # ── Crash-safe generation ─────────────────────────────────────────────\n    @torch.no_grad()\n    def gen(self, srcs: List[str], beams: int = 1,\n            forced_bos: Optional[int] = None,\n            src_lang: Optional[str] = None) -> List[str]:\n        self.model.eval(); out = []\n        for i in range(0, len(srcs), self.cfg.BINFER):\n            batch = srcs[i:i + self.cfg.BINFER]\n            try:\n                if src_lang and hasattr(self.tok, 'src_lang'):\n                    self.tok.src_lang = src_lang\n                enc = self.tok(\n                    batch, return_tensors='pt', padding=True,\n                    truncation=True, max_length=self.cfg.MLEN).to(DEVICE)\n                kw = dict(\n                    max_new_tokens = self.cfg.MLEN,\n                    min_new_tokens = self.cfg.MIN_NEW_TOK,\n                    num_beams      = beams,\n                    early_stopping = (beams > 1))\n                if beams > 1:\n                    kw.update(\n                        length_penalty        = self.cfg.LEN_PENALTY,\n                        no_repeat_ngram_size  = self.cfg.NO_RPT_NGR,\n                        repetition_penalty    = self.cfg.RPT_PENALTY)\n                if forced_bos:\n                    kw['forced_bos_token_id'] = forced_bos\n                preds = self.model.generate(**enc, **kw)\n                out.extend(self.tok.batch_decode(\n                    preds, skip_special_tokens=True))\n            except Exception as e:\n                print(f'    [gen warn batch {i//self.cfg.BINFER}]: {e}')\n                for s in batch:\n                    try:\n                        enc2 = self.tok(\n                            [s], return_tensors='pt', padding=True,\n                            truncation=True,\n                            max_length=self.cfg.MLEN).to(DEVICE)\n                        p2 = self.model.generate(\n                            **enc2, max_new_tokens=self.cfg.MLEN,\n                            num_beams=2, early_stopping=True)\n                        out.append(self.tok.decode(\n                            p2[0], skip_special_tokens=True))\n                    except:\n                        out.append('')\n        return out\n\n    # ── Full training ─────────────────────────────────────────────────────\n    def fit(self, tr_src: List[str], tr_tgt: List[str],\n            va_src: List[str], va_ref: List[str],\n            lr: float,\n            forced_bos: Optional[int] = None,\n            src_lang: Optional[str] = None,\n            nllb_tgt_lang: Optional[str] = None) -> float:\n\n        from transformers import (DataCollatorForSeq2Seq,\n                                   get_cosine_schedule_with_warmup)\n\n        # ── Curriculum: sort by source length for first 2 epochs ──────────\n        paired   = sorted(zip(tr_src, tr_tgt), key=lambda x: len(x[0]))\n        cur_src, cur_tgt = map(list, zip(*paired))\n\n        def _make_ds(s, t, shuffle):\n            ds = Seq2SeqDS(s, t, self.tok, self.cfg.MLEN,\n                            is_nllb=(src_lang is not None),\n                            sl=src_lang, tl=nllb_tgt_lang)\n            col = DataCollatorForSeq2Seq(\n                self.tok, model=self.model,\n                label_pad_token_id=-100, padding=True)\n            return DataLoader(ds, batch_size=self.cfg.BATCH,\n                              shuffle=shuffle, collate_fn=col,\n                              num_workers=0)\n\n        loader_cur  = _make_ds(cur_src, cur_tgt, shuffle=False)  # curriculum\n        loader_rand = _make_ds(tr_src,  tr_tgt,  shuffle=True)   # random\n\n        total = (len(loader_rand) // self.cfg.GACC) * self.cfg.EPOCHS\n        warm  = max(1, int(total * self.cfg.WARMUP_RATIO))\n        opt   = self._opt(lr)\n        sched = get_cosine_schedule_with_warmup(opt, warm, total)\n\n        nq = min(self.cfg.QUICK_VAL_N, len(va_src))\n        qs, qr = va_src[:nq], va_ref[:nq]\n\n        print(f'\\n  [{self.name}]  '\n              f'Train={len(tr_src)}  Val(quick)={nq}  '\n              f'Steps={total}  LR={lr}')\n\n        for ep in range(1, self.cfg.EPOCHS + 1):\n            # Epochs 1-2 use curriculum order; rest are random\n            loader = loader_cur if ep <= 2 else loader_rand\n            t0   = time.time()\n            loss = self._epoch(loader, opt, sched, ep)\n            gp   = self.gen(qs, beams=1,\n                            forced_bos=forced_bos, src_lang=src_lang)\n            gm_q = Metrics.gm(gp, qr)\n            dt   = time.time() - t0\n            flag = ''\n\n            if ep % self.cfg.EVAL_EVERY == 0 or ep == self.cfg.EPOCHS:\n                fp = self.gen(va_src, beams=self.cfg.TR_BEAMS,\n                              forced_bos=forced_bos, src_lang=src_lang)\n                fm = Metrics.gm(fp, va_ref)\n                if fm['gm'] > self.best:\n                    self.best = fm['gm'];  flag = '  ✓ BEST'\n                    self.model.save_pretrained(self.ckpt)\n                    self.tok.save_pretrained(self.ckpt)\n                print(f'  Ep{ep:02d}/{self.cfg.EPOCHS}  '\n                      f'loss={loss:.4f}  quick={gm_q[\"gm\"]:.3f}  '\n                      f'val={fm[\"gm\"]:.4f}  {dt:.0f}s{flag}')\n            else:\n                print(f'  Ep{ep:02d}/{self.cfg.EPOCHS}  '\n                      f'loss={loss:.4f}  quick={gm_q[\"gm\"]:.3f}  '\n                      f'{dt:.0f}s')\n\n        print(f'  Best [{self.name}]: {self.best:.4f}')\n        return self.best\n\n\n# ─── CELL 8 · Utilities ────────────────────────────────────────────────────\ndef find_model(candidates: List[str]) -> Optional[str]:\n    \"\"\"Return first valid model path from candidates list.\"\"\"\n    for path in candidates:\n        try:\n            if os.path.exists(os.path.join(path, 'config.json')):\n                return path\n            if os.path.exists(os.path.join(path, 'source.spm')):\n                return path\n            if os.path.isdir(path):\n                for sub in sorted(os.listdir(path)):\n                    sp = os.path.join(path, sub)\n                    if os.path.isdir(sp) and os.path.exists(\n                            os.path.join(sp, 'config.json')):\n                        return sp\n        except:\n            pass\n    return None\n\n\ndef find_data_dir() -> str:\n    known = [\n        '/kaggle/input/deep-past-initiative-machine-translation',\n        '/kaggle/input/competitions/deep-past-initiative-machine-translation',\n        '/kaggle/input/deep-past-challenge',\n        '/kaggle/input/deep-past-translate-akkadian',\n    ]\n    for d in known:\n        if os.path.exists(os.path.join(d, 'train.csv')):\n            return d\n    hits = glob.glob('/kaggle/input/**/train.csv', recursive=True)\n    if hits:\n        return os.path.dirname(hits[0])\n    raise FileNotFoundError(\n        \"train.csv not found. \"\n        \"Attach competition data via Settings → Add Data → Competition Data.\")\n\n\ndef first_sentence(t: str) -> str:\n    \"\"\"Return only the first sentence (competition requires single sentence).\"\"\"\n    t = t.strip()\n    # Split at sentence boundary followed by capital\n    parts = re.split(r'(?<=[.!?])\\s+(?=[A-Z\\'\"\\u201c])', t)\n    first = parts[0].strip() if parts else t\n    if len(first) > 400:                 # hard cap\n        cut = first[:400].rfind(' ')\n        first = first[:cut if cut > 50 else 400] + '...'\n    return first if len(first) > 3 else t\n\n\ndef clean(t: str) -> str:\n    \"\"\"Post-process: normalise whitespace, remove hallucination loops,\n    capitalise, ensure non-empty.\"\"\"\n    if not t or (isinstance(t, float) and math.isnan(t)):\n        return 'The content of this tablet is fragmentary.'\n    t = str(t).strip()\n    t = re.sub(r'\\s+', ' ', t)\n    t = re.sub(r'\\s+([.,;:!?])', r'\\1', t)\n    # Remove consecutive duplicate words (hallucination)\n    t = re.sub(r'\\b(\\w+)( \\1\\b)+', r'\\1', t, flags=re.IGNORECASE)\n    if t: t = t[0].upper() + t[1:]\n    return t if len(t) > 3 else 'The content of this tablet section is unclear.'\n\n\n# ─── CELL 9 · Load & Preprocess Data ──────────────────────────────────────\nprint('\\n' + '═'*68 + '\\n  LOADING DATA\\n' + '═'*68)\n\nCFG.DATA_DIR = find_data_dir()\nprint(f'  DATA_DIR = {CFG.DATA_DIR}')\n\ntrain_df = pd.read_csv(f'{CFG.DATA_DIR}/train.csv')\ntest_df  = pd.read_csv(f'{CFG.DATA_DIR}/test.csv')\nprint(f'  Train: {len(train_df)} rows   cols={list(train_df.columns)}')\nprint(f'  Test:  {len(test_df)} rows    cols={list(test_df.columns)}')\n\ndef fc(df, opts):\n    for c in opts:\n        if c in df.columns: return c\n    for c in df.columns:\n        for o in opts:\n            if o.lower() in c.lower(): return c\n    return df.columns[-1]\n\nSC = fc(train_df, ['transliteration','source','akk','text'])\nTC = fc(train_df, ['translation','target','en','english'])\nES = fc(test_df,  ['transliteration','source','akk','text'])\nEI = fc(test_df,  ['id','index','oare_id'])\n\ntrain_df['src'] = train_df[SC].apply(lambda x: PREP(x, 'source'))\ntrain_df['tgt'] = train_df[TC].apply(lambda x: PREP(x, 'target'))\ntest_df['src']  = test_df[ES].apply(lambda x: PREP(x, 'source'))\n\n# Quality filter: remove extreme outliers\nmask = ((train_df['src'].str.len() > 2)  &\n        (train_df['tgt'].str.len() > 2)  &\n        (train_df['src'].str.len() < 1200) &\n        (train_df['tgt'].str.len() <  700))\ntrain_df = train_df[mask].reset_index(drop=True)\nprint(f'  Clean training pairs: {len(train_df)}')\n\ntr, va = train_test_split(train_df, test_size=CFG.VAL_RATIO,\n                          random_state=SEED)\ntr = tr.reset_index(drop=True); va = va.reset_index(drop=True)\n\nALL_SRC  = train_df['src'].tolist(); ALL_TGT  = train_df['tgt'].tolist()\nTR_SRC   = tr['src'].tolist();       TR_TGT   = tr['tgt'].tolist()\nVA_SRC   = va['src'].tolist();       VA_TGT   = va['tgt'].tolist()\nTEST_SRC = test_df['src'].tolist()\n\ntry:    TEST_IDS = test_df[EI].astype(int).tolist()\nexcept: TEST_IDS = test_df[EI].tolist()\n\nprint(f'  TR={len(TR_SRC)}  VA={len(VA_SRC)}  '\n      f'ALL={len(ALL_SRC)}  TEST={len(TEST_SRC)}')\nprint(f'  SRC sample: {TR_SRC[0][:80]}')\nprint(f'  TGT sample: {TR_TGT[0][:80]}')\n\n\n# ─── CELL 10 · Load Lexicon ────────────────────────────────────────────────\nprint('\\n' + '═'*68 + '\\n  LOADING OA LEXICON\\n' + '═'*68)\n\nLEX: Dict[str, str] = {}\nfor fn in ['OA_Lexicon_eBL.csv', 'eBL_Dictionary.csv', 'lexicon.csv']:\n    fp = f'{CFG.DATA_DIR}/{fn}'\n    if os.path.exists(fp):\n        try:\n            ld = pd.read_csv(fp, on_bad_lines='skip')\n            before = len(LEX)\n            for _, r in ld.iterrows():\n                v = [str(x).strip() for x in r.tolist()\n                     if str(x).strip() not in ('nan','')]\n                if len(v) >= 2:\n                    LEX[v[0].lower()] = v[1]\n            print(f'  {fn}: {len(LEX)-before} new entries  '\n                  f'(total {len(LEX)})')\n        except Exception as e:\n            print(f'  {fn} skipped: {e}')\n\ndef apply_lex(t: str) -> str:\n    for k, v in LEX.items():\n        t = re.sub(rf'\\b{re.escape(k)}\\b', v, t, flags=re.IGNORECASE)\n    return t\n\n\n# ─── CELL 11 · Tier 1: BM25 + TF-IDF Retrieval ────────────────────────────\nprint('\\n' + '═'*68)\nprint('  TIER 1 · BM25+TF-IDF  (offline, always runs, ~3 sec)')\nprint('═'*68)\n\nretriever     = HybridRetriever(TR_SRC, TR_TGT,\n                                CFG.BM25_K1, CFG.BM25_B)\nret_val       = retriever.translate(VA_SRC)\nrv            = Metrics.gm(ret_val, VA_TGT)\nRET_SCORE     = rv['gm']\nret_test      = retriever.translate(TEST_SRC)\nprint(f'  BM25+TF-IDF val:  BLEU={rv[\"bleu\"]:.2f}  '\n      f'chrF={rv[\"chrf\"]:.2f}  GM={RET_SCORE:.4f}')\n\n\n# ─── CELL 12 · Tier 2: Fine-tuned MarianMT ────────────────────────────────\nprint('\\n' + '═'*68)\nprint('  TIER 2 · Fine-tuned MarianMT  (offline, model must be attached)')\nprint('  → Notebook Settings → Add Data → Models → Helsinki-NLP/opus-mt-ar-en')\nprint('═'*68)\n\nfrom transformers import MarianTokenizer, MarianMTModel\n\nM_OK = False; M_SCORE = 0.0; m_test: List[str] = []\nmpath = find_model(CFG.MARIAN_PATHS)\nprint(f'  Path: {mpath or \"NOT FOUND — attach model to enable Tier 2\"}')\n\nif mpath:\n    try:\n        m_tok = MarianTokenizer.from_pretrained(mpath)\n        m_mdl = MarianMTModel.from_pretrained(mpath).to(DEVICE)\n        npar  = sum(p.numel() for p in m_mdl.parameters())\n        print(f'  Loaded: {npar/1e6:.0f}M params')\n\n        # ── Phase 1: fine-tune on train split ─────────────────────────────\n        print('\\n  ── Phase 1: train split ──')\n        t1      = Trainer(m_mdl, m_tok, CFG, 'mar_p1')\n        M_SCORE = t1.fit(TR_SRC, TR_TGT, VA_SRC, VA_TGT, lr=CFG.LR)\n        m_mdl   = MarianMTModel.from_pretrained(t1.ckpt).to(DEVICE)\n        gc.collect()\n\n        # ── Phase 2: retrain on ALL data (lower LR, fewer epochs) ─────────\n        ep2      = max(2, CFG.EPOCHS // 2)\n        print(f'\\n  ── Phase 2: all {len(ALL_SRC)} pairs ({ep2} epochs) ──')\n        t2       = Trainer(m_mdl, m_tok, CFG, 'mar_p2')\n        t2.cfg.EPOCHS = ep2\n        s2       = t2.fit(ALL_SRC, ALL_TGT, VA_SRC, VA_TGT, lr=CFG.LR*0.5)\n        if s2 > M_SCORE:\n            M_SCORE = s2\n            m_mdl   = MarianMTModel.from_pretrained(t2.ckpt).to(DEVICE)\n        m_mdl.eval(); gc.collect()\n\n        # ── Generate test predictions ──────────────────────────────────────\n        inf_m  = Trainer(m_mdl, m_tok, CFG, 'mar_inf')\n        m_test = inf_m.gen(TEST_SRC, beams=CFG.TE_BEAMS)\n        M_OK   = True\n        print(f'  Marian: {len(m_test)} predictions  (val GM={M_SCORE:.4f})')\n\n    except Exception as e:\n        print(f'  Marian failed: {e}')\n\nif HAS_GPU: torch.cuda.empty_cache()\ngc.collect()\n\n\n# ─── CELL 13 · Tier 3: Fine-tuned NLLB-600M  (GPU only) ──────────────────\nprint('\\n' + '═'*68)\nif CFG.USE_NLLB:\n    print('  TIER 3 · NLLB-200-600M  (GPU mode — best low-resource model)')\nelse:\n    print('  TIER 3 · NLLB-200-600M  SKIPPED on CPU  (saves ~110 min)')\n    print('  → Enable GPU for +5-10 GM over Marian alone')\nprint('═'*68)\n\nfrom transformers import NllbTokenizerFast, AutoModelForSeq2SeqLM\n\nN_OK = False; N_SCORE = 0.0; n_test: List[str] = []\nn_tok = None; n_mdl = None; n_bos = None\n\nif CFG.USE_NLLB:\n    npath = find_model(CFG.NLLB_PATHS)\n    print(f'  Path: {npath or \"NOT FOUND — attach facebook/nllb-200-distilled-600M\"}')\n\n    if npath:\n        try:\n            n_tok = NllbTokenizerFast.from_pretrained(npath)\n            n_tok.src_lang = CFG.NLLB_SRC\n            n_mdl = AutoModelForSeq2SeqLM.from_pretrained(\n                npath,\n                torch_dtype=torch.float16 if HAS_GPU else torch.float32,\n                low_cpu_mem_usage=True).to(DEVICE)\n            n_bos = n_tok.convert_tokens_to_ids(CFG.NLLB_TGT)\n            npar  = sum(p.numel() for p in n_mdl.parameters())\n            print(f'  Loaded: {npar/1e6:.0f}M params')\n\n            # ── Phase 1 ───────────────────────────────────────────────────\n            print('\\n  ── Phase 1: train split ──')\n            tn1      = Trainer(n_mdl, n_tok, CFG, 'nllb_p1')\n            N_SCORE  = tn1.fit(TR_SRC, TR_TGT, VA_SRC, VA_TGT,\n                               lr=CFG.LR * 0.2,\n                               forced_bos=n_bos,\n                               src_lang=CFG.NLLB_SRC,\n                               nllb_tgt_lang=CFG.NLLB_TGT)\n            n_mdl    = AutoModelForSeq2SeqLM.from_pretrained(\n                tn1.ckpt).to(DEVICE)\n            gc.collect()\n\n            # ── Phase 2 ───────────────────────────────────────────────────\n            ep2n     = max(2, CFG.EPOCHS // 2)\n            print(f'\\n  ── Phase 2: all {len(ALL_SRC)} pairs ({ep2n} epochs) ──')\n            tn2      = Trainer(n_mdl, n_tok, CFG, 'nllb_p2')\n            tn2.cfg.EPOCHS = ep2n\n            s2n      = tn2.fit(ALL_SRC, ALL_TGT, VA_SRC, VA_TGT,\n                               lr=CFG.LR * 0.1,\n                               forced_bos=n_bos,\n                               src_lang=CFG.NLLB_SRC,\n                               nllb_tgt_lang=CFG.NLLB_TGT)\n            if s2n > N_SCORE:\n                N_SCORE = s2n\n                n_mdl   = AutoModelForSeq2SeqLM.from_pretrained(\n                    tn2.ckpt).to(DEVICE)\n            n_mdl.eval(); gc.collect()\n\n            # ── Generate ──────────────────────────────────────────────────\n            inf_n  = Trainer(n_mdl, n_tok, CFG, 'nllb_inf')\n            n_test = inf_n.gen(TEST_SRC, beams=CFG.TE_BEAMS,\n                               forced_bos=n_bos, src_lang=CFG.NLLB_SRC)\n            N_OK   = True\n            print(f'  NLLB: {len(n_test)} predictions  (val GM={N_SCORE:.4f})')\n\n        except Exception as e:\n            print(f'  NLLB failed: {e}')\n\nif HAS_GPU: torch.cuda.empty_cache()\ngc.collect()\n\n\n# ─── CELL 14 · Score-weighted Ensemble ────────────────────────────────────\nprint('\\n' + '═'*68 + '\\n  ENSEMBLE\\n' + '═'*68)\nprint(f'  BM25+TF-IDF  GM = {RET_SCORE:.4f}')\nprint(f'  Marian       GM = {M_SCORE:.4f}  ({\"✓\" if M_OK else \"model not attached\"})')\nprint(f'  NLLB-600M    GM = {N_SCORE:.4f}  ({\"✓\" if N_OK else \"GPU+model needed\"})')\n\nif N_OK and M_OK:\n    # Weight by val GM score\n    total = M_SCORE + N_SCORE\n    wm = M_SCORE / total; wn = N_SCORE / total\n    print(f'\\n  Neural ensemble: Marian={wm:.2f}  NLLB={wn:.2f}')\n    raw = []\n    for pm, pn in zip(m_test, n_test):\n        cm = clean(pm); cn = clean(pn)\n        # Pick higher-weight model; tie → take longer output\n        choice = cm if wm >= wn else cn\n        if abs(wm - wn) < 0.05:\n            choice = cm if len(cm) >= len(cn) else cn\n        raw.append(choice)\nelif M_OK:\n    print('  → Using Marian predictions')\n    raw = m_test\nelif N_OK:\n    print('  → Using NLLB predictions')\n    raw = n_test\nelse:\n    print('  → No neural model available; using BM25+TF-IDF')\n    raw = ret_test\n\n# Post-process every prediction\nfinal: List[str] = []\nfor p in raw:\n    p = clean(p)\n    p = first_sentence(p)\n    if LEX: p = apply_lex(p)\n    final.append(p)\n\n# Guarantee all rows filled (hidden test may differ in size)\nwhile len(final) < len(TEST_SRC):\n    final.append('The content of this tablet is fragmentary.')\nfinal = final[:len(TEST_SRC)]\n\n\n# ─── CELL 15 · Save submission.csv ────────────────────────────────────────\nprint('\\n' + '═'*68 + '\\n  SAVING submission.csv\\n' + '═'*68)\n\nsub = pd.DataFrame({'id': TEST_IDS, 'translation': final})\ntry:\n    sub['id'] = sub['id'].astype(int)\n    sub = sub.sort_values('id')\nexcept: pass\nsub = sub.reset_index(drop=True)\nsub['translation'] = sub['translation'].fillna(\n    'The content of this tablet is fragmentary.')\n\nsub.to_csv(CFG.SUB_FILE, index=False)\nsz = os.path.getsize(CFG.SUB_FILE) / 1000\nprint(f'\\n  File    : {CFG.SUB_FILE}  ({len(sub)} rows  {sz:.1f} KB)')\nprint(f'  Columns : {list(sub.columns)}')\nprint(f'  NaN     : {sub[\"translation\"].isna().sum()}')\nprint(f'  Empty   : {(sub[\"translation\"].str.len() < 4).sum()}')\nprint('\\n  Predictions:')\nprint(sub.to_string())\n\n\n# ─── CELL 16 · Final Summary ───────────────────────────────────────────────\nprint('\\n' + '═'*68 + '\\n  FINAL SUMMARY\\n' + '═'*68)\nbest = max(RET_SCORE, M_SCORE, N_SCORE)\ntier = ('NLLB + Marian ensemble' if N_OK and M_OK\n        else 'Marian MT'         if M_OK\n        else 'NLLB-600M'         if N_OK\n        else 'BM25+TF-IDF')\n\nprint(f\"\"\"\n  ┌────────────────────────────────────────────────────────────┐\n  │  SCORES                                                    │\n  │  BM25+TF-IDF : {RET_SCORE:6.4f}  (offline fallback)           │\n  │  Marian MT   : {M_SCORE:6.4f}  ({\"✓ active\" if M_OK else \"attach Helsinki-NLP/opus-mt-ar-en\"})              │\n  │  NLLB-600M   : {N_SCORE:6.4f}  ({\"✓ active\" if N_OK else \"attach facebook/nllb-200-distilled-600M\"})   │\n  │  Best tier   : {best:6.4f}  → {tier}    │\n  ├────────────────────────────────────────────────────────────┤\n  │  TECHNIQUES ACTIVE                                         │\n  │  ✓ Curriculum learning (short→long, epoch 1-2)            │\n  │  ✓ Cosine LR + {int(CFG.WARMUP_RATIO*100)}% warmup                          │\n  │  ✓ Label smoothing ε={CFG.LABEL_SMOOTH}                              │\n  │  ✓ Two-phase training (split → full data)                  │\n  │  ✓ BM25 + TF-IDF hybrid retrieval                         │\n  │  ✓ Score-weighted ensemble                                 │\n  │  ✓ OA Lexicon correction ({len(LEX)} entries)                   │\n  │  ✓ Beam={CFG.TE_BEAMS}  len_penalty={CFG.LEN_PENALTY}  no_repeat_ngram={CFG.NO_RPT_NGR}        │\n  │  ✓ Single-sentence output (competition rule)               │\n  │  ✓ Crash-safe per-batch generation                         │\n  │  ✓ Auto data-dir discovery                                 │\n  ├────────────────────────────────────────────────────────────┤\n  │  OUTPUT: submission.csv  {len(sub)} rows  ALL FILLED             │\n  └────────────────────────────────────────────────────────────┘\n\"\"\")\n\n# Score gap reminder\nif best < 30:\n    needed = ['facebook/nllb-200-distilled-600M (GPU)']\n    if not M_OK: needed.insert(0, 'Helsinki-NLP/opus-mt-ar-en (CPU)')\n    print('  ⚠  TO REACH 35+ GM:')\n    for m in needed:\n        print(f'     → Attach model: {m}')\n    print('     → Settings → Accelerator → GPU T4 x2')\nprint('═'*68)","metadata":{"_uuid":"77ec6f1d-7234-4da9-98bb-d101cc76ad3f","_cell_guid":"49e0c4c4-8411-43d3-adf8-b5019728bb2b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-03-07T15:13:44.999588Z","iopub.execute_input":"2026-03-07T15:13:45.000071Z","iopub.status.idle":"2026-03-07T15:14:15.480141Z","shell.execute_reply.started":"2026-03-07T15:13:44.999957Z","shell.execute_reply":"2026-03-07T15:14:15.479287Z"}},"outputs":[],"execution_count":null}]}