Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions src/model_train.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;

Expand Down
Loading