Skip to content

Перенос состояния через границу чанков: чья сторона неверна

Статус: РАЗРЕШЕНО ЧАСТИЧНО (2026-09-30). Дефект настоящий; виновник — наш код, а не f64-транскрипция. Точная строка — нет, механизм — да.

Измерено на main (5dd279d), ndarray, T = 32 = 2 чанка, против f64-оракула:

output rel = 8.290e-1 (output scale 6.759e-1)
chunk 0 max |diff| = 4.907e-8 <- внутричанковая алгебра ВЕРНА
chunk 1 max |diff| = 5.603e-1 <- перенос через границу НЕВЕРЕН
final state rel = 8.930e-1
oracle self-consistency (fwd-mode vs FD) = 2.160e-12

Что было отброшено, и с каким замером

Section titled “Что было отброшено, и с каким замером”
гипотеза результат
пакетная переработка (ChunkPath::Batched) DM_GDN2_OPS=loop даёт тот же ответ
член S_in · E_last сохраняется при обнулённом начальном состоянии
формула khat0ᵀ v_new0 не подходит ни при одной из шести конвенций затухания; перенос однозначно восстановлен решением 16 ур. в 8 неизв. на столбец (ранг 8, подгонка до 3.0e-8)

Что нашлось при чтении первоисточника

Section titled “Что нашлось при чтении первоисточника”

fla/ops/common/chunk_delta_h.py @ 9f38d249 — это первоисточник формулы переноса, а не транскрипция. Строки 246–284, блок if USE_G: / if USE_GK: в конце обработки чанка:

if USE_G: # per-head decay
b_g_last = exp2(g[... last_idx ...])
b_v = b_v * where(m_t, exp2(b_g_last - b_g), 0)[:, None]
b_h1 *= b_g_last # ← decay ПЕРЕНОСИМОГО СОСТОЯНИЯ
if USE_GK: # per-key-dimension decay
b_gk_last1 = gk[..., o_k1, :]
b_h1 *= exp2(b_gk_last1)[None, :] # ← ВТОРОЙ, НЕЗАВИСИМЫЙ decay

Два независимых затухания подряд. У нас:

src/kernel/chunk_cube.rs:541

S[kk][v] = S[kk][v]·g_last[kk] + Σ_r kgd[r][kk]·v_new[r][v]

где kgd[r] = k_r · exp(g_last) / exp(g_r) (:117), g_last[kk] = exp(g_left) (:120) — однофакторное, по размерности kk, без отдельного слагаемого.

Поиск по vendor/dormouse-fused/crates/burn-gdn2/src/ на gk / kg_decay / gk_last / STATE_V_FIRST: нуль совпадений. Раздельного затухания по размерностям ключа у нас нет вовсе, и флага выбора такого режима тоже нет.

Что это значит и почему это НЕ «транскрипция неверна»

Section titled “Что это значит и почему это НЕ «транскрипция неверна»”

USE_GK — это опциональная конфигурация FLA, а не формула, которую мы переписали неверно. Наш код реализует USE_G-эквивалент: одно затухание. Если v USE_GK=False, формула переноса у FLA совпадает с нашей по виду.

Значит: расхождение 5.6e-1 на chunk 1 не объясняется отсутствием USE_GK, если только эталон не считает в режиме USE_GK=True. Это проверяемо и не проверено, и это первое, что должен сделать следующий агент: посмотреть, при каком USE_GK / USE_G построена tools/gen_reference_f64.py, и пересчитать в согласованном режиме.

Второе, что требует проверки: у FLA decay обоих слагаемых берётся из exp2(...), то есть логарифм по основанию 2. Наш g_last — из натурального exp. Если соглашение по основанию не выровнено при переводе g в gk, значения будут согласованы на первом чанке (где перенос ещё не участвует в накоплении) и разойдутся на втором. Это объясняет наблюдаемую форму дефекта лучше, чем отсутствие USE_GK. burn-kda явно оговаривает этот вопрос (autodiff.rs exp2/base-2 cumsum) — не проверено здесь.

Сужено до конкретной строки (2026-09-30, чтение кода)

Section titled “Сужено до конкретной строки (2026-09-30, чтение кода)”

Записаны все присваивания в s_sh в chunk_cube.rs (строки 340–700). Их ровно три:

