diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5b1b5..1ebb482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,29 @@ All notable changes to EigenScript are documented here. button geometry is now defined once (`_dialog_btn_rect`) instead of being recomputed by render and click separately. +### Changed +- **The observer learning-rate gate is now two-channel (entropy *and* drift).** + `observer_matrix_scale` throttled a weight matrix to 0.5x whenever its + entropy was quiet — but entropy is computed over one SGD step's worth of + weight change, which is always far below thresholds calibrated for scalar + convergence loops. Instrumented over 2,597 steps x 42 matrices it classified + "stable" for **100.0%** of matrix-steps and emitted 1.0x **zero** times: an + adaptive gate that was really a constant 0.5x drag, while the loss was + demonstrably still falling (6.09 -> 0.54). The fix adds the motion channel + the observer buffer never carried — relative drift `||grad|| / ||w||` — and + releases the "stable" verdict when the weights are still moving. The two + channels disagreed on 100% of matrix-steps and drift was right every time, + so the gate now releases 99.6% and throttles only genuine oscillation (0.4%). + Measured on a fixed checkpoint, 400 windows, identical seeds: entropy-only + 1.405 / 0.729 at steps 200/300 vs two-channel 0.976 / 0.553 — ~25% better + loss, matching a fully-disabled gate while *retaining* the ability to throttle + a matrix that genuinely settles. `EIGS_OBS_ENTROPY_ONLY=1` restores the old + single-channel gate and `EIGS_OBS_SCALE_OFF=1` disables gating entirely, so + all three arms stay available for A/B; `EIGS_OBS_DRIFT_EPS` tunes the + release threshold (default 1e-3). This is the observer's own failure mode — + a meter blind to slow motion certifying a moving system as converged — found + by pointing the instrument at itself. + ### Fixed - **`file_exists` / `ls` / `mkdir` / `getcwd` / `exe_path` are now trace-recorded (#585).** These fs builtins predated the tape and ran live diff --git a/src/model_train.c b/src/model_train.c index 2eecd90..bcab0e9 100644 --- a/src/model_train.c +++ b/src/model_train.c @@ -449,6 +449,17 @@ static void native_forward_with_cache(int *token_ids, int seq_len, TransformerMo #define OBS_H_LOW g_obs_h_low static float observer_matrix_scale(double *obs, int start, int count) { + /* A/B control: EIGS_OBS_SCALE_OFF=1 disables observer LR scaling entirely + * (all matrices at full rate). Measured 2026-07-18: the classifier returns + * "stable"->0.5 for 100.0% of matrix-steps and never once reaches + * improving->1.0, because a single SGD step moves a weight matrix's entropy + * far below OBS_DH_SMALL by construction (thresholds are calibrated for + * scalar convergence loops, not weight matrices). So the "adaptive" gate is + * really a hidden constant 0.5x on the whole model's LR, and it is + * self-reinforcing: halved LR -> smaller deltas -> still "stable". */ + static int scale_off = -1; + if (scale_off < 0) scale_off = getenv("EIGS_OBS_SCALE_OFF") ? 1 : 0; + if (scale_off) return 1.0f; if (!obs || count <= 0) return 1.0f; /* no observer data → full rate */ double sum_abs_dH = 0.0, sum_entropy = 0.0, sum_age = 0.0; @@ -711,6 +722,44 @@ static float l2_norm_f(const float *a, int64_t n) { return (float)sqrt(s); } +/* ---- Two-channel observer gate: the VALUE-motion channel ---- + * The model's observer buffer carries five fields per element and ALL of them + * are entropy-derived (entropy, dH, last_entropy, obs_age, prev_dH) — there is + * no value-motion signal at all, which is why the entropy classifier certifies + * "stable" for 100% of matrix-steps: one SGD step moves a matrix's entropy far + * below OBS_DH_SMALL by construction. Supply the missing channel here as the + * gradient magnitude expressed as a fraction of the weight magnitude: + * scale-free, stateless, and needs no per-matrix calibration. */ +static float matrix_rel_drift(const float *grad, const float *w, int64_t n) { + if (!grad || !w || n <= 0) return 0.0f; + float gn = l2_norm_f(grad, n); + float wn = l2_norm_f(w, n); + return gn / (wn + 1e-8f); +} + +/* Two-channel gating is the DEFAULT. Measured 2026-07-18 over 2,597 steps x 42 + * matrices: the entropy channel classified "stable" for 100.0% of matrix-steps + * while the drift channel found every matrix still moving — the two channels + * disagreed 100% of the time, and ground truth (loss 6.09 -> 0.54) says drift + * was right. The entropy-only gate was therefore a disguised constant 0.5x drag + * costing ~25% of learning progress. EIGS_OBS_ENTROPY_ONLY=1 restores the old + * single-channel behavior for A/B; EIGS_OBS_SCALE_OFF=1 disables gating entirely. */ +static int obs_2ch_enabled(void) { + static int v = -1; + if (v < 0) v = getenv("EIGS_OBS_ENTROPY_ONLY") ? 0 : 1; + return v; +} + +static float obs_drift_eps(void) { + static float e = -1.0f; + if (e < 0.0f) { + const char *s = getenv("EIGS_OBS_DRIFT_EPS"); + e = s ? (float)atof(s) : 1e-3f; + if (e <= 0.0f) e = 1e-3f; + } + return e; +} + static void train_dump_open_if_needed(TransformerModel *model) { if (g_train_dump_init || g_train_dump_inhibit) return; const char *path = getenv("EIGS_TRAIN_DUMP"); @@ -1256,6 +1305,48 @@ static int native_train_step_impl(int *input_ids, int input_len, int *output_ids /* Consult observer state for per-matrix learning rate scaling */ float *obs_scales = compute_observer_scales(&g_model); + + /* EIGS_OBS_2CH: two-quiet rule. A matrix counts as "stable" only if BOTH + * its entropy is flat AND it has actually stopped moving. The entropy + * channel alone says stable 100% of the time (see matrix_rel_drift), so the + * gate degenerates to a constant 0.5x drag. Here we release that 0.5 back to + * full rate whenever the value channel shows the matrix is still moving. + * Oscillating (0.3) and diverging (0.1) are genuine instability signals and + * are deliberately left alone — this only re-examines the "settled" verdict. */ + if (obs_scales && obs_2ch_enabled()) { + const float eps = obs_drift_eps(); + int di = 0; + if (obs_scales[di] == 0.5f && + matrix_rel_drift(grad_token_emb, g_model.token_embeddings, + (int64_t)vocab_size * d_model) > eps) + obs_scales[di] = 1.0f; + di++; + if (obs_scales[di] == 0.5f && + matrix_rel_drift(grad_output_proj, g_model.output_proj, + (int64_t)d_model * vocab_size) > eps) + obs_scales[di] = 1.0f; + di++; + for (int l = 0; l < n_layers; l++) { + TransformerLayer *dl = &g_model.layers[l]; + const float *dg[10] = { lg_wq[l], lg_wk[l], lg_wv[l], lg_wo[l], + lg_ff1[l], lg_ff2[l], + lg_ln1g[l], lg_ln1b[l], lg_ln2g[l], lg_ln2b[l] }; + const float *dw[10] = { dl->w_q, dl->w_k, dl->w_v, dl->w_o, + dl->w_ff1, dl->w_ff2, + dl->ln1_gamma, dl->ln1_beta, + dl->ln2_gamma, dl->ln2_beta }; + int64_t dn[10] = { (int64_t)d_model * d_model, (int64_t)d_model * d_model, + (int64_t)d_model * d_model, (int64_t)d_model * d_model, + (int64_t)d_model * d_ff, (int64_t)d_ff * d_model, + d_model, d_model, d_model, d_model }; + for (int m = 0; m < 10; m++, di++) { + if (obs_scales[di] == 0.5f && + matrix_rel_drift(dg[m], dw[m], dn[m]) > eps) + obs_scales[di] = 1.0f; + } + } + } + float emb_scale = obs_scales ? obs_scales[0] : 1.0f; float proj_scale = obs_scales ? obs_scales[1] : 1.0f;