Раздел 46 · Effect: Тихая Академия

Один договор, много копий

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

У схемы из прошлых уроков много работы: она разбирает, записывает, даёт тип. Но она ещё и описание данных, дерево, которое можно обойти и перевести во что угодно. Из одного описания выходит много производных, и все они меняются вместе со схемой. Сегодня три: договор для чужих, равенство и генератор для проверки свойств.

Копия для гильдий: JSON Schema

JSON Schema это язык договоров о JSON, который понимает почти любой инструмент на любом языке. Schema.toJsonSchemaDocument переводит схему на этот язык:

export const frameContract = Schema.toJsonSchemaDocument(Frame);
{
  "type": "object",
  "required": ["id", "route", "priority", "headers", "message"],
  "id": { "type": "integer", "maximum": 9007199254740991, "exclusiveMinimum": 0 },
  "priority": { "type": "string", "enum": ["low", "normal", "urgent"] },
  "route": {
    "type": "array",
    "prefixItems": [{ "type": "string" }, { "type": "string" }],
    "maxItems": 2,
    "minItems": 2
  }
}

Это выборка из frameContract.schema, сам документ длиннее. Видно, что переехало: обязательные поля, целое больше нуля из FrameId, три слова priority, кортеж из двух элементов. relay в required нет: он optionalKey.

А теперь честно о том, что не переехало. У кода башни в JSON Schema написано просто "type": "string": проверка isPattern из урока 78 в 4.1 в документ не попадает. Свой фильтр «башня не шлёт кадр сама себе» не переедет никогда: это функция на TypeScript, и перевести её на язык JSON Schema нельзя. Бренды тоже остаются у нас, у ткачей их нет. Копия для гильдий слабее оригинала. Поэтому гильдия проверяет ею свои кадры до отправки, чтобы реже получать отказ, а принимаем мы всё равно своей схемой, а не их проверкой.

Договор с моделью

Тот же документ отдают языковой модели, когда просят ответ строго по форме: «ответь JSON по этой схеме». Модель это ещё одна гильдия, которая пишет нам кадры, и доверять ей стоит ровно так же, как Магистрали. Её ответ это строка с провода: разбираешь той же схемой, отказ объясняешь explain из урока 81 и, если хочешь, возвращаешь модели как подсказку для второй попытки. Схема получается договором в обе стороны: JSON Schema говорит модели, что прислать, а разбор проверяет, что прислали.

Копия для часовщиков: равенство

Два разбора одной строки дают два разных объекта. === сравнивает ссылки и ответит false. Сравнить по JSON.stringify? Он зависит от порядка ключей, а sentAt у нас DateTime.Utc, объект, а не строка. Нужно равенство по значению, которое знает форму кадра. Схема её знает:

export const sameFrame = Schema.toEquivalence(StampedFrame);

Равенство sameFrame спускается по полям: числа и строки сравнивает как есть, кортеж по элементам, headers по ключам, DateTime по моменту времени. Два разбора одной строки равны. Тот же кадр на миллисекунду позже не равен. В уроке 44 ты видел Equal.equals для классов Data; здесь то же, но для любой схемы, даже если её значения простые объекты.

Копия для проверки: генератор

Третья копия самая хитрая. Чтобы проверить, что договор честный, нужен не один кадр, а тысячи разных, в том числе таких, какие ты бы не придумал. Схема знает, из чего состоит кадр, значит может их и породить: число в пределах проверок, строку нужного вида, одно из трёх слов срочности.

Нестабильно. Модуль effect/Arbitrary в 4.1 помечен @stability unstable: его имена и настройки могут поменяться в минорной версии. Это свой генератор Effect, а не библиотека fast-check, хотя идея та же. Урок написан на 4.1.0.

Arbitrary.schema(S) строит генератор значений схемы S. Arbitrary.checkEffect(генератор, свойство) порождает значения и проверяет на каждом свойство, пока не найдёт контрпример или не кончатся попытки. Про проверку свойств подробно было в уроке 27, там законы проверял fast-check.

Свойство честного договора: записал на провод, прочитал обратно, вышло то же. Вот оно на подтверждении Ack, у которого номер Schema.Number:

const Ack = Schema.Struct({ kind: Schema.Literal('ack'), of: Schema.Number });
const AckLine = Schema.fromJsonString(Ack);
const same = Schema.toEquivalence(Ack);

const survives = (ack: typeof Ack.Type) =>
  Schema.encodeEffect(AckLine)(ack).pipe(
    Effect.flatMap(Schema.decodeEffect(AckLine)),
    Effect.map((back) => same(back, ack)),
    Effect.orElseSucceed(() => false),
  );

const result = await Effect.runPromise(
  Arbitrary.checkEffect(Arbitrary.schema(Ack), survives, { runs: 1000, seed: 3 }),
);
{
  _tag: 'Falsified',
  initialInput: { kind: 'ack', of: NaN },
  shrunkInput: { kind: 'ack', of: NaN },
  failure: { _tag: 'ReturnedFalse' },
  runs: 239,
  ...
}

На двести тридцать девятом кадре договор соврал. Schema.Number честно пропускает NaN: это число в смысле TypeScript. Но JSON не умеет NaN, и JSON.stringify пишет вместо него null:

{"kind":"ack","of":null}
Expected number
  at ["of"]

Кадр, который наша же схема пустила внутрь, наша же схема не может прочитать с провода. С другим seed генератор приносит Infinity: та же беда. Руками такой кадр никто бы не написал, а Магистраль однажды прислала бы. Поэтому номера в сообщениях кадра в эталоне не Schema.Number, а Schema.Int: целое число конечно, и JSON его переносит. С Schema.Int та же проверка отвечает { _tag: 'Passed', runs: 1000 }.

В тесте этапа свойство пишут короче, через @effect/vitest: it.effect.prop получает имя, схемы входов и свойство, а генератор строит из схем сам:

it.effect.prop('любой кадр по договору переживает провод', { frame: StampedFrame }, ({ frame }) =>
  Effect.map(roundTrip(frame), (back) => sameFrame(back, frame)),
);

Генератор кадров, равенство и проверка, что кадр переживает провод, все три выведены из одной StampedFrame. Добавишь поле, и все три о нём узнают.

Где тут дыра

const weaversContract = {
  type: 'object',
  required: ['id', 'route', 'priority', 'message'],
  properties: {
    id: { type: 'number' },
    priority: { enum: ['low', 'normal', 'urgent'] },
  },
};

В прошлом месяце копия совпадала с договором. С тех пор у кадра появились обязательные headers, а номер стал целым больше нуля. Ткачи проверяют свои кадры по копии Ринцвальда, кадры проходят, а мы отказываем: headers: нет поля. Ткачи уверены, что виноваты мы, и по-своему правы: мы дали им неверный договор. Рукописная копия расходится с оригиналом молча, в день, когда кто-то правит схему и не помнит о копии.

Лечится тем, что копия выводится из схемы при каждой сборке. Править её руками нельзя, потому что руками её никто не пишет.

Магистраль, этап 8

Договор кадров получает три копии: JSON Schema для гильдий, равенство кадров и проверку, что кадр переживает провод. Главный тест этапа это свойство на тысячах кадров, которые генератор выводит из той же схемы.

Практика

Глубже в архиве

Схемы как договор с моделью в v3 разобраны в уроке «Schema для LLM» старого раздела, проверка свойств в уроке «Свойства и снимки» раздела про тестирование.

Дальше

Утром Рыдкулл кладёт на стол Казимира объявление Магистрали, ещё пахнущее краской.

Две версии одного договора ждут в следующем уроке.