Общая мотивация
Поводом задуматься был небольшой пет проект для корпоративной отчетности, где вручную вносят данные в таблицы, формат которых для гибкости задается в Excel. Схема,
Статистика кода
--- .rs (32 files, 3043 lines) ---
452 domain\formulas.rs
286 presentation\table_render.rs
280 infrastructure\repository.rs
265 infrastructure\xlsx_xml.rs
198 services\cell_change_service.rs
127 presentation\controller.rs
121 infrastructure\excel_loader.rs
110 services\export_service.rs
106 presentation\html_views.rs
103 domain\dto.rs
85 services\errors.rs
81 services\calc_service.rs
75 presentation\flows.rs
74 infrastructure\sheet_list.rs
69 app_state.rs
67 infrastructure\auth.rs
66 infrastructure\user_ctx.rs
60 main.rs
57 services\viewer_queries.rs
56 services\adapters.rs
53 domain\styles.rs
48 infrastructure\report_registry.rs
42 domain\utils.rs
40 domain\merges.rs
38 infrastructure\sessions.rs
26 infrastructure\errors.rs
20 presentation\view_models.rs
12 domain\errors.rs
9 infrastructure\mod.rs
6 domain\mod.rs
6 services\mod.rs
5 presentation\mod.rs
--- .sql (6 files, 278 lines) ---
78 migrations\03_templates_mod.sql
56 migrations\04_reports_mod.sql
44 migrations\06_test_users.sql
40 migrations\05_add_postgres_extentions.sql
37 migrations\01_templates.sql
23 migrations\02_reports.sql
--- .html (3 files, 106 lines) ---
54 presentation\templates\reports.html
26 presentation\templates\report_header.html
26 presentation\templates\login.html
Почему Rust и ручная реализация?
TL;DR: Оказалось проще формализовать и реализовать свой потокобезопасный движок Excel с нуля, чем оптимизировать мутабельный движок.
Первая попытка (C# + EPPlus):
Мутабельный ООП-движок формул (готовая библиотека):
├─ Проблема: блокировки при конкурентном доступе
├─ Вариант 1: Полная блокировка - слишком медленно
├─ Вариант 1Б: Полная блокировка листа - и медленно и сложно
├─ Вариант 2: Батч накапливает изменения от всех пользователей + подписка на результат - сложно делать подписку
├─ Вариант 2Б: Таймеры-дятлы (контроллеры спрашивают батч "готово?") - растет лаг до ответа
├─ Вариант 3: Воркер на клиента + синхронизация через БД - очень много ОЗУ, придется делать время жизни воркера, снижать нагрузку на БД путем добавления write through cache с батч записью в БД и gracefull degradation
└─ Результат: каждый вариант сложен или ухудшает свойства системы, непонятно как выбрать
Проблема: оптимизации stateful (чертов ООП!) системы, слишком сложно проектировать,
нельзя доверить LLM (tacit контекст, trade-offs, partial failure)
Вторая попытка (Rust + ручная реализация):
Иммутабельная модель + функциональное ядро:
├─ Парсер формул по EBNF грамматике
├─ Интерпретатор (чистые функции)
├─ Топологический анализ зависимостей формул (обнаружение циклов)
└─ Eventual consistency (LWW для конфликтов), но мелко гранулярная
Результат: формализованные задачи LLM сделала безупречно,
а архитектурные решения (консистентность, конкурентность)
я зафиксировал сам до генерации кода.
Третья попытка (снова С#, батчинг и дятлы):
Таймеры-дятлы (контроллеры спрашивают у батча "готово?" 10-20 раз в секунду) оказались все же проще. То, что я считал за недостаток - таймер увеличивает лаг ответа на свой интервал - оказалось благом: мы избавились от "удара грома", когда система должна одновременно ответить сотням и тысячам пользователей сразу после завершения батча, создавая пиковую нагрузку. Дятлы-таймеры же вносят небольшой естественный джиттер в доли секунды.
Но у этого варианта было сложно доказать пригодность по НФТ (низкий лаг, не упираемся в ресурсы) и он уже не был реализован. К тому же, недостаток опыта не позволил мне сразу реализовать дятлов красиво.
Неожиданное открытие:
Сложное, но формализованное (парсер грамматики, интерпретатор формул, обнаружение циклов) проще реализовать с нуля, чем оптимизировать существующий мутабельный движок. LLM сделала безупречно, без правок и последующих доработок.
Простое, но контекстно зависимое (какую модель конкурентности выбрать? батчи или параллельные воркеры на каждое изменение? блокировки какой гранулярности?) - слишком много вариантов, trade-offs зависят от паттернов использования. LLM давала типовые решения, игнорируя специфику. Я тоже затруднялся уверенно оценить все варианты и выбрать лучший.
Два фундаментальных инсайта
1. Отложить некоторые решения или недоделать спецификации дорого мне обошлись
В частности, дорого обошлись ошибки в выборе свой/чужой движок, сложности рефакторинга при изменение контрактов фронт-бэк.
Решения и контракты имеют разный срок «цементирования» - часть надо сразу, часть подстраиваются позже.
Ранняя фиксация требуется для того, что:
Дорого изменять (системные / не локализованные гарантии, консистентность данных)
Сложно верифицировать результат (контракты фронт-бэк не проверишь компилятором), но может дать большой импакт позже (архитектурные паттерны)
Есть риски от нечеткости требований (бизнес-инварианты с неявными корнер кейсами)
Поздняя фиксация допустима для:
Дешёвых в изменении компонентов (легкий в рефакторинге код - кстати Rust код рефакторить проще чем C#, адаптеры, DTO) – подгоняем под остальное, рефакторим по мере разработки
2. Парадокс сложности
Сложное, но формализованное теперь дешево (идеальная задача для LLM)
Чёткие границы задачи
Известная предметная область, есть типовые решения
Объективные критерии успеха
Быстрая верификация
Простое, но контекстное опасно для LLM
Неявные требования
Субъективные критерии
Отложенная верификация
Новая ценность разработчика:
Не скорость кодинга, а способность:
Видеть долгосрочные последствия (blast radius изменений, emergent behavior, неопределенности и риски)
Извлекать неявное знание (tacit/implicit должно стать explicit)
Формализовать намерения. Потребности и требования надо преобразовать в формальные спецификации.
Создавать рамки доказуемости (внутри которых LLM безопасна). Идеально, если соответствие спецификациям гарантируются компилятором на уровне типов или хотя бы проверяются property тестами. Еще хуже, если они проверяются тестами на каждый use case и совсем плохо, если не проверяются совсем. Обязательно код должен быть удобен для review, логика понятна, сконцентрирована и не зашумлена.
Мы перестаём писать код.
Мы становимся архитекторами корректности.
P.S. Ценность корректности разная. Для менее ответственных и более определенных систем можно больше доверить LLM и джунам, но это совсем другая история (Каневский жпг).
Комментарии (12)

Ydav359
05.10.2025 13:02Много терминов, но нет фрагментов кода. Ссылка на гитхаб, это, конечно, хорошо, но статьи без кода сложнее воспринимать

Emelian
05.10.2025 13:02но статьи без кода сложнее воспринимать
Вам можно позавидовать! Я, программистские статьи, плохо воспринимаю без картинок. По принципу: «Лучше один раз потрогать, чем сто раз увидеть!» :) .

Dhwtj Автор
05.10.2025 13:02Код в гите
Картинка в начале статьи, ссылкой на plantuml
Также количество строк кода по файлам как "экономика", стоимость разных частей проекта. Тут видно, что треть кода (движок формул и парсинг XML стилей Excel) была делегирована LLM, быстро реализована и не требовала далее сложного тестирования и рефакторинга.
Зато пришлось повозиться с теми частями, корректность которых нельзя доказать компилятором. Например, последовательность обработки. Ошибся и привет.
А код я могу выложить. Скажите, какой. Могу выложить список сигнатур функций движка формул, включая публичные апи и приватные функции
Код
// src/services/cell_change_service.rs - Полностью обновленная версия // изменение ячеек, пересчет формул, дельта use std::collections::HashMap; use serde::Deserialize; use uuid::Uuid; use crate::services::calc_service::ChangedCell; use crate::domain::dto::{CellUpsertItem, ReportCellDto,SheetWithData}; use crate::domain::formulas; use crate::domain::utils::{a1_to_rc, rc_to_a1}; use crate::infrastructure::report_registry::ReportRegistry; use crate::infrastructure::repository::Repo; use crate::infrastructure::user_ctx::UserCtx; use crate::services::errors::ServiceError; use crate::services::adapters::{fetch_cells, fetch_report, load_sheet_from_report}; #[derive(Clone, Debug)] pub struct CellChangeService { repo: Repo, excel: ReportRegistry, } #[derive(Deserialize, Clone, Debug)] pub struct CellChangeInput { pub report_id: Uuid, pub sheet_name: String, pub r: u32, pub c: u32, pub value: String, pub region_id: Option<i16>, } type CellValues = HashMap<(u32, u32), String>; type Coord = (u32, u32); impl CellChangeService { pub fn new(repo: Repo, excel: ReportRegistry) -> Self { Self { repo, excel } } pub async fn change_cell( &self, input: CellChangeInput, user: UserCtx, ) -> Result<Vec<ChangedCell>, ServiceError> { user.require_auth()?; let filled_sheet = self.load_sheet_data(&input).await?; let before = self.compute_full_state(&filled_sheet); self.save_change(&input).await?; let after = self.compute_state_with_change(&filled_sheet, &input); Ok(self.find_formula_changes(&before, &after, &input)) } /// Возвращает структуру листа и пользовательские данные async fn load_sheet_data( &self, input: &CellChangeInput ) -> Result<SheetWithData, ServiceError> { let report = fetch_report(&self.repo, input.report_id).await?; let sheet_structure = load_sheet_from_report(&self.excel, &report, &input.sheet_name)?; let db_cells = fetch_cells(&self.repo, input.report_id, &input.sheet_name).await?; Ok(SheetWithData { sheet_structure, db_cells, }) } /// Сохраним изменения async fn save_change(&self, input: &CellChangeInput) -> Result<(), ServiceError> { let (value_num, value_text) = normalize_value(&input.value); let item = CellUpsertItem { address: rc_to_a1(input.r, input.c), value_num, value_text, region_id: input.region_id, row_index: None, }; self.repo.upsert_cells(input.report_id, &input.sheet_name, &[item]).await?; Ok(()) } // --- Методы вычислений теперь принимают `FilledSheet` --- /// Пересчитаем весь лист fn compute_full_state(&self, filled_sheet: &SheetWithData) -> CellValues { let mut values = self.merge_template_and_db( &filled_sheet.sheet_structure.values, &filled_sheet.db_cells, ); self.apply_formulas(&mut values, &filled_sheet.sheet_structure.formulas); values } /// Пересчитаем лист после изменения ячейки fn compute_state_with_change( &self, filled_sheet: &SheetWithData, input: &CellChangeInput, ) -> CellValues { let mut values = self.merge_template_and_db_except( &filled_sheet.sheet_structure.values, &filled_sheet.db_cells, (input.r, input.c), ); values.insert((input.r, input.c), input.value.clone()); self.apply_formulas(&mut values, &filled_sheet.sheet_structure.formulas); values } // --- Вспомогательные методы --- /// Зальем пользовательские данные из БД в шаблон fn merge_template_and_db(&self, template: &CellValues, db_cells: &[ReportCellDto]) -> CellValues { let mut values = template.clone(); for cell in db_cells { if let Ok(coord) = a1_to_rc(&cell.address) { values.insert(coord, cell_to_string(cell)); } } values } /// Зальем пользовательские данные из БД в шаблон кроме указанной ячейки fn merge_template_and_db_except( &self, template: &CellValues, db_cells: &[ReportCellDto], except: Coord ) -> CellValues { let mut values = template.clone(); for cell in db_cells { if let Ok(coord) = a1_to_rc(&cell.address) { if coord != except { values.insert(coord, cell_to_string(cell)); } } } values } fn apply_formulas(&self, values: &mut CellValues, formulas: &HashMap<Coord, String>) { let computed = formulas::compute_formulas(values, formulas); values.extend(computed); } /// найдем изменения вычисляемых ячеек fn find_formula_changes( &self, before: &CellValues, after: &CellValues, input: &CellChangeInput, ) -> Vec<ChangedCell> { let changed = (input.r, input.c); let mut res = Vec::new(); for (coord, new_val) in after { if *coord == changed { continue; } let old = before.get(coord).cloned().unwrap_or_default(); if old != *new_val { res.push(create_change_record(coord, new_val, &input.sheet_name)); } } res } } // --- Чистые функции-помощники --- fn cell_to_string(cell: &ReportCellDto) -> String { cell.value_text.clone() .or_else(|| cell.value_num.map(|n| n.to_string())) .unwrap_or_default() } fn create_change_record(coord: &Coord, value: &str, sheet_name: &str) -> ChangedCell { let (value_num, value_text) = normalize_value(value); ChangedCell { sheet_name: sheet_name.to_string(), r: coord.0, c: coord.1, address: rc_to_a1(coord.0, coord.1), value_num, value_text, is_formula: true, display_value: value.to_string(), } } pub fn normalize_value(s: &str) -> (Option<f64>, Option<String>) { let trimmed = s.trim(); if trimmed.is_empty() { return (None, None); } let normalized = trimmed.replace(' ', "").replace(',', "."); match normalized.parse::<f64>() { Ok(n) => (Some(n), None), Err(_) => (None, Some(trimmed.to_string())), } }Тут ошибся и не нашел ошибки пока не сконцентрировал логику, выкинув в другие слои то, что к алгоритму не относится. Поэтому, весь код старательно разделил по слоям: тонкие контроллеры, умные сервисы, домены, репозитории.

Emelian
05.10.2025 13:02Код в гите
Картинка в начале статьи, ссылкой на plantumlВ данном случае, с концепцией вашей статьи:
Парадокс сложности: почему сложное, но формализованное стало дешевле простого, но контекстно зависимого
я, совершенно, согласен. Настолько, что мне не требуются ни код, ни картинки для ее обоснования. Я бы и сам мог, вместо вас, порассуждать на эту тему. Например:
Сложность ведь разная бывает. Есть сложность элементная, а есть структурная. Элементов, всяких разных может быть много, но структура их взаимоотношений, может быть, простой, или, по крайней мере, понятной и обозримой. Это ваш первый случай. Во втором случае, элементов меньше, но расположены они в беспорядке, хаотично.
Примерно, как жена говорит мужу: «У тебя беспорядок на компьютерном столе. Дай, я наведу там порядок!». На что муж отвечает: «Ничего не трогай! Это не «беспорядок», это «творческий беспорядок»!» И, добавляет: «Беспорядок – это у тебя. На мой вопрос: «Где у нас сахар?», ты ответила: «В банке из под чая, на которой написано «соль»!».
Интересней было бы поговорить о сложности программных проектов. Как сделать, чтобы легко было понять собственный код, написанный несколько месяцев назад? Как правильно структурировать сам проект и код в нём? И как, вообще, «правильно» реализовывать программные модели? Желательно, на конкретных примерах.

Dhwtj Автор
05.10.2025 13:02Как сделать, чтобы легко понять собственный код, написанный несколько месяцев назад?
Ну, ты полезешь с частным вопросом "где сахар?"
Должна быть привычная или хотя бы логичная система как найти сахар. Потом возникнет вопрос
как вынуть банку с сахаром, высыпать в маленькую сахарницу на столе. И не сломать при этом ничего.
как отметить, что сахара стало меньше и скоро надо взять его в магазине
Прикольней, когда ты попадаешь на остров где воздушный шар, башня и швабры. Вот там поведение кода более непредсказуемое. И бесполезно делать документацию, нужно делать предсказуемую логику.
Сложность ведь разная бывает. Есть сложность элементная, а есть структурная. Элементов, всяких разных может быть много, но структура их взаимоотношений, может быть, простой, или, по крайней мере, понятной и обозримой.
LLM ломает привычную экономику проекта. Я бы сам не полез крутить свой движок формул и кишки XML внутри Excel. А тут оказалось, что это дешевле чем настраивать готовые библиотеки под потокобезопасность и даже дешевле чем придумать к нему батч обработку накопившихся запросов пользователей
Впрочем, на месте LLM вполне может быть и джун, вообще любое делегирование задач

Emelian
05.10.2025 13:02Должна быть привычная или хотя бы логичная система как найти сахар.
Ты говоришь о привычных, повторяющихся действиях. Но, там все просто. Ошибся раз, во второй уже будешь чуть умнее. Т.е., метод «научного втыка» – рулит.
бесполезно делать документацию, нужно делать предсказуемую логику
Ну, да! Как в анекдоте: «–Ёжики станьте птицами! – Ура! А как? – Отстаньте, глупые! Я не тактик, я стратег!»
Я бы сам не полез крутить свой движок формул и кишки XML внутри Excel.
У китайцев есть опенсорсный проект «MyCell», аналог Эксела, поддерживающий работу xml-файлов. Я даже пытался приспособить его для автоматизации процессов для табельной, занимающейся учетом рабочего времени, с использованием моей системы учета (программно – аппаратной). Жаль, фирму ликвидировали, а так могло что-то получиться.

Spyman
05.10.2025 13:02Вы сравнивали решение задачи с нуля и использование готовой библиотеки с помощью llm и пришли к выводу что с нуля нейрона пишет лучше? - Это совершенно логично - обучающая выборка намного больше для ванильного кода.
Вы пришли к выводу что четко формализованные до разработки требования лучше, чем генерируемые в процессе разработки? - давно известная истинна - без четкого т.з. результат х.з.
или это просто сложно написанная статья об очевидных вещах?

Dhwtj Автор
05.10.2025 13:02Вопрос масштаба.
Мне вот была ни разу не очевидно, что 450+250 строк в мне не знакомой предметной области будет написать проще, чем выбирать как бы хитро работать с многопотоком с уже готовым но не потокобезопасным движком

Spyman
05.10.2025 13:02На таких объемах я бы свою треуголку на то, что код написанный llm будет работать не поставил. Чем больше код, тем выше вероятность что llm пойдет в разнос, и по результатам одного экскремента из статьи - можно только утверждать только что "возможно, что сгенерировать код с 0 в полном объеме будет проще, чем использовать готовое решение".
Но как будто это опять же и так очевидно.

GendByteMaster
05.10.2025 13:02"Интересный взгляд на делегирование задач LLM — особенно в части, где формализация снижает стоимость разработки.

Dhwtj Автор
05.10.2025 13:02неделю статья с битой ссылкой на гит и хоть бы кто написал
ну, код никто не читает, ясно
Dhwtj Автор
В сущности, статья про делегирование, а вовсе не про LLM. И даже не про ИТ