строка что путь
373 s_sh = state[...] — загрузка входа до цикла while t, один раз
590 s_sh = s_sh·glast[cb·kd+kk] + (acc_a+acc_b+acc_c+acc_d) основной путь, перенос
677 s_sh = state_in[...] другой путь

И вычисление v_new, которое читает s_sh:

434: vn_a += w0 * s_sh[kk * vtile + v0]; // v_new = u - W·s

Гипотеза, которую надо проверить первой — расхождение нормировки внутри чанка. kgd[r] = k_r·exp(g_last)/exp(g_r) (:117) нормирует вклад k к концу чанка (gexp_l = gexp[c-1]). Значит и входящее состояние s_sh должно быть нормировано к тому же концу, то есть домножено на exp(g_last) до того, как из него считается v_new. Но :590 применяет glast после — v_new на :434 читает ещё не затухавшее состояние.

Это объясняет наблюдаемую форму дефекта точно: на первом чанке входное состояние нулевое, v_new не зависит от нормировки, и chunk 0 сходится на 4.9e-8. На втором чанке состояние ненулевое — и нормировка проявляется целиком. Не «где-то расходится», а ровно «со второго чанка».

Проверяемое следствие: если гипотеза верна, подстановка S ← S·glast в начало обработки чанка (то есть перенос :590 в ДО вычисления v_new) должна снять красное. Если красное останется — гипотеза опровергнута, и следующий кандидат: glast[cb·kd+kk] индексируется cb = bh·nt + t, и при nt > 1 либо при переиспользовании буфера glast между вызовами индекс указывает не на тот блок.

Замечание к states_out: :722 пишет финальное состояние без общего decay — то есть наружу отдаётся сырое. Это может быть верно (сырое состояние и подаётся на вход следующего вызова как state_initial), а может и означать двойное затухание на стыке двух вызовов. Отдельно проверить.

  1. Прочитать gen_reference_f64.py: при каких USE_G/USE_GK/основании построен эталон. Одна строка в коде, ответ всё проясняет.
  2. Пересчитать эталон в согласованном режиме и снять красное.
  3. Если всё ещё красное — чинить наш S-перенос по форме S ← (S·g_head)·g_key + …, предварительно проверив, что S в нашей компоновке оси-«ключ» и есть та, по которой FLA умножает на gk_last.

Чего этот документ НЕ утверждает

Section titled “Чего этот документ НЕ утверждает”
  • Не утверждается, что виноват chunk_cube.rs:541. Замечено расхождение формул, не совпадение ошибки.
  • Не утверждается, что эталон неверен. Он опровергнут на chunk 0 (4.9e-8), а его самосогласованность 2.16e-12 — против его собственной формулы он прав; вопрос только в том, в каком режиме он построен.
  • Не утверждается, что USE_GK нужен нам. Это конфигурация, а не дефект.

Обновление 2026-09-30: гипотеза A ОПРОВЕРГНУТА измерением

Section titled “Обновление 2026-09-30: гипотеза A ОПРОВЕРГНУТА измерением”

Проверено само, не делегировано. Числа на main воспроизведены точно (chunk 0 4.907e-8, chunk 1 5.603e-1, final state 8.930e-1).

Что было сделано. Снят множитель glast[cb*kd+kk] из переноса (:758 в исполняемом пути, :590 — в другом), чтобы状态 не затухала дважды. Результат: числа не изменились ни на одну цифру — chunk 1 так и 5.603e-1, final state так и 8.930e-1.

Что это доказывает. Правка не попала в исполняемый путь (или её эффект точно нулевой), то есть проблема не в лишнем затухании на переносе. Иначе chunk 1 сдвинулся бы. Правка откачена, дерево чистое.

Это отвергает саму диагностическую гипотезу документа в её исполнимой форме. То, что остаётся подтверждённым, — это рассогласование нормировки, которое я вывел из чтения кода: w_buf чанка t+1 построен к концу чанка t+1 (kgd[r] = k_r·exp(g_last)/exp(g_r), :117), а s_sh на входе чанка t+1 — состояние на конец чанка t. Это чтение, а не измерение, и вчерашний план «фальсифицировать одним движением» именно это и не смог подтвердить.

