/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /* * test_overfit.c — Training-loop smoke test for the RWKV-x from-scratch trainer, * with the optimizer SWAPPABLE between AdamW (all-continuous baseline) and SR4 * (ternary projection matrices via the latent 4-bit accumulator). * * This is the FIRST proof that either optimizer drives an actual language model: * we overfit a single FIXED random token sequence (pure memorization, no corpus) * and watch the cross-entropy fall. * * - AdamW arm : every parameter is continuous, trained by foundry optim * (fo_adamw_new + fo_step). Expected to drive CE -> ~0 * (near-perfect memorization) => proves the loop is correct. * - SR4 arm : the big linear projection matrices (attention + time-mix * receptance/key/value/output, channel-mix ffn_key/ffn_value) * are TERNARY {-1,0,+1} materialized from a per-weight int4 * accumulator `q` (ternary_optim / SR4), each with a per-OUTPUT- * COLUMN fp32 scale restoring dynamic range: * W_eff[i,j] = scale[j] * ternaryW[i,j] * Z = W_eff^T x == scale (*) (ternaryW^T x) (folded) * Everything else (embedding, head, LayerNorm gamma/beta, the * RWKV-7 LoRA gate matrices, biases, lerps, AND the per-column * scales themselves) stays continuous and trains with AdamW. * * Param split is by registry NAME suffix (see is_ternary_name). * * Ternary-scale coupling (the one real subtlety): * The model forward/backward only ever sees W_eff (we overwrite the model's * own weight buffer with scale*ternaryW before each forward), so the analytic * grad the backward produces is g = dL/dW_eff. We then split it: * grad wrt ternary weight (drives q) : gq[i,j] = g[i,j] * scale[j] * grad wrt scale : sg[j] = sum_i g[i,j]*ternaryW[i,j] * SR4-step q with gq; AdamW-step the scale vector with sg (as a normal * continuous param). * * Build: see Makefile target `test-overfit` (standalone; NOT in the default * `test` aggregate — it is a slow optimization loop). */ #include #include #include #include #include #include "../rx_pretrain.h" #include "../../ternary_optim/ternary_optim.h" #include "../../optim/optim.h" #include "../../tensor/tensor.h" /* ------------------------------------------------------------------ */ /* Tiny config for the overfit smoke. */ /* ------------------------------------------------------------------ */ #define CFG_C 64 /* n_embd */ #define CFG_HEAD 4 #define CFG_HS 16 /* head_size (HEAD*HS == C) */ #define CFG_VOCAB 64 #define CFG_T 16 /* sequence length */ #define CFG_FFN 128 #define CFG_LORA 16 static rx_pt_config make_cfg(void){ rx_pt_config pc; memset(&pc,0,sizeof pc); pc.n_embd=CFG_C; pc.n_head=CFG_HEAD; pc.head_size=CFG_HS; pc.n_rwkv_layer=1; pc.n_attn_layer=1; pc.vocab_size=CFG_VOCAB; pc.Dw=CFG_LORA; pc.Da=CFG_LORA; pc.Dg=CFG_LORA; pc.Dv=CFG_LORA; pc.ffn_dim=CFG_FFN; pc.chunk_size=CFG_T; /* one chunk => full causal attention */ pc.moba_top_k=0; /* keep all */ return pc; } /* Fixed random token sequence (input) + shifted target. length T+1. */ static void make_fixed_seq(int *tokens, int *targets, int T, int vocab){ uint64_t s = 0xC0FFEEULL; int seq[CFG_T+1]; for (int t=0;t<=T;t++){ s = s*6364136223846793005ULL + 1442695040888963407ULL; seq[t] = (int)((s>>33) % (uint64_t)vocab); } for (int t=0;tgrad = tm_from_data(grad, shape, 1, TM_F32); t->requires_grad = true; return t; } /* ------------------------------------------------------------------ */ /* Cosine LR schedule. */ /* ------------------------------------------------------------------ */ static float cosine_lr(float lr0, float lr_min, int step, int total){ if (step >= total) return lr_min; float c = 0.5f*(1.0f + cosf((float)M_PI * (float)step/(float)total)); return lr_min + (lr0-lr_min)*c; } /* ================================================================== */ /* AdamW arm: every param continuous. */ /* ================================================================== */ static double run_adamw(const rx_pt_config *pc, const int *tokens, const int *targets, int T, int steps, int report_every, float lr0, float lr_min, int verbose){ rx_ptmodel *m = rx_model_new_random(pc, 1234); rx_pt_acts *acts = rx_pt_acts_new(m, T); int nP = rx_ptmodel_num_params(m); tm_tensor **params = calloc(nP, sizeof(tm_tensor*)); for (int i=0;i lr_min=%.4g, %d steps\n", lr0, lr_min, steps); for (int step=0; stepgrad too */ free(params); fo_free(opt); rx_pt_acts_free(acts); rx_ptmodel_free(m); return loss; } /* ================================================================== */ /* SR4 arm: ternary projections via latent int4 q + per-column scale. */ /* ================================================================== */ typedef struct { float *data; /* model weight buffer (overwritten w/ W_eff) */ float *grad; /* dL/dW_eff (from backward) */ int rows, cols, numel; ternary_optim *opt; /* owns the int4 accumulator q */ float *tern; /* pure ternary {-1,0,+1} [numel] */ float *scale; /* per-output-column fp32 scale [cols] */ float *sgrad; /* scale grad [cols] */ float *gq; /* grad wrt ternary weight = g*scale [numel] */ tm_tensor *scale_t; /* scale as a continuous AdamW param */ } tern_mat; /* freeze_noncore: when nonzero, ONLY the ternary q accumulators and their * per-column scales train — emb/head/LayerNorm/LoRA/biases are held at random * init. This isolates how much of the loss drop is genuinely driven by SR4 on * the ternary projections vs. carried by the powerful continuous emb/head. */ static double run_sr4(const rx_pt_config *pc, const int *tokens, const int *targets, int T, int steps, int report_every, float target_step, float cont_lr, float scale_lr, int freeze_noncore, int verbose){ rx_ptmodel *m = rx_model_new_random(pc, 1234); rx_pt_acts *acts = rx_pt_acts_new(m, T); int nP = rx_ptmodel_num_params(m); /* Partition params: ternary matrices vs continuous. */ tern_mat *tmat = calloc(nP, sizeof(tern_mat)); int n_tern = 0; tm_tensor **cont = calloc(2*nP, sizeof(tm_tensor*)); /* + scale vectors */ int n_cont = 0; int64_t idx_base = 0; for (int i=0;idata=d; tt->grad=g; tt->numel=n; tt->rows = rows_of(nm,n); tt->cols = n / tt->rows; ternary_optim_config oc; ternary_optim_config_default(&oc); oc.seed = 42; oc.idx_base = idx_base; idx_base += n; tt->opt = ternary_optim_create(tt->rows, tt->cols, &oc); tt->tern = malloc((size_t)n*sizeof(float)); tt->gq = malloc((size_t)n*sizeof(float)); tt->scale= malloc((size_t)tt->cols*sizeof(float)); tt->sgrad= calloc((size_t)tt->cols,sizeof(float)); for (int c=0;ccols;c++) tt->scale[c]=1.0f; /* scale trains as a continuous AdamW param (grad in sgrad) */ tt->scale_t = wrap_param(tt->scale, tt->sgrad, tt->cols); cont[n_cont++] = tt->scale_t; } else if (!freeze_noncore){ cont[n_cont++] = wrap_param(d,g,n); } } /* AdamW over all continuous params (incl. per-column scales). */ fo_adam_config cfg = fo_adam_default(cont_lr); cfg.weight_decay = 0.0f; fo_optimizer *opt = fo_adamw_new(cont, n_cont, &cfg); if (verbose) printf(" [SR4] target_step=%.4g cont_lr=%.4g scale_lr=%.4g " "(%d ternary mats, %d continuous params)\n", target_step, cont_lr, scale_lr, n_tern, n_cont); double loss = 0.0; for (int step=0; stepopt, tt->tern); for (int r=0;rrows;r++) for (int c=0;ccols;c++) tt->data[(size_t)r*tt->cols+c] = tt->scale[c]*tt->tern[(size_t)r*tt->cols+c]; } /* 2. forward + backward (grads land in model buffers = dL/dW_eff) */ rx_ptmodel_zero_grad(m); rx_pretrain_forward(m, tokens, T, acts); loss = rx_pretrain_backward(m, acts, targets, T, -1); /* 3. split ternary grad -> {q-grad, scale-grad} */ for (int t=0;tcols;c++){ double sg=0.0; for (int r=0;rrows;r++){ size_t k=(size_t)r*tt->cols+c; sg += (double)tt->grad[k]*(double)tt->tern[k]; tt->gq[k] = tt->grad[k]*tt->scale[c]; /* dL/d ternaryW */ } tt->sgrad[c]=(float)sg; /* dL/d scale[c] */ } } /* 4a. SR4-step the ternary accumulators */ for (int t=0;tgrad too */ for (int t=0;t1)? argv[1] : "both"; rx_pt_config pc = make_cfg(); int tokens[CFG_T], targets[CFG_T]; make_fixed_seq(tokens, targets, CFG_T, CFG_VOCAB); double ln_vocab = log((double)CFG_VOCAB); printf("=== RWKV-x overfit smoke ===\n"); printf("config: C=%d heads=%d hs=%d vocab=%d T=%d ffn=%d lora=%d " "(1 RWKV + 1 attn layer)\n", CFG_C,CFG_HEAD,CFG_HS,CFG_VOCAB,CFG_T,CFG_FFN,CFG_LORA); printf("fixed sequence (memorization target), random-init CE ~= ln(vocab) = %.4f\n\n", ln_vocab); int fail = 0; if (!strcmp(mode,"adamw") || !strcmp(mode,"both")){ printf("--- AdamW arm (all-continuous baseline) ---\n"); double L = run_adamw(&pc, tokens, targets, CFG_T, 1200, 100, 1e-2f, 1e-4f, 1); printf(" => AdamW final CE = %.6f (%s memorized)\n\n", L, (L<0.05)?"YES —":"NOT fully"); if (L >= 0.05) fail = 1; } if (!strcmp(mode,"sr4") || !strcmp(mode,"both")){ printf("--- SR4 arm (ternary projections) ---\n"); /* coarse step-size sweep */ float cand[] = {0.003f, 0.01f, 0.03f, 0.1f, 0.3f}; int ncand = (int)(sizeof(cand)/sizeof(cand[0])); float best_ts = cand[0]; double best_L = 1e9; printf(" coarse target_step sweep (400 steps each):\n"); for (int i=0;i SR4 final CE = %.6f (best target_step %.3g)\n\n", L, best_ts); /* Isolation probe: freeze emb/head/LN/LoRA at random init; only SR4's * ternary q + per-column scales may train. Answers "is SR4 actually * training the projections, or is the continuous emb/head doing it?" */ printf(" --- isolation probe: ONLY ternary q + scales train " "(emb/head/LN/LoRA frozen at random init) ---\n"); double Lf = run_sr4(&pc, tokens, targets, CFG_T, 1500, 100, best_ts, 5e-3f, 1e-2f, 1, 1); printf(" => SR4-only-projections final CE = %.6f " "(start ~%.3f); ternary path %s learning\n\n", Lf, ln_vocab, (Lf < ln_vocab-0.3)?"IS":"is NOT"); } if (!strcmp(mode,"both")){ if (fail){ printf("RESULT: FAIL — AdamW did not memorize.\n"); return 1; } printf("RESULT: PASS — AdamW memorized; SR4 wired and reported.\n"); } return 0; }