From 1d3859c6e8ccd2060d10f27d34834a1f4b723486 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 18 Jul 2026 05:37:30 -0500 Subject: [PATCH] Make the observer LR gate two-channel (entropy + drift) observer_matrix_scale throttled a weight matrix to 0.5x whenever its entropy channel was quiet. But that entropy is computed over a single SGD step's worth of weight change, which sits far below thresholds calibrated for scalar convergence loops -- so the "quiet" test was effectively always true. Instrumented over 2,597 steps x 42 matrices, the gate classified "stable" for 100.0% of matrix-steps and emitted the 1.0x full-rate verdict zero times. An adaptive gate that never adapts is a constant 0.5x drag, and it was applying that drag while the loss was plainly still falling (6.09 -> 0.54). The observer buffer here carries only entropy-derived state (entropy, dH, last_entropy, obs_age, prev_dH) -- there is no value channel at all, so slow-but-steady motion is invisible to it. This adds that channel as relative drift ||grad|| / ||w|| and releases the "stable" verdict when the weights are still moving, leaving the oscillating (0.3x) and diverging (0.1x) verdicts untouched since those are genuine instability signals the entropy channel reads correctly. The two channels disagreed on 100% of matrix-steps, and ground truth says drift was right every time: the gate now releases 99.6% and throttles only real oscillation (0.4%). Three-way A/B, fixed checkpoint, 400 windows, identical seeds: step entropy-only gate off two-channel 100 1.554582 1.205151 1.237571 200 1.405466 1.012344 0.975602 300 0.728710 0.542757 0.553253 Two-channel recovers ~all of the gate-off gain (~25% better loss than the status quo) while retaining the ability to throttle a matrix that genuinely settles -- which "off" permanently gives up. Note the A/B cannot separate off from two-channel here: during active learning "release everything" and "off" are the same policy, so the distinction is structural and would only show in a long run where something actually settles. All three arms stay selectable for future A/B: EIGS_OBS_ENTROPY_ONLY=1 (old single-channel), EIGS_OBS_SCALE_OFF=1 (no gating), EIGS_OBS_DRIFT_EPS (release threshold, default 1e-3). Suite: 3164/3164 on the http+model build, [47c] gradcheck still pins the batched path to the per-position oracle. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 23 ++++++++++++ src/model_train.c | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) 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;