Почему не дальше, честно. Чтобы это довести, нужен вывод схемы нормировки целиком: является ли u/w нормированными к началу или к концу чанка, и в какой форме S должен входить в v_new = u − W·S. Из chunk 0, сходящегося на 4.9e-8, следует, что внутри одного чанка всё согласовано; расходится только стык. Но выбрать между «нормировать u,w к началу» и «умножать s_sh на отношение затуханий между чанками» по чтению нельзя — второй вариант требует деления на затухание, которое на длинных последовательностях вырождается, и это отдельное численное решение, а не строчная правка.

Состояние: гейт остаётся красным намеренно. Один приборный тест на изменение не дал ничего — это результат, и он записан.

Обновление 2026-09-30 (позднее): причина НЕ в затухании переноса

Section titled “Обновление 2026-09-30 (позднее): причина НЕ в затухании переноса”

Три измерения, все на main/forward.rs, все с числами.

1. Тест идёт НЕ по кубическому ядру. module.rs:52 берёт fused-путь только при is_cuda::<B>(); на ndarray это ложь, и выполняется тензорный chunk_wy_forward_impl → chunk_wy_forward_batched/loop из forward.rs. Все правки в chunk_cube.rs обязаны были дать ровно ноль. Проверено: снятие glast в ОБОИХ путях ядра (:590 и :758) сдвинуло числа на ноль.

Это опровергает диагностику документа: весь разбор был про ядро, которое здесь не исполняется. Агент, писавший документ, читал chunk_cube.rs, не проверив, какой путь берёт ndarray.

2. Затухание переноса меняет ответ на 0.1 %. Заменено на carry[ci] = exp(g_cumsum[ci,last] − g_cumsum[ci−1,last]) (затухание МЕЖДУ чанками, а не от начала последовательности — исходный g_last был именно последним, и на чанке 0 они совпадают):

chunk 1 final state
исходно (g_last) 5.603e-01 8.930e-01
carry_decay 5.609e-01 9.398e-01

Ошибка изменилась на 0.1 %. Значит затухание переноса — не причина. Правка откачена; исходная формула может быть верной при нормировке состояния к началу чанка.

3. Что остаётся доказанным. chunk 0 верен на 4.907e-08 при любом затухании, chunk 1 расходится на O(1). Единственное, что меняется между ними, — состояние перестаёт быть нулевым. Ошибка сосредоточена в внутричанковой нормировке, а не на стыке:

let v_new = cut(u) - cut(w_wy).matmul(s_before); // в какой нормировке?
state = s_before * g_last + k_dec.matmul(v_new); // k_dec = k5·exp(g_cum[last]−g_cum[t])

k_dec нормирован к концу чанка. Если v_new нормирован к началу (u = m_inv @ (w·g_exp) — произведение на полное накопленное затухание, а m_inv решает систему внутричанково), то k_dec и v_new стоят в разных нормировках, и их произведение бессмысленно. На чанке 0 это не видно: всё нулевое.

Это следующий шаг, и он уже сформулирован проверяемо: определить нормировку v_new и привести к ней k_dec (или наоборот). Выбор между «нормировать u,w к концу» и «нормировать k_dec к началу» — численное решение, а не чтение.

Состояние: гейт красный намеренно, диагноз сужен с «одна строка ядра» до «внутричанковая нормировка v_new против k_dec в forward.rs».


Обновление 2026-09-30 (третья сессия): f32 НЕ причина, и пропущенный гейт найден

Section titled “Обновление 2026-09-30 (третья сессия): f32 НЕ причина, и пропущенный гейт найден”

Пять агентов до этого искали строку в chunk_cube.rs. Ниже — четыре измерения, которые ломают неверную ветку диагностики, и одна структурная находка, которая объясняет, почему дефект вообще дожил.

1. Эталон не устарел (numpy, без cargo)

Section titled “1. Эталон не устарел (numpy, без cargo)”

tools/gen_bwd_f64.py::forward — построчная транскрипция forward.rs::chunk_wy_forward_batched, включая рекурсию состояния. Оба закоммиченных фикстура воспроизводятся текущим генератором до нуля:

фикстура out max|d| out_state max|d|
ref_bwd_f64.bin (T=16, 1 чанк) 0.000e+00 0.000e+00
ref_bwd_f64_carry.bin (T=32, 2 чанка) 0.000e+00 0.000e+00

