yomitan_anki_conf

Пара словарей EN+RU для расширения Flib-club — ТЗ для anki_conf

Дата: 2026-09-08. Отправитель: репозиторий yomitan_phrases/src — форк Yomitan, расширение Flib-club. Получатель: anki_conf_deploy/turmalin_build — владелец dict_base.db и Go-инструментов сборки Yomitan-словарей.

Что этот документ делает: описывает артефакт, который обязан произвести anki_conf, чтобы расширение показывало русские переводы рядом с английскими статьями. Читателю не нужно читать код расширения: всё, что от него требуется, названо здесь, и у каждого требования указан конкретный символ-читатель в форке — чтобы требование можно было опровергнуть, а не принять на веру.

Чего этот документ не делает: не пересказывает ТЗ расширения. Внутренняя постановка — docs/superpowers/specs/2026-09-07-russian-translation-and-chunk-search-design.md (далее ТЗ-расширения); сюда вынесено только то, что пересекает границу репозиториев.

Пометки достоверности (те же, что в ТЗ-расширения): ИЗ КОДА — прочитано в файле форка на коммите 6d5074fb, указан символ; ЗАМЕРЕНО — проверено командой; РЕШЕНИЕ — продуктовый выбор; ОТКРЫТО — ответа нет; ⚠️ — грабля.

⚠️ Якоря — по символам, а не по номерам строк. Номера строк в этом проекте протухают; где ниже назван номер, он снят на 6d5074fb. Ищите по имени функции.

Редакция 2 (после состязательной проверки). Что изменилось по существу:

  1. Добавлен §9 — «как пара доезжает до пользователя». Артефакт, собранный строго по редакции 1, был бы правильным, но не доехал бы до паринга на типичной машине: заголовок wty-en-en уже занят апстримным словарём, а импорт словаря с занятым заголовком пропускается. Симптом при этом неотличим от разъехавшегося отпечатка.
  2. §5.5 — новая ловушка: поля isUpdatable / indexUrl / downloadUrl, унаследованные из апстримного index.json, дают пользователю кнопку, заменяющую нашу пару апстримом.
  3. §8 — таблица диагностики исправлена: предупреждение «fingerprints differ» срабатывает в трёх случаях, а не в одном, зажигает бейдж на иконке и пишется на каждый поиск. Молчащая зона при этом уже, чем говорила редакция 1.
  4. §11 — базовое число 1 962 075 из A5 не воспроизводится; правило «мерить, а не переписывать» применено к самому этому документу.
  5. §13 — добавлен критерий уникальности штампа на русской стороне; список разделён на «по артефакту» и «по выкладке».

1. Требование одной фразой

Нужен не «русский zip», а пересобранная пара wty-en-en.zip + flibooster-en-ru.zip, выпущенная одним прогоном, где каждая строка обоих банков несёт ненулевой sequence, русская строка несёт sequence своей английской строки, а index.json.revision у обоих zip побайтно одинаков.

Формулировка владельца была: плагину нужен словарь, «содержащий и английские артикли и русские артикли». Намерение верное — пользователь обязан получить и английские, и русские статьи. Уточняется только форма: это не один словарь с двумя языками внутри, а пара словарей, поставляемая вместе и связанная штампом. Почему именно так — §2, и это утверждение выводится из кода, а не из вкуса.

⚠️ Сегодняшний апстримный wty-en-en для этой задачи непригоден. У него sequence всех строк равен нулю, а нулевые штампы расширение отбрасывает (§5.1). Поэтому английский словарь тоже пересобирается, а не берётся как есть. Это главная новость этого документа: объём работы — не «сделать русский zip», а «выпустить пару».


2. Почему «один словарь с обоими языками» невозможен — ИЗ КОДА

Расширение отделяет перевод от оригинала по имени словаря, и делает это в трёх независимых местах. Если бы обе стороны лежали в одном словаре, все три места сломались бы одновременно.

  1. ИЗ КОДА pairDefinitions (ext/js/display/translation-pairing.js) раскладывает определения одной статьи на «хозяев» и «переводы» единственным предикатом:

    if (definition.dictionary === translationDictionary) {
      /* это перевод */
    }
    

    Одно имя словаря на оба языка — и каждая английская статья была бы объявлена переводом.

  2. ИЗ КОДА DisplayTranslation._hideTranslationSourceDefinitions (ext/js/display/display-translation.js) убирает русские определения из обычного списка .definition-list, отбирая узлы по атрибуту data-dictionary; атрибут ставит DisplayGenerator._createTermDefinition (ext/js/display/display-generator.js, node.dataset.dictionary = dictionary). Тот же ключ — имя словаря.

  3. ИЗ КОДА DisplayTranslation._shouldShowButton включает кнопку Ru, только если среди установленных словарей есть словарь с именем translationDictionary (info.some((d) => d.title === options.flibTranslation.translationDictionary)).

