ИИ не умеет считать. Поэтому Tetri использует математику

Финансовый результат должен быть воспроизводимым, а не просто правдоподобным. Вот как Tetri строит отчёты, прогнозы и налоговые расчёты, не поручая модели придумывать числа.

Tetri не использует ИИ для расчёта отчётов, анализа расходов или прогноза денежного потока.

Сейчас это звучит почти как оправдание. ИИ добавляют в любой софт, а личные финансы кажутся для него очевидным местом. Данных много. Людям нужны ответы. Несложно представить чат, который обещает объяснить, куда ушли деньги.

Проблема проще: ИИ не может быть калькулятором.

Языковая модель генерирует вероятное продолжение текста. Иногда в нём оказывается правильная сумма. Иногда — неправильное число в очень уверенной формулировке.

Если ИИ-агент вызывает калькулятор, исполняет код или делает запрос к базе данных, результат может быть точным. Но считал детерминированный инструмент. Модель лишь выбрала его и описала ответ. В финансовом приложении эта граница должна оставаться очевидной.

Ниже несколько примеров того, что на самом деле выполняет анализ внутри Tetri. В коде опущены запросы и DTO, но сами расчёты не изменены.

Денежные расчёты начинаются с десятичной арифметики

Числа в JavaScript хранятся в двоичном формате с плавающей точкой. Для координат и анимации этого достаточно. Для денег — нет: 0.1 + 0.2 не равно в точности 0.3.

Tetri хранит денежные значения как десятичные строки и считает их через Decimal. Точность и округление настраиваются один раз, а при агрегации значения не переводятся в обычные числа JavaScript.

Decimal.set({ precision: 36, rounding: Decimal.ROUND_HALF_UP });

export function toDecimal(
  value: Decimal | string | number | null | undefined,
): Decimal {
  if (value == null || value === '') return new Decimal(0);
  return new Decimal(value);
}

export function sumDecimals<T>(
  items: readonly T[],
  selector: (item: T) => Decimal,
): Decimal {
  let total = new Decimal(0);
  for (const item of items) {
    total = total.plus(selector(item));
  }
  return total;
}

Правило простое: считать с полной точностью, сначала агрегировать и только потом округлять на границе выдачи. Баланс — не интерпретация модели. Это сумма транзакций.

Как Tetri находит необычные расходы

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

Для выбранного месяца Tetri берёт предыдущие шесть месяцев как базовый период. Исторические расходы группируются по категориям и валютам. Перед агрегацией каждая валютная группа переводится в основную валюту рабочего пространства: складывать евро напрямую с долларами бессмысленно.

Для каждой категории общая историческая сумма делится на количество транзакций. Затем каждая текущая транзакция сравнивается с этим средним значением.

for (const [categoryId, { total, count }] of totalsByCategory) {
  if (count === 0) continue;
  avgMap.set(categoryId, total.dividedBy(count));
}

for (const row of currentRows) {
  if (!row.categoryId) continue;

  const catAvg = avgMap.get(row.categoryId);
  if (!catAvg || catAvg.isZero()) continue;

  const homeAmount = this.currencyConversion
    .legToHome(row, defaultCurrencyId, rateMap)
    .abs();

  const multiplier = homeAmount.dividedBy(catAvg);

  if (multiplier.greaterThan(THRESHOLD)) {
    candidates.push({ row, homeAmount, catAvg, multiplier });
  }
}

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

Алгоритм намеренно простой. И это полезно. Пользователь может понять, почему транзакция появилась в отчёте, и не согласиться с порогом, не гадая, что модель решила о его жизни.

Как строится прогноз денежного потока

Прогноз Tetri — не сгенерированное предположение о том, что пользователь, возможно, сделает.

Он собирается из явно заданных будущих операций: регулярных правил, подписок, запланированных транзакций, платежей по долгам, целей и сумм бюджета. Для каждого месяца движок считает фактическое количество срабатываний активного правила и умножает заданную сумму на это количество.

const count = getRuleOccurrences(
  ctx,
  rule,
  month,
  monthStart,
  monthEnd,
).length;

if (count === 0) continue;

const perOcc = rulePerOccFor(ctx, rule.id, month);
const total = perOcc.times(count);

if (rule.type === 'income') {
  if (rule.walletId == null) {
    incomeFromRules = incomeFromRules.plus(total);
  } else {
    incomeByWallet.set(
      rule.walletId,
      (incomeByWallet.get(rule.walletId) ?? new Decimal(0)).plus(total),
    );
  }
} else {
  commitments = commitments.plus(total);
}

Запланированные транзакции добавляются по рабочим датам. Основная сумма долга учитывается отдельно от расходов: погашение тела долга уменьшает и деньги, и обязательство. Если считать весь платёж расходом, чистый капитал окажется занижен. Взносы на цели и плановые переменные расходы поступают из своих источников.

Затем баланс рассчитывается последовательно, месяц за месяцем:

const net = finiteDecimal(a.income)
  .minus(finiteDecimal(a.commitments))
  .minus(finiteDecimal(a.variable))
  .minus(finiteDecimal(a.goalContributions))
  .plus(finiteDecimal(a.debtPaydown ?? '0'));

balance = balance.plus(net);

Диапазон неопределённости тоже рассчитывается математически. Tetri измеряет исторические колебания месячного денежного потока через медианное абсолютное отклонение. Один экстремальный месяц влияет на него меньше, чем на обычное стандартное отклонение. По мере удаления от текущей даты диапазон растёт по фиксированной формуле дисперсии:

const variance = safeShock
  .pow(2)
  .times(monthsAheadDecimal)
  .plus(safeDrift.pow(2).times(monthsAheadDecimal.pow(2)));

const band = variance.sqrt();
const low = balance.minus(band);
const high = balance.plus(band);

Это всё ещё прогноз, а не обещание. Его предположения могут оказаться неверными. Но линия воспроизводима, а каждую точку можно связать с платежами, бюджетами и правилами, из которых она получилась.

Как рассчитывается прогрессивный налог

В расчёте налогов правдоподобного ответа тоже недостаточно.

Tetri классифицирует транзакции по налоговому профилю пользователя, переводит суммы по выбранному источнику курсов, применяет вычеты и проходит по таблице ставок. В прогрессивной шкале каждая ставка применяется только к той части дохода, которая попала в её диапазон.

const periodStartIncome = Decimal.max(
  thresholdIncome.minus(gross),
  new Decimal(0),
);

for (const bracket of sorted) {
  const from = toDecimal(bracket.from);
  const to = bracket.to !== null ? toDecimal(bracket.to) : null;
  const taxableFrom = Decimal.max(from, periodStartIncome);
  const taxableTo =
    to === null ? thresholdIncome : Decimal.min(to, thresholdIncome);
  const incomeInBracket = taxableTo.minus(taxableFrom);

  if (incomeInBracket.lte(0)) continue;

  const rate = new Decimal(bracket.rate);
  const taxInBracket = incomeInBracket.times(rate);
  tax = tax.plus(taxInBracket);
}

Вместе с результатом движок возвращает применённые диапазоны. Поэтому можно показать, какая часть дохода облагалась по каждой ставке. Это оценка, а не налоговая консультация, но за ней стоит расчёт, который можно проверить.

Правила как маленькие программы

Автоматизации и налоговая классификация в Tetri используют группы условий вместо вероятностной классификации. Условия можно вкладывать друг в друга через AND и OR, а сравнения денежных значений выполняются через Decimal.

export function matchesGroup(
  subject: RuleSubject,
  group: ConditionGroup,
  depth = 0,
): boolean {
  if (depth > MAX_CONDITION_DEPTH) {
    throw new Error(`ConditionGroup exceeds max depth ${MAX_CONDITION_DEPTH}`);
  }

  const results = group.conditions.map((item) =>
    'conditions' in item
      ? matchesGroup(subject, item, depth + 1)
      : matchesCondition(subject, item),
  );

  return group.operator === 'AND'
    ? results.every(Boolean)
    : results.some(Boolean);
}

Такие правила менее гибкие, чем просьба к модели самостоятельно определить смысл транзакции. Зато их видно, можно изменить и повторно применить. Если правило ошибочно, понятно, что именно исправлять.

Где может пригодиться ИИ

Языковая модель всё же может быть полезна рядом с этими системами. Например, превратить вопрос обычным языком в запрос к готовому отчёту или объяснить уже рассчитанный результат. Внешний агент может вызвать узкую операцию Tetri через MCP после явного разрешения пользователя.

Но модель не должна становиться источником баланса, прогноза или налоговой оценки.

Есть и граница данных. Встроенной ИИ-функции понадобилась бы история финансовых операций. Запуск достаточно мощной модели целиком внутри инфраструктуры Tetri слишком дорог для соло-разработчика. Передача данных внешней модели добавляет ещё одного обработчика данных и новые способы ошибиться.

Сейчас во внешние ИИ-сервисы отправляется ровно ноль финансовых записей Tetri.

Если ИИ позже появится в Tetri, математика должна остаться на своём месте. Модель может помочь задать вопрос или понять ответ. Она не должна придумывать число между ними.