Значит «эталон построен в другом режиме USE_G/USE_GK» — мертвая гипотеза: эталон построен ровно тем кодом, который напечатан рядом с ним.

2. f32 НЕ объясняет расхождение — это ключевое измерение

Section titled “2. f32 НЕ объясняет расхождение — это ключевое измерение”

Та же алгебра, что и в Rust, воспроизведена в numpy для трёх комбинаций dtype × алгоритм инверсии. Против закоммиченного out/out_state:

dtype инверсия chunk 0 chunk 1 state rel
f64 np.linalg.inv 8.3e-17 1.1e-16 0.0
f64 Neumann (как в Rust) 8.3e-17 2.2e-16 4.3e-16
f32 Neumann (как в Rust) 7.2e-08 9.8e-08 3.2e-07

f32-симуляция ТОЙ ЖЕ алгебмы сходится на обоих чанках с шумом f32. Она на 6 порядков ниже измеренных Rust 5.603e-01. Значит виноват не численный формат и не инверсия, а что-то в коде Rust, чего нет в этой транскрипции — и это не гипотеза, а исключение: арифметика совпадает, сходится, значит расходится не она.

Побочно: инверсия Neumann здесь не вырождена. max|(-L)^j| для обоих чанков падает до 1e-14 к j=14, максимальная сумма модулей строки |L| = 3.54 (чанк 0) и 4.27 (чанк 1), пик ряда — единица на I. nearest wrong formula для инверсии здесь не существует.

3. Ответ на решающий вопрос брифа: да, один чанк сходится — но не по тому, что проверяли

Section titled “3. Ответ на решающий вопрос брифа: да, один чанк сходится — но не по тому, что проверяли”

Одночанковая проверка the_forward_agrees_with_the_f64_transcription зелёная (chunk 0 = 4.907e-08). Но она выбрасывает состояние:

let (out, _new_state) = chunk_wy_forward( // autodiff_bwd_f64.rs:251

_new_state. То есть за всю жизнь гейта проверялся ВЫХОД чанка, и никогда — состояние, которое чанк оставляет за собой. Это ровно та величина, которую потребляет следующий чанк. Отсюда форма дефекта, и она не требует ни одной из пяти отвергнутых гипотез:

  • состояние считается после того, как out_c уже положен в outputs (строка 297 против 292-294) ⇒ на одном чанке ошибка состояния невидима по построению;
  • на двух чанках s_before на чанке 1 ненулевой, и ошибка проявляется целиком.

Вот почему пять агентов читали chunk_cube.rs, а не находили дефект, и почему замена затухания переноса сдвинула ответ на 0.1 %: меняли ЗНАЧЕНИЕ множителя, а сломано ПРОИЗВОДСТВО состояния.

Пропущенный гейт добавлен: the_state_we_hand_back_is_the_state_the_oracle_computed (1 и 2 чанка, против уже закоммиченных блоков out_state).

4. Ещё два отвергнутых кандидата (чтение кода burn, не измерение)

Section titled “4. Ещё два отвергнутых кандидата (чтение кода burn, не измерение)”
  • cut(g_last) режет ось размера 1 диапазоном 0..chunk_size. Не дефект: burn-std-0.22.0-pre.3/src/tensor/slice.rs:34-51 клампит (slice.to_range(*dim_size)), получается [B,H,1,K], как и задумано.
  • Единственный matmul с транспонированным левым операндом — cut(k_dec).swap_dims(2,3).matmul(v_new), ровно то самое сломаное слагаемое. Correlation сильная, но burn-ndarray/src/tensor.rs:543-565 для non-standard-layout идёт по ветке to_shape(dim).unwrap().as_standard_layout(), то есть копирует по логическим индексам, и значения верны. Не причина — но именно поэтому гипотеза выглядела правдоподобно.

Состояние: названное красное unexplained остаётся сильнее любой правдоподобной строки. Что установлено: инверсия в порядке, f32 в порядке, нормировки v_new/k_dec в транскрипции согласованы (иначе f32-симуляция разошлась бы), и остаётся ровно одно — состояние, которое производит forward.rs:296-297 (и его близнец :636 в петлевом рукаве).

ОБНОВЛЕНИЕ 2026-09-30 (позднее): ВИНОВЕН ЭТАЛОН, gen_bwd_f64.py:258-259

Section titled “ОБНОВЛЕНИЕ 2026-09-30 (позднее): ВИНОВЕН ЭТАЛОН, gen_bwd_f64.py:258-259”

Одна строка, двойной экспоненциал. tools/gen_bwd_f64.py::forward:

258: g_last = E[:, :, :, c - 1:c, :] # E = exp(G) <- УЖЕ экспоненцировано
259: decay_last = ... else np.exp(g_last - G) # exp(exp(G_last) - G) <- ДВОЙНОЙ exp

Rust (forward.rs:269-277) делает правильно:

let decay_last = (g_cumsum.slice(last) - g_cumsum).exp(); // exp(G_last - G)

g_last в оракуле — это E (экспонента суммы), и он используется в exp второй раз. Правильная величина — G[..., c-1:c, :], лог- domain. Это ровно та ошибка, о которой спрашивал бриф («определи нормировку v_new конструкцией») — но нормировки в коде нет: расходится не Rust, а эталон, и обе стороны используют k_dec = k·exp(G_last−G_t), то есть нормировку к КОНЦУ чанка. v_new и k_dec стоят в ОДНОЙ нормировке, и это подтверждено измерением, а не чтением.

Доказательство: пошаговое сравнение f32 Rust с двумя кандидатами

Section titled “Доказательство: пошаговое сравнение f32 Rust с двумя кандидатами”

Один и тот же вход (ref_bwd_f64.bin, T=16, один чанк), один и тот же f32 numpy-транскрипт, меняется только строка decay_last:

decay_last vs закоммиченный f64 out_state
exp(E_last − G) (как в оракуле, двойной exp) 3.09e-07 ✅ шум f32
exp(G_last − G) (как в Rust, одиночный exp) 1.24e+00 ❌

|ref|max = 1.781. Оракул сходится с неправильной формулой, а Rust — с правильной, и расхождение 1.24 это не шум, а весь масштаб.

Пошаговая сверка всех членов (f32 Rust из forward.rs против f32 numpy транскрипта, против f64 оракула):

член Rust vs numpy numpy vs f64-оракул
t1 = s_before·g_last 1.49e-08 1.70e+00
t2 = k_decᵀ·v_new 1.04e-07 1.34e+00
sum = state 1.04e-07 1.24e+00
k_decᵀ 5.97e-08 —
v_new 5.97e-08 —

Rust воспроизводит транскрипт своего собственного кода до 1.0e-07 и разошелся с оракулом на всех трёх членах сразу — ровно так ведёт себя неверный эталон, а не неверная реализация.

Почему пять агентов не нашли

Section titled “Почему пять агентов не нашли”

Потому что искали в chunk_cube.rs. Соблазн «наш код неверен» держался на том, что эталон — это транскрипция forward.rs (так и написано в шапке autodiff_bwd_f64.rs), и читающий это решит, что оракул по определению повторяет код, а значит идти надо в код. Он повторяет код почти построчно — и в одной строке повторяет не ту формулу, потому что в Python g_last уже экспоненцирован, и разница невидима глазом.

Урок, который стоит записать: транскрипция, построенная по соседнему коду, наследует его ошибки — и в этом конкретном случае ошибка была не в томе копируемом, а в ОДНОМ различающем символе. Гейт, который это поймал бы, — сверка оракула с двумя формулами, а не с одной.

Состояние: причина найдена и измерена; chunk_cube.rs и нормамировки непричастны. Правка в оракуле + перегенерация фикстур.

Итог: фикс, границы применимости и оба направления гейта

Section titled “Итог: фикс, границы применимости и оба направления гейта”

Правка — две строки в двух генераторах, ни одной в src/. forward.rs побайтово совпадает с main после всей работы; diff чист.

  • tools/gen_bwd_f64.py:263-269 — decay_last в лог- домене.
  • tools/fwd_mode.py:144-149 — то же (иначе два метода оракула разошлись бы между собой, а не с нами).
  • Перегенерированы tests/ref_bwd_f64.bin и tests/ref_bwd_f64_carry.bin. ref_bwd_f64_faults.bin не изменился ни на байт.
величина было стало
chunk 1 max abs diff 5.603e-01 3.327e-08
final state rel 8.930e-01 2.621e-07
state на ОДНОМ чанке (не проверялось) 1.193e-07
out rel, 2 чанка 8.290e-01 2.6e-07
dstate градиент — 2.039e-07

Бар FWD_BAR = 1e-3, шум f32 ~5e-7. Запас 4 порядка.

Оба направления гейта (требование брифа выполнено)

Section titled “Оба направления гейта (требование брифа выполнено)”
  1. Вернул двойной exp в gen_bwd_f64.py, перегенерировал, прогнал гейт:
    two_chunks... FAILED chunk 1 5.603e-1, state 8.930e-1
    the_state_we_hand_back... FAILED one chunk 6.974e-1
    Числа в точности исходные из брифа. Новый гейт ловит старый дефект.
  2. Вернул фикс, перегенерировал: 5 passed; 0 failed.

Толерантность не тронута, #[ignore] нет, тестов не удалено.

Новый гейт, которого не было

Section titled “Новый гейт, которого не было”

the_state_we_hand_back_is_the_state_the_oracle_computed — 1 и 2 чанка. Причина, по которой его не было: the_forward_agrees_with_the_f64_transcription берёт let (out, _new_state) = ... (autodiff_bwd_f64.rs:251) и выбрасывает состояние. Выход чанка проверялся всю жизнь; состояние, которое чанк оставляет, — нет. Ошибка состояния невидима на одном чанке по построению, потому что outputs.push(out_c) (строка 294) стоит до обновления состояния (строка 296-297).

Это и есть тот гейт, который поймал бы дефект в день написания одночанковой фикстуры, и он не требует ни градиента, ни второй стороны, ни новых данных: блок out_state уже был в ref_bwd_f64.bin.

CUDA-ядро дефекта НЕ разделяет (пункт 4 брифа)

Section titled “CUDA-ядро дефекта НЕ разделяет (пункт 4 брифа)”

chunk_cube.rs:117 — kgd[r] = k_r * gexp_l / gexp_r, то есть k·exp(G_last)/exp(G_t) = k·exp(G_last−G_t): одна экспоненциация, и glast (:119) — это gexp последней строки, тоже без двойного exp. Проверено численно на случайном g:

пара max abs разница
chunk_cube.rs kgd vs forward.rs decay_last 2.220e-16
двойной exp оракула vs forward.rs 4.681e+00
двойной exp оракула vs chunk_cube.rs 4.681e+00

Три независимые реализации затухания (тензорный рукав, CUDA-ядро, FLA) согласны; отличается только эталон. Поэтому chunk_cube.rs чинить не нужно, и тест, сравнивающий два рукава, здесь не понадобился: расхождение было не между рукавами.

Оговорка, которая остаётся: на ndarray ядро не исполняется (module.rs:52 требует is_cuda), поэтому его численно проверяет только CUDA-гейт, а этот прогон его не покрывает. Это долг, а не результат.

Про инертные фолты (открыто, не мой дефект)

Section titled “Про инертные фолты (открыто, не мой дефект)”

decay-sign, no-state-decay, decay-on-v-new двигают loss на 0.000e+00 при ОДНОМ чанке. Проверено на старом генераторе тоже — они были инертны и до правки, то есть это структурное свойство одночанкового случая (loss зависит только от out, а out на одном чанке не читает decay_last), а не следствие фикса. Генератор это различает и пишет в фикстуру loss фолта именно для такой проверки. Не трогал.

Чего этот документ НЕ утверждает

Section titled “Чего этот документ НЕ утверждает”
  • Не утверждается, что chunk_cube.rs был неверен. Он неверен не был, и показано это численно.
  • Не утверждается, что нормировки v_new/k_dec различались. Они совпадают: обе стороны используют k·exp(G_last−G_t), то есть нормировку к КОНЦУ чанка. Вопрос брифа «определи нормировку v_new конструкцией» отвечает тем, что её не пришлось менять — расходилась не нормировка, а базис, в котором она вычислялась (лог против экспоненты).
  • Слово «verified» здесь не используется без внешнего источника: конвенция затухания сверена с fla/ops/common/chunk_delta_h.py:236-240, commit 9f38d249, файл на диске в /home/sehaxe/.cache/uv/archive-v0/DISaWrLCUXUgOZHC/. Всё остальное — собственная транскрипция, и так и помечено.