⇒ Имя словаря — единственный признак «это перевод». Оно приходит из index.json.title и живёт до самого DOM. Один словарь на два языка обнулил бы колонку, блюр (§4) и пересчёт счётчиков определений сразу.

РЕШЕНИЕ Два zip, выпускаемые вместе, связанные штампом sequence и общим revision. Пользователь при этом получает ровно то, что просил владелец: и английские статьи, и русские — в одной карточке, потому что расширение сливает статьи двух словарей по общему заголовку (§5.2), а не потому, что они лежат в одном файле.


3. Как мы сюда пришли: что есть и чего нет

Расширение готово и слито — колонка, блюр, уборка русских строк из обычного списка, учёт раскрытий. Не хватает только данных, и они целиком на стороне anki_conf.

что состояние на 6d5074fb
код паринга и колонки в расширении есть: ext/js/display/translation-pairing.js, ext/js/display/display-translation.js
имена словарей в настройках есть: ext/data/schemas/options-schema.json (flibTranslation) + миграция OptionsUtil._updateVersion78 (options-util.js)
сам словарь flibooster-en-ru ⚠️ не существует нигде — только как строка в схеме, миграции и тестах (test/options-util.test.js)
словарь wty-en-en со штампами ⚠️ не существует; апстримный zip (ext/data/recommended-dictionaries.json → HuggingFace) имеет sequence = 0 у всех строк
манифест провижининга ext/data/provisioning/dictionaries.json ЗАМЕРЕНО содержит []; каталога ext/data/provisioning/dictionaries/ в дереве нет
инструмент экспорта пары ⚠️ не написан — ТЗ-расширения §12 числит его за turmalin_build, исполнитель не назначен
замена уже установленной пары новым отпечатком ⚠️ не написана (§9.1); работа форка, но anki_conf она бьёт по приёмке

⇒ anki_conf — блокирующая зависимость. Пока пары нет, расширение ведёт себя корректно (показывает переводы сиротами), но пользователь не видит ни одной пары, потому что видеть нечего.


4. Что расширение сделает с артефактом

Короткая картинка результата — чтобы сборщик понимал, ради чего штампы.

⇒ Именно поиск хозяина (findHost) и есть та операция, ради которой нужен штамп. Без штампа интерфейс работает, но состоит из одних сирот.


5. Требования к артефакту

5.1 Ш-1: штамп sequence — ненулевой в обоих банках

ИЗ КОДА findHost (translation-pairing.js):

const stamps = new Set(translation.sequences.filter((s) => s > 0));
if (stamps.size === 0) { return null; }
…
if (host.sequences.some((s) => stamps.has(s))) { return row; }

Требования, вытекающие буквально:

⚠️ ГЛАВНАЯ ГРАБЛЯ: включатель — это поле СТРОКИ банка, а не флаг в index.json. Это ровно тот случай, когда «сначала порт включателя, потом украшения»: сборщик, который выставил "sequenced": true и не тронул поле строки, не сделал ничего.

РЕШЕНИЕ "sequenced": true — только у английского zip. ⚠️ У флага есть побочный эффект: ИЗ КОДА DictionaryImportController._addDictionarySettings при пустом general.mainDictionary назначает главным первый импортированный sequenced-словарь. Если первым встанет русский, главным станет он. В режиме group это ничего не ломает, но в выпадающем списке главного словаря появятся оба, а в свежем профиле главным окажется словарь переводов. Ставить флаг обоим — допустимо; ставить его только английскому — безопаснее и ничего не стоит.

5.2 Ш-2: русская строка — копия английской по полям слияния

Перевод попадает в ту же карточку, что оригинал, только если расширение сгруппировало обе статьи в одну.

ИЗ КОДА Translator._groupDictionaryEntriesByHeadword (ext/js/language/translator.js), ключ группы:

