Парадокс сложности: почему сложное, но формализованное стало дешевле простого, но контекстно зависимого

Общая мотивация

Поводом задуматься был небольшой пет проект для корпоративной отчетности, где вручную вносят данные в таблицы, формат которых для гибкости задается в 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. Отложить некоторые решения или недоделать спецификации дорого мне обошлись

В частности, дорого обошлись ошибки в выборе свой/чужой движок, сложности рефакторинга при изменение контрактов фронт-бэк.

Решения и контракты имеют разный срок «цементирования» - часть надо сразу, часть подстраиваются позже.

Ранняя фиксация требуется для того, что:

Поздняя фиксация допустима для:

2. Парадокс сложности

Сложное, но формализованное теперь дешево (идеальная задача для LLM)

Простое, но контекстное опасно для LLM

 

Новая ценность разработчика:
Не скорость кодинга, а способность:

Мы перестаём писать код.
Мы становимся архитекторами корректности.

P.S. Ценность корректности разная. Для менее ответственных и более определенных систем можно больше доверить LLM и джунам, но это совсем другая история (Каневский жпг).

@Dhwtj
05.10.2025 12:27 UTC
Первоисточник

Комментарии

@Dhwtj
05.10.2025 13:02 UTC
-1

В сущности, статья про делегирование, а вовсе не про LLM. И даже не про ИТ

@Ydav359
05.10.2025 13:23 UTC
-1

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

@Emelian
05.10.2025 13:36 UTC
0

но статьи без кода сложнее воспринимать

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

05.10.2025 14:05 UTC
0

Код в гите

Картинка в начале статьи, ссылкой на 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())),
    }
}

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

05.10.2025 14:58 UTC
0

Код в гите
Картинка в начале статьи, ссылкой на plantuml

В данном случае, с концепцией вашей статьи:

Парадокс сложности: почему сложное, но формализованное стало дешевле простого, но контекстно зависимого

я, совершенно, согласен. Настолько, что мне не требуются ни код, ни картинки для ее обоснования. Я бы и сам мог, вместо вас, порассуждать на эту тему. Например:

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

Примерно, как жена говорит мужу: «У тебя беспорядок на компьютерном столе. Дай, я наведу там порядок!». На что муж отвечает: «Ничего не трогай! Это не «беспорядок», это «творческий беспорядок»!» И, добавляет: «Беспорядок – это у тебя. На мой вопрос: «Где у нас сахар?», ты ответила: «В банке из под чая, на которой написано «соль»!».

Интересней было бы поговорить о сложности программных проектов. Как сделать, чтобы легко было понять собственный код, написанный несколько месяцев назад? Как правильно структурировать сам проект и код в нём? И как, вообще, «правильно» реализовывать программные модели? Желательно, на конкретных примерах.

05.10.2025 15:08 UTC
0

Как сделать, чтобы легко понять собственный код, написанный несколько месяцев назад?

Ну, ты полезешь с частным вопросом "где сахар?"

Должна быть привычная или хотя бы логичная система как найти сахар. Потом возникнет вопрос

  1. как вынуть банку с сахаром, высыпать в маленькую сахарницу на столе. И не сломать при этом ничего.

  2. как отметить, что сахара стало меньше и скоро надо взять его в магазине

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

Сложность ведь разная бывает. Есть сложность элементная, а есть структурная. Элементов, всяких разных может быть много, но структура их взаимоотношений, может быть, простой, или, по крайней мере, понятной и обозримой. 

LLM ломает привычную экономику проекта. Я бы сам не полез крутить свой движок формул и кишки XML внутри Excel. А тут оказалось, что это дешевле чем настраивать готовые библиотеки под потокобезопасность и даже дешевле чем придумать к нему батч обработку накопившихся запросов пользователей

Впрочем, на месте LLM вполне может быть и джун, вообще любое делегирование задач

05.10.2025 16:58 UTC
0

Должна быть привычная или хотя бы логичная система как найти сахар.

Ты говоришь о привычных, повторяющихся действиях. Но, там все просто. Ошибся раз, во второй уже будешь чуть умнее. Т.е., метод «научного втыка» – рулит.

бесполезно делать документацию, нужно делать предсказуемую логику

Ну, да! Как в анекдоте: «–Ёжики станьте птицами! – Ура! А как? – Отстаньте, глупые! Я не тактик, я стратег!»

Я бы сам не полез крутить свой движок формул и кишки XML внутри Excel.

У китайцев есть опенсорсный проект «MyCell», аналог Эксела, поддерживающий работу xml-файлов. Я даже пытался приспособить его для автоматизации процессов для табельной, занимающейся учетом рабочего времени, с использованием моей системы учета (программно – аппаратной). Жаль, фирму ликвидировали, а так могло что-то получиться.

@Spyman
06.10.2025 11:06 UTC
0

Вы сравнивали решение задачи с нуля и использование готовой библиотеки с помощью llm и пришли к выводу что с нуля нейрона пишет лучше? - Это совершенно логично - обучающая выборка намного больше для ванильного кода.

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

или это просто сложно написанная статья об очевидных вещах?

@Dhwtj
06.10.2025 14:47 UTC
+1

Вопрос масштаба.

Мне вот была ни разу не очевидно, что 450+250 строк в мне не знакомой предметной области будет написать проще, чем выбирать как бы хитро работать с многопотоком с уже готовым но не потокобезопасным движком

06.10.2025 20:56 UTC
0

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

Но как будто это опять же и так очевидно.

@GendByteMaster
06.10.2025 19:31 UTC
0

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

@Dhwtj
07.10.2025 16:18 UTC
0

неделю статья с битой ссылкой на гит и хоть бы кто написал

ну, код никто не читает, ясно

@Cordekk
09.10.2025 09:32 UTC
0

парадокса нет, оно же и раньше так работало. Все дело в пороге входа.
Человек знающий питон всё пытается сделать на питоне, человек со знанием c++ делает на с++.

Человеку, не знающему ничего из этого, проще было взять какой-то комбайн настраиваемых мышкой, а теперь вот проще задавать вопросы нейросети.