this._createMapKey([term, normalizedReading, ...inflectionRuleChainCandidates]);

Что из этого чем является для сборщика:

поле строки банка роль
expression (поз. 0) несущее: это term в ключе группы. Расхождение — разные карточки, все переводы сироты
reading (поз. 1) несущее: normalizedReading в ключе. Пустое поле подменяется на expression (_convertTermBankEntryV3)
rules (поз. 3) несущее косвенно: rules решают, какие деинфляции применимы, а цепочка деинфляции входит в ключ группы
definitionTags (поз. 2), termTags (7) на ключ группы не влияют; расхождение даёт разные ярлыки у двух половин ряда, а не потерю пары
glossary (поз. 5) обязано отличаться — это и есть содержимое перевода
sequence (поз. 6) §5.1

РЕШЕНИЕ Практическое правило остаётся прежним и более строгим, чем минимум: русская строка — побайтная копия английской по expression, reading, rules, definitionTags, termTags, и отличается только glossary и sequence. Это ровно требование Ш-2 / критерий A4 ТЗ-расширения; здесь лишь названо, какая его часть несущая, а какая — гигиена.

⚠️ Исключение по reading, иначе побайтная сверка забракует исправную пару. ИЗ КОДА _convertTermBankEntryV3 подменяет пустое чтение на выражение (reading = reading.length > 0 ? reading : expression), и в ключ группы уходит уже подменённое значение. ЗАМЕРЕНО (проверяющим, по апстримному zip) в wty-en-en все reading пусты. ⇒ Пара, где английская строка несёт "", а русская — expression, спарится нормально. Правило сверки формулируется как «reading русской строки пуст или равен expression», а не «побайтно равен английскому».

5.3 Ш-3: revision — побайтно одинаковый и непустой

ИЗ КОДА isStrictPairingAvailable (translation-pairing.js):

return host.revision.length > 0 && host.revision === translation.revision;

⇒ revision — отпечаток сборки, а не «версия словаря» в человеческом смысле. Формат предложен ТЗ-расширения §3.1: <дата сборки>+<rows>-<max_rowid>. Расширение формат не проверяет — ему важно только совпадение и непустота. Владелец формата — anki_conf.

5.4 Заголовки фиксированы: wty-en-en и flibooster-en-ru

ИЗ КОДА ext/data/schemas/options-schema.json, ветка flibTranslation:

"translationDictionary": {"type": "string", "default": "flibooster-en-ru"},
"hostDictionary": {"type": "string", "default": "wty-en-en"}

Те же значения проставляет миграция OptionsUtil._updateVersion78 (ext/js/data/options-util.js) — и она же, по своему комментарию, единственный источник этих значений: сид ext/data/provisioning/default-options.json их не содержит и содержать не должен.

ЗАМЕРЕНО Грепом по ext/ (*.html, *.js, *.json): элемента интерфейса настроек для этих двух полей нет. Единственные читатели — display-translation.js (_shouldShowButton, _hideTranslationSourceDefinitions, _renderColumns) и translation-pairing.js.

⇒ ⚠️ index.json.title обоих zip обязан совпадать с этими строками побайтно. Иначе пользователю придётся править экспорт настроек руками — интерфейса, которым это чинится, нет.

⚠️ Заголовок английского zip — это ТРЕТЬЕ условие паринга, а не только косметика. ИЗ КОДА findHost отбрасывает хозяев чужих словарей поимённо: if (host.dictionary !== hostDictionary) { continue; }. Хозяин назван по имени намеренно — у пользователя может стоять второй английский словарь с чужими sequence. ⇒ Английский zip с заголовком wty-en-en-stamped (или любым другим) установится, будет виден в списке, и не спарится ни разу, даже если штампы и revision безупречны.

О «необычном» в заголовках: обе строки — ASCII, нижний регистр, дефисы, без пробелов и кавычек. ИЗ КОДА заголовок словаря попадает в CSS-селектор атрибута (Display._getCustomCss: [data-dictionary="${escapedTitle}"], кавычки экранируются; результат уходит в публичный setCustomCss) и в dataset.dictionary. Ограничений на символы схема dictionaryIndex не накладывает, но менять эти строки не нужно и нельзя — их значение зашито в дефолты настроек.

5.5 Формат банка и обязательные поля index.json

Форматные ограничения названы явно, чтобы их не приходилось угадывать — все проверяются ajv-схемами при импорте, то есть их нарушение даёт отказ импорта, а не тихую потерю пар.

⚠️ Ловушка: isUpdatable / indexUrl / downloadUrl — этих полей в обоих index.json быть НЕ ДОЛЖНО

Это самый тихий способ откатить всю работу, и он срабатывает у сборщика, который поступает естественно: регенерирует банки, а метаданные index.json берёт из апстримного zip. ЗАМЕРЕНО (проверяющим, чтением index.json из скачанного zip): апстримный wty-en-en несёт "isUpdatable": true и адреса на HuggingFace.

Что тогда произойдёт — по коду:

  1. ИЗ КОДА DictionaryEntry.checkForUpdate (ext/js/pages/settings/dictionary-controller.js) вызывается для каждого словаря с isUpdatable и валидными URL, когда пользователь нажимает «Check for updates» в настройках (_onCheckUpdatesButtonClick → _checkForUpdates): качает апстримный index.json и сравнивает ревизии через compareRevisions. ⚠️ Проверка запускается нажатием, а не сама по себе, — но это лишь означает, что бомба с часовым механизмом ждёт первого визита в настройки, а не что её нет.
  2. ИЗ КОДА compareRevisions (ext/js/dictionary/dictionary-data-util.js) сначала проверяет обе строки регуляркой ^(\d+\.)*\d+$. Наш отпечаток <дата>+<строки>-<max_rowid> её не проходит, поэтому сравнение вырождается в строковое current < latest — результат произволен и почти наверняка даст «доступно обновление».
  3. Пользователю показывается кнопка обновления; подтверждение в модальном окне (DictionaryController.updateDictionary) удаляет наш словарь и ставит апстримный zip. ⇒ Штампы исчезли, revision разъехался, вся пара мертва — а по симптомам это неотличимо от ошибки сборки.

Обратный случай не лучше: ИЗ КОДА DictionaryImporter._createSummary — if (typeof isUpdatable === 'boolean') { if (!isUpdatable || !this._validateUrl(indexUrl) || !this._validateUrl(downloadUrl)) throw new Error('Invalid index data for updatable dictionary'); }.

⇒ Поле, присутствующее со значением false, роняет импорт; поле true без валидных indexUrl и downloadUrl — тоже. Схема индекса вдобавок объявляет isUpdatable как const: true, так что false не проходит и ajv.

РЕШЕНИЕ Полей isUpdatable, indexUrl, downloadUrl в index.json обоих zip нет. Не false, не пустая строка — отсутствуют. Обновление пары делается пересборкой и перевыкладкой (§9), а не механизмом автообновления Yomitan: он не умеет держать два словаря в согласии и разведёт их при первом же срабатывании.

5.6 Сводка: какое поле артефакта кто читает

Таблица существует для проверки: поле без названного читателя — признак ошибочного требования.

поле артефакта цепочка читателей в форке
строка банка, поз. 6 sequence DictionaryImporter._convertTermBankEntryV3 → DictionaryDatabase._createTerm → Translator._createTermDictionaryEntryFromDatabaseEntry (sequences) → findHost
строка банка, поз. 0/1/3 Translator._groupDictionaryEntriesByHeadword (ключ группы)
строка банка, поз. 5 glossary DisplayGenerator._createTermDefinitionEntry → содержимое ячейки перевода (DisplayTranslation._buildCell)
index.json.title DictionaryImporter._createSummary → isStrictPairingAvailable, _shouldShowButton, _hideTranslationSourceDefinitions, pairDefinitions
index.json.revision DictionaryImporter._createSummary → isStrictPairingAvailable
index.json.format / version DictionaryImporter (выбор схемы банка)
index.json.sequenced ⚠️ только DictionaryImportController._addDictionarySettings и DictionaryController._updateMainDictionarySelectOptions — к парингу отношения не имеет
index.json.isUpdatable / indexUrl / downloadUrl ⚠️ DictionaryImporter._createSummary (валидация, бросает) → DictionaryEntry.checkForUpdate → замена словаря апстримным. Полей быть не должно (§5.5)

6. Точная семантика штампа — что сборщику надо знать про списки

findHost пересекает списки штампов, а не сравнивает sequences[0]:

if (host.sequences.some((s) => stamps.has(s))) {
  return row;
}

Комментарий в коде объясняет это слиянием определений с одинаковым глоссарием (Translator._addTermDefinitions → _addUniqueSimple(definition.sequences, sequences)).

⚠️ Уточнение, важное для сборщика. ИЗ КОДА это слияние в том режиме, где колонка вообще работает, не происходит:

⇒ На практике в рабочем режиме sequences каждого определения — список из одного элемента (своего sequence, либо -1). Пересечение списков — оборонительный код, а не рабочий режим. Для anki_conf это означает более строгое требование, чем «штамп где-то в списке»: штамп русской строки обязан быть РАВЕН штампу английской строки. Именно так и сформулирован критерий A2 ТЗ-расширения.

7. Смешанного режима нет

ИЗ КОДА комментарий pairDefinitions, дословно:

⚠️ СМЕШАННОГО РЕЖИМА НЕТ: либо все пары строгие, либо все переводы сироты.

Механика: strict вычисляется один раз на выдачу (DisplayTranslation._onContentUpdateComplete → isStrictPairingAvailable) и передаётся во все вызовы pairDefinitions. Фолбэка «не нашли по штампу — привяжем по заголовку» нет намеренно: состояние, в котором часть рядов собрана одним способом, а часть другим, неотличимо от ошибки.

⇒ Следствие для сборщика: поставка, где штампы проставлены только части статей, стоит ровно столько же, сколько поставка без штампов вообще, — для непроштампованной части. Частичный охват допустим по содержанию (у слова может не быть перевода — тогда в ряду прочерк), но недопустим по механике: если строка перевода существует, её штамп обязан быть валиден.


8. Режим отказа, который вы увидите первым

Это самая дорогая ошибка, потому что выглядит как частичный успех.

Симптом. Кнопка Ru есть, нажимается, переводы видны — но каждый идёт отдельной строкой с серым чипом «только перевод», а справа от английских статей — прочерки.

Что это значит. ИЗ КОДА _appendOrphanRow рисуется, когда findHost вернул null. Это полный отказ паринга, а не частичный: колонка прочерков рядом с сиротами — подпись «пары нет вообще», а не «часть пар не нашлась».

Пять причин, в порядке вероятности:

причина как отличить
sequence равен 0 или не совпадает хотя бы на одной стороне ⚠️ ни консоли, ни бейджа — stamps.size === 0 (и промах по штампу) возвращают null молча
английский словарь не установлен или установлен под другим заголовком (⚠️ см. §9 — самая вероятная причина у вас) предупреждение (ниже) есть; в списке словарей нет строки ровно с именем wty-en-en
revision разный у двух zip предупреждение (ниже) есть; в настройках у двух словарей видны разные строки ревизии
штампы совпадают, но нарушен Ш-2 предупреждения нет; статьи не слились: русская и английская — разные карточки, перевод сирота в своей
словарь перевода установлен, но выключен в активном профиле (ИЗ КОДА _getTranslatorEnabledDictionaryMap, ext/js/background/backend.js) ⚠️ единственная строка без единой сироты: pairDefinitions (translation-pairing.js) не получает ни одного определения с dictionary === translationDictionary, класть в сироты нечего; предупреждения и бейджа тоже нет — колонка перевода везде прочерк

⚠️ Последняя строка — не «частичный успех», а самая тихая из всех. _shouldShowButton (условие 3 ниже) и isStrictPairingAvailable оба читают api.getDictionaryInfo(), а тот, ИЗ КОДА (Backend._onApiGetDictionaryInfo), отдаёт все установленные словари — включённость в профиле не проверяет никто из них. Кнопка получает право показаться, паринг — право считаться строгим, а до перевода дело просто не доходит: словарь, которого нет в _getTranslatorEnabledDictionaryMap, переводчик не запрашивает вообще, ни для одного слова. Итог выглядит не как «пары нет», а как «словарь-перевода пуст» — и чинится он не пересборкой артефакта, а галочкой в настройках (§9.2).

Предупреждение, о котором речь: flib: dictionary build fingerprints differ, translations shown unpaired. ИЗ КОДА оно пишется, когда isStrictPairingAvailable вернула false, а она возвращает false в трёх случаях: (а) в getDictionaryInfo() нет словаря с именем hostDictionary, (б) нет словаря с именем translationDictionary, (в) revision пустой или различается. На практике случаев два: ветка (б) до предупреждения не доходит — _onContentUpdateComplete выходит раньше по _shouldShowButton, которая этот же словарь и требует (§8, условие 3), так что (б) достижима только гонкой (словарь удалён между двумя вызовами getDictionaryInfo()). ⚠️ Текст предупреждения врёт про причину: он говорит про отпечатки, а сработать мог из-за отсутствующего английского словаря. Не начинайте отладку с revision.

Ещё две поправки к тому, как это предупреждение выглядит:

⚠️ Асимметрия, из-за которой тишина обманчива — но она уже, чем кажется. Молча проваливается только случай «целочисленный штамп, но <= 0 либо не совпадающий». Всё остальное ловится раньше и громче:

⇒ Не считайте валидацию импорта защитой от главной ошибки: она закрывает соседнюю половину. Проверять sequence > 0 и совпадение штампов надо самому (§13, П1–П3).

Кроме того, кнопки Ru не будет вовсе, если не выполнено любое из трёх условий _shouldShowButton (ИЗ КОДА, все три проверяются по порядку):

  1. options.general.resultOutputMode === 'group' — в split/merge словари не сливаются в одну карточку, хозяина не найти никогда;
  2. display.contentType === 'terms' — на выдаче kanji/phrase переводить нечего;
  3. среди установленных словарей есть словарь с именем flibTranslation.translationDictionary (api.getDictionaryInfo()) — ⚠️ «установлен», а не «включён»: выключенный в профиле словарь тоже проходит это условие (см. последнюю строку таблицы выше).

⇒ Отладочный порядок для сборщика: нет кнопки — смотрите 1–3; кнопка есть, все сироты — смотрите таблицу выше, начиная со второй строки, а не с первой; кнопка есть, сирот нет вовсе, везде прочерк — смотрите последнюю строку таблицы и §9.2.

⚠️ Перед любой приёмкой удалите ранее установленный wty-en-en — иначе вы будете проверять не свой артефакт. Почему это не паранойя, а типичный случай — §9.


9. Как пара доезжает до пользователя — ⚠️ артефакт правильный ≠ пара работает

Этот раздел — про то, чего нет в требованиях к артефакту, но без чего безупречно собранная пара не доедет до паринга на типичной машине. Оба провала ниже выглядят как «fingerprints differ» и отправляют искать ошибку в revision, которой там нет.

9.1 Коллизия заголовка: пересобранный английский zip НЕ вытеснит установленный

ИЗ КОДА DictionaryImporter.importDictionary:

if (await dictionaryDatabase.dictionaryExists(dictionaryTitle)) {
  return {
    errors: [
      new Error(
        `Dictionary ${dictionaryTitle} is already imported, skipped it.`,
      ),
    ],
    result: null,
  };
}

Импорт словаря с уже занятым заголовком пропускается. Механизма «заменить» нет: замена — это удаление плюс импорт, и делает её только пользователь руками в настройках. ИЗ КОДА runDictionaryProvisioning (ext/js/pages/common/provisioning-controller.js) отбирает кандидатов тем же ключом — entries.filter((entry) => !importedTitles.has(entry.title)) — то есть тоже пропустит.

⚠️ А заголовок wty-en-en на типичной машине уже занят — нашими же дефолтами. ЗАМЕРЕНО ext/data/provisioning/default-options.json содержит "mainDictionary": "wty-en-en", и он же значится в ext/data/recommended-dictionaries.json с адресом на HuggingFace. То есть у пользователя, поставившего английский словарь до нашей пары, стоит апстримный wty-en-en без штампов.

Сценарий провала целиком:

  1. Пользователь получает пару. Русский словарь встаёт (его заголовок свободен).
  2. Английский пропускается — заголовок занят апстримным, у которого все sequence = 0.
  3. _shouldShowButton проходит (русский словарь установлен) → кнопка Ru появляется.
  4. isStrictPairingAvailable сравнивает revision апстримного (2026.08.29) и нашего (<дата>+<строки>-<max_rowid>) → false → «fingerprints differ», все переводы сироты.

⇒ Симптом идентичен разъехавшемуся отпечатку, а причина другая: пара не собралась, потому что половина не установилась.

ОТКРЫТО Пути «выкатить новый отпечаток поверх старого» сегодня нет. Каждая пересборка требует на стороне пользователя удаления обоих словарей и переимпорта по фиксированным заголовкам — а автоматизации этого в форке не написано. Это работа форка, не anki_conf, и она названа здесь только потому, что без неё anki_conf не сможет ни принять свою работу, ни объяснить отчёт о поле. Владелец — форк; вносится в кросс-репозиторные зависимости ТЗ-расширения §12.

Что из этого следует для anki_conf практически:

9.2 Оба словаря должны быть включены в активном профиле

Свойство установки, а не артефакта, но живёт здесь же: определения приходят только из словарей, включённых в текущем профиле (ИЗ КОДА Backend._getTranslatorEnabledDictionaryMap, ext/js/background/backend.js, — фильтрует options.dictionaries по dictionary.enabled).

⚠️ Обе половины этой ловушки молчаливые, и это стоит знать заранее. isStrictPairingAvailable (§8) читает api.getDictionaryInfo(), а тот отдаёт установленные словари независимо от того, включены ли они в профиле — предупреждение и бейдж реагируют только на установку, не на включённость. ⇒ Включённость словарей не диагностируется ничем: ни консолью, ни бейджем, ни текстом в интерфейсе.

ИЗ КОДА При импорте DictionaryImportController._addDictionarySettings включает словарь в текущем профиле и добавляет выключенным в остальные. ⇒ У пользователя с несколькими профилями пара будет работать в одном и молчать в других — это ожидаемо и не является дефектом артефакта.

9.3 Автообновление словаря — отдельный способ разрушить пару

См. ловушку в §5.5: isUpdatable в index.json даёт пользователю кнопку, заменяющую наш проштампованный словарь апстримным. Это тот же класс проблемы — «что происходит с парой после установки», — и закрывается он отсутствием трёх полей в индексе.


10. Доставка: что и куда публикует anki_conf

РЕШЕНИЕ В-5 (ТЗ-расширения §3.5). Словари не кладутся в сборку расширения — расширение скачивает их при первом запуске, тем же способом, каким сегодня качается Flibooster.exe.

Адресация трёхступенчатая, и каждая ступень решает свою задачу:

  1. В сборке расширения лежит только указатель:

    { "pointer": "https://<хост-раздачи>/dictionaries/latest.json" }
    

    ⚠️ Отпечаток сборки словаря не зашивается в расширение. Иначе каждая пересборка словаря требовала бы релиза расширения.

  2. Указатель latest.json (публикует anki_conf) называет один каталог отпечатка и оба zip с sha256:

    [
      {
        "title": "wty-en-en",
        "url": "https://<хост-раздачи>/dictionaries/<отпечаток>/wty-en-en.zip",
        "sha256": "…"
      },
      {
        "title": "flibooster-en-ru",
        "url": "https://<хост-раздачи>/dictionaries/<отпечаток>/flibooster-en-ru.zip",
        "sha256": "…"
      }
    ]
    
  3. Каталог отпечатка содержит оба zip.

⚠️ Оба адреса обязаны лежать под одним каталогом отпечатка. Тогда «скачали половину пары» невозможно по построению адреса, а не по дисциплине сборщика. Это второй слой защиты поверх Ш-3: revision ловит разъезд после установки, единый каталог не даёт ему случиться.

⚠️ sha256 обязателен. ⚠️ Формулировка «расширение сверяет» — это требование к будущей реализации форка, а не описание сегодняшнего поведения: провижининг по указателю ещё не написан (см. ниже). Публиковать суммы надо с самого начала: иначе первая же версия загрузчика поедет без сверки, потому что сверять будет нечего. Приём — тот же, которым Taskfile ядра сверяет скачанные сборки расширения.

⚠️ Заголовки кэширования. Указатель latest.json обязан отдаваться с коротким cache-control; ориентир — то, что ядро ставит своему манифесту релизов: max-age=60. Долгий кэш на указателе означает пользователей, сидящих на разъехавшейся паре. Сами zip лежат под адресом с отпечатком и потому иммутабельны — им длинный max-age не только можно, но и нужно.

Плейсхолдеры, которые заполняет anki_conf / выкладка, а не этот репозиторий: <хост-раздачи> (бакет/CDN), <отпечаток>, значения sha256, точный формат строки отпечатка, регламент и частота пересборки. ОТКРЫТО О-11 ТЗ-расширения: бакет и шаг выкладки не назначены.

ЗАМЕРЕНО Со стороны форка это пока не сделано. runDictionaryProvisioning (ext/js/pages/common/provisioning-controller.js) сегодня читает манифест вида {file, title} и собирает локальный адрес getUrl('data/provisioning/dictionaries/' + entry.file). Переход на указатель — работа форка (ТЗ-расширения §3.5, §12), и она не блокирует сборку артефакта: пару можно принимать импортом zip вручную.


11. Как мерить приёмку — ⚠️ только по артефакту

⚠️ Ни одно число в отчёте о приёмке не берётся из документации — только из произведённого артефакта.

Это не абстрактная осторожность. ЗАМЕРЕНО (ТЗ-расширения §4.1, 2026-09-07): все числа в anki_conf/docs/api/chunks/README.md оказались протухшими — они писались до пополнения матчсета коммитом 7c0e035a, и разошлись с реальностью в разы (выражений: README 6 764 против замеренных 22 690; размер: ~1.6 МБ против 6.44 МБ). Ошибка была не в цифрах, а в способе: их прочитали из прозы, а не сняли с артефакта.

Правило. Любая величина в приёмочном отчёте — количество статей, доля статей с переводом, max_rowid, размеры файлов, sha256 — снимается командой по выпущенному zip, и команда приводится рядом с числом, чтобы её можно было повторить. Число без воспроизводимой команды считается отсутствующим.

⚠️ Это правило применяется и к самому этому документу — включая базовое число из A5. Критерий A5 ТЗ-расширения называет 1 962 075 статей в пересобранном wty-en-en.zip и велит сверять их с term_count апстримного zip. ЗАМЕРЕНО (проверяющим, по zip с адреса из ext/data/recommended-dictionaries.json, revision: 2026.08.29): в апстримном zip 67 банков и 1 643 040 строк, а поля term_count в его index.json нет вообще.

⇒ Из этого следуют две вещи, и обе важны для сборщика:

  1. Сверять не с чем в том виде, как написано в A5. Величина, которую надо получить, — это число строк term-банков, и оно считается командой по zip, например:

    unzip -p wty-en-en.zip 'term_bank_*.json' | jq -s 'map(length) | add'
    

    (команда — иллюстрация ожидаемой формы отчёта, а не догма; годится любая, чей вывод воспроизводим).

  2. Чем является 1 962 075 — неизвестно. Это не число строк банков апстримного zip. Возможно, число строк в dict_base.db, возможно — значение до какой-то фильтрации. ⇒ Не подгоняйте под него ничего и не объясняйте «потерю» 319 035 статей, которой, возможно, никогда не было. Замерьте своё число, приведите команду, и назовите, что именно вы посчитали. Это часть ответа на О-Р1 (§12).


12. Открытый вопрос к anki_conf — форк ответить не может

ОТКРЫТО О-Р1. Может ли каждая английская статья получить стабильный ненулевой sequence, и что при этом происходит с числом статей?

Постановка:

⚠️ Связанный риск, названный в ТЗ-расширения §3.5 ИЗ ЗАДАЧИ §9: релизный конвейер ядра сегодня безусловно перезаписывает dict_base.db.gz необогащённой базой (обход — SKIP_DICT_BAKE=1). Это бьёт прямо в Ш-1/Ш-3: «пересборка из одного прогона» на сегодняшнем конвейере не воспроизводится. Владелец вопроса — anki_conf.


13. Критерии приёмки на стороне anki_conf

Критерии A1–A5, A6, A6а ТЗ-расширения остаются в силе и здесь не повторяются — они описывают, что проверяет форк. Ниже — дополнительные критерии, которые anki_conf проверяет у себя. Каждый — с воспроизводимой командой и числом, снятым с артефакта (§11).

⚠️ Все проверки группы А гоняются по ВЫПУЩЕННЫМ zip, а не по промежуточным таблицам базы. Конвейер, где выпечка словаря пропускается или подменяется (ТЗ-расширения §3.5, SKIP_DICT_BAKE), закроет критерии по данным, которые пользователю не уедут. Проверяется тот файл, который уйдёт в выкладку, — и он же потом публикуется без пересборки.

13.1 Группа А — по артефакту, до публикации

13.2 Группа Б — по процессу и выкладке

⚠️ Эти три «до публикации» не проверяются в принципе: П7 — утверждение о процессе, П8 и П9 требуют уже опубликованных адресов. Они закрываются после выкладки и до объявления поставки.


14. Что вне области этого документа