Image: AI generated
Движки правил стояли на одной и той же предпосылке 60 лет. Объект проверки — это «факт (fact)».
Drools помещает Java-объекты как «fact» в working memory. Rego обращается с input как с заведомо истинными данными. JSON Schema считает структуру документа данностью. Все основаны на одном допущении — входные данные являются фактом.
Но в чём вообще смысл существования движка правил? Проверять, соответствуют ли данные правилам. Называть объект, нуждающийся в проверке, «уже истинным» — это противоречие.
Не факт, а утверждение
Объект проверки — не fact, а claim (утверждение). Это заявление, которое может оказаться истинным или ложным. Его обоснованность должна быть определена правилами.
JWT уже следует этому принципу. sub, exp, iss называются не «facts», а «claims». Это утверждения эмитента токена. Только после проверки подписи, срока действия и сверки issuer им можно доверять.
Эта структура была описана ещё в 1958 году.
Модель аргументации Тулмина
Стивен Тулмин в 1958 году разложил структуру аргументации на 6 элементов:
- Claim (утверждение): объект оценки. То, что нужно проверить — истинно или ложно.
- Ground (основание): данные-доказательства, используемые для оценки.
- Warrant (гарантия): правило, определяющее, что основание поддерживает утверждение.
- Backing (подкрепление): обоснование того, почему правило действительно.
- Qualifier (квалификатор): степень уверенности в оценке.
- Rebuttal (опровержение): исключительные условия, при которых утверждение не выполняется.
Формальная логика гласит: «если посылки истинны, то и заключение истинно». Тулмин мыслил иначе. «Утверждение поддерживается основанием и правилом, но при наличии исключений оно опровергается.» Любой аргумент может быть опровергнут.
Движки правил 60 лет стояли на стороне формальной логики. Вход — fact, результат — allow/deny, исключения — отдельный механизм. Тулмин был на другой стороне. Вход — claim, результат — степень (degree), исключения — встроены.
Проблема в том, что книга Тулмина стояла на полке философии. На полке движков правил её не было видно. 60 лет недостающего звена.
Поэтому я создал движок правил
toulmin — это реализация модели аргументации Тулмина в виде движка правил на Go.
Требования эволюционируют
Посмотрим, как if-else и toulmin справляются с одной и той же эволюцией требований.
// Понедельник: "Доступ только для аутентифицированных, блокировка по IP, внутренняя сеть освобождена от блокировки"
g := toulmin.NewGraph("api:access")
auth := g.Rule(isAuthenticated)
blocked := g.Counter(isIPBlocked)
exempt := g.Except(isInternalIP)
blocked.Attacks(auth)
exempt.Attacks(blocked)
// Вторник: "Добавить Rate limiting"
limited := g.Counter(isRateLimited)
limited.Attacks(auth)
// Среда: "Премиум-пользователи освобождены от Rate limit"
premium := g.Except(isPremiumUser)
premium.Attacks(limited)
// Четверг: "Во время инцидента ограничены даже премиум-пользователи"
incident := g.Counter(isIncidentMode)
incident.Attacks(premium)
Каждый день 2 строки добавляются, существующий код не меняется. Та же эволюция на if-else:
// Понедельник
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else {
allow = true
}
}
// Четверг — 4 уровня вложенности, структуру невозможно понять
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else if isRateLimited(ip) {
if isPremium(user) {
if !incidentMode {
allow = true
}
}
} else {
allow = true
}
}
toulmin: 2 строки на требование, структура неизменна. if-else: каждый раз перестраивается вся структура.
Правила — это функции Go
func(ctx Context, specs Specs) (bool, any)
ctx= материал для оценки, меняющийся с каждым запросом (пользователь, IP, контекст). Доступ черезGet/Set.specs= критерий оценки, фиксируемый при объявлении графа через.With()(пороговые значения, имена ролей, настройки)- Возврат =
(результат оценки, доказательство). Доказательство — произвольный тип, зависящий от домена.
func CheckOneFileOneFunc(ctx toulmin.Context, specs toulmin.Specs) (bool, any) {
gf, _ := ctx.Get("file")
f := gf.(*FileGround)
if len(f.Funcs) > 1 {
return true, &Evidence{Got: len(f.Funcs), Expected: 1}
}
return false, nil
}
Не нужно учить новый язык, как Rego. Достаточно писать функции на Go. (Порт на TypeScript rulecat использует ту же сигнатуру — npm install rulecat.)
spec — одна функция, разные критерии оценки
spec передаёт критерий оценки правила через builder в момент объявления. Одна и та же функция, зарегистрированная с разным spec, становится отдельным правилом — фабрика замыканий не нужна:
g := toulmin.NewGraph("access")
admin := g.Rule(isInRole).With(&RoleSpec{Role: "admin"}) // ruleID = "isInRole#admin"
editor := g.Rule(isInRole).With(&RoleSpec{Role: "editor"}).Qualifier(0.8)
g := toulmin.NewGraph("line-limit")
strict := g.Rule(CheckLineCount).With(&LineLimit{Max: 100}).Qualifier(0.7)
relaxed := g.Rule(CheckLineCount).With(&LineLimit{Max: 200}).Qualifier(0.5)
relaxed.Attacks(strict)
Значение spec должно реализовывать интерфейс Spec (SpecName() string·Validate() error) и проверяется в момент регистрации. Правила, которым spec не нужен, опускают .With() (nil spec).
Исключения объявляются как граф
С помощью Graph Builder API объявляются отношения между правилами, а движок обрабатывает их автоматически. Функция — это идентификатор. Строковые имена не нужны.
g := toulmin.NewGraph("filefunc")
w := g.Rule(CheckOneFileOneFunc)
d := g.Except(TestFileException)
d.Attacks(w)
ctx := toulmin.NewContext()
ctx.Set("file", file)
results, _ := g.Evaluate(ctx)
Одну и ту же функцию можно переиспользовать в другом графе с другими отношениями поражения:
strictGraph := toulmin.NewGraph("strict")
strictGraph.Rule(CheckOneFileOneFunc)
// Без исключений — тестовые файлы тоже не допускаются
lenientGraph := toulmin.NewGraph("lenient")
w := lenientGraph.Rule(CheckOneFileOneFunc)
r1 := lenientGraph.Except(TestFileException)
r2 := lenientGraph.Except(GeneratedFileException).Qualifier(0.8)
r1.Attacks(w)
r2.Attacks(w)
// Тестовые + сгенерированные файлы — оба исключения
Отслеживание обоснования оценки
Если передать EvalOption{Trace: true}, движок отслеживает не только verdict, но и какие правила активировались и какое правило какое победило. Каждая TraceEntry несёт элементы Тулмина как есть — Name (Claim)·Ground (ctx)·Specs (Backing)·Verdict:
results, _ := g.Evaluate(ctx, toulmin.EvalOption{Trace: true})
// results[0].Verdict: +0.6
// results[0].Trace: [
// {Name: "CheckOneFileOneFunc", Role: "rule", Activated: true, Qualifier: 1.0},
// {Name: "TestFileException", Role: "except", Activated: true, Qualifier: 1.0},
// ]
Когда правил десятки, человек может прочитать «почему получился именно такой verdict». Если передать Duration: true, движок также измеряет время выполнения каждого правила. Аудит-лог и отладка фактически встроены в движок — отдельное логирование не нужно.
Оценка вычисляется одной формулой
Применён h-Categoriser Амгу (2013):
raw = w / (1 + Σ raw(attackers))
verdict = 2 × raw - 1
+1.0— нарушение подтверждено0.0— оценка невозможна-1.0— опровержение подтверждено
Когда правило срабатывает, оно становится warrant. Когда исключение тоже срабатывает, оно становится attacker. Формула вычисляет баланс сил и выдаёт verdict. А если есть исключение из исключения? Оно становится attacker атакера, и исходное правило восстанавливается. Принцип компенсации (Compensation) — свойство, которому удовлетворяет только h-Categoriser.
Три уровня силы правил
Применена классификация Нюта (1994):
| Сила | Значение | Пример |
|---|---|---|
| Strict | Невозможно отменить | «Без аутентификации доступ к admin API запрещён» |
| Defeasible | Может быть отменено исключением | «Одна функция на файл» |
| Defeater | Не утверждает само, только блокирует другое правило | «Тестовые файлы — исключение» |
Strict-правила отклоняют атакующие рёбра. Defeater только атакует, собственной оценки не имеет. Это структурно выражает уровень принудительности правила.
Чем отличается от Rego
| Rego | toulmin | |
|---|---|---|
| Написание правил | Нужно учить DSL Rego | Функции Go |
| Обработка исключений | Ручной паттерн default/else | Декларативный граф defeats |
| Оценка | Бинарная allow/deny | Непрерывная [-1, +1] |
| Обоснование правила | # METADATA (движок игнорирует) | spec (часть структуры) |
| Сила правил | Отсутствует | strict/defeasible/defeater |
| Размер движка | Десятки тысяч строк | Сотни строк |
| Скорость | Интерпретатор (парсинг→AST→вычисление) | Прямой вызов функций Go |
Rego — широкий: экосистема интеграций с Kubernetes, Terraform, Envoy. toulmin — глубокий: в нём есть то, чего нет в Rego (defeasibility, qualifier, backing).
Перемещение Qualifier
В оригинальной модели Тулмина Qualifier прикреплён к Claim. «Вероятно, этому пациенту следует назначить пенициллин» — модальный квалификатор, выражающий степень уверенности в утверждении.
Движок toulmin переместил Qualifier с Claim на каждое Rule. В движке правил claim — это всего лишь объект проверки. «В этом файле 3 функции» — констатация факта, к которой степень уверенности неприменима. Качество оценки определяется уверенностью в правиле:
- «Одна функция на файл» — qualifier 1.0 (определённое правило)
- «Рекомендуется до 100 строк» — qualifier 0.7 (гибкое правило)
Qualifier каждого Rule становится начальным весом w(a) в h-Categoriser, а итоговый verdict выполняет роль, которую в оригинальной модели Тулмина играл Qualifier, — степень уверенности в оценке.
Эмпирическая проверка: преобразование 22 правил filefunc в модель Тулмина
filefunc — инструмент для конвенций структуры кода при LLM-нативной разработке на Go. Все 22 правила были преобразованы в warrant модели Тулмина.
Классификация по силе
| Strength | Кол-во | Доля | Примеры |
|---|---|---|---|
| Strict | 15 | 68% | F1, F2, F3, F4, A1-A3, A6-A16 |
| Defeasible | 4 | 18% | Q1, Q2, Q3, C4 |
| Defeater | 3 | 14% | F5, F6, исключение для тестовых файлов |
Большинство — strict. Конвенции структуры кода по своей природе минимизируют исключения.
Количественные результаты
| Проект | Файлов (до→после) | Средний LOC/файл (до→после) | Устранено нарушений SRP | Устранено нарушений depth |
|---|---|---|---|---|
| filefunc | — (соответствовал изначально) | 25.1 | 0 | 0 |
| yongol | 87→1,260 | 244→25.4 | 66→0 | 148→0 |
| whyso | 12→99 | 147.8→24.4 | 12→0 | 23→0 |
В yongol количество файлов выросло с 87 до 1 260. Файлов стало значительно больше, но средний LOC упал с 244 до 25.4. Нарушения SRP — 66, нарушения depth — 148, все сведены к нулю.
Теоретическая основа
Оригинальных теорий нет. Всё основано на существующих исследованиях:
| Элемент | Первоисточник |
|---|---|
| Структура из 6 элементов | Toulmin (1958) |
| strict/defeasible/defeater | Nute (1994) |
| h-Categoriser | Amgoud & Ben-Naim (2013) |
Оригинальность — в открытии того, что они связаны. 60 лет философия (Toulmin), логика (Nute) и теория аргументации (Amgoud) существовали по отдельности, и все они сошлись в одной точке — программном движке правил.
Вычисление контрактов
Верховенство права работает не потому, что судья умён. А потому, что структура принуждает к вынесению решения. Есть правила, объявлены исключения, и по доказательствам выносится приговор.
toulmin перенёс эту структуру в код.
- Warrant = статья закона
- Backing = цель законодателя
- Strength = императивная норма vs диспозитивная норма
- Rebuttal = исключительная оговорка
- Claim = дело
- Ground = доказательства
- h-Categoriser = приговор
Объявляется контракт (warrant), объявляются исключения (rebuttal), подставляются доказательства (ground), и вычисляется оценка (verdict).
Не человек решает. Формула вычисляет.
Acc(a) = w(a) / (1 + Σ Acc(attackers))
MIT License. github.com/park-jun-woo/toulmin
История изменений
- 2026-06-18: Обновление API — граф в стиле builder (
Rule/Counter/Except·.With()·.Attacks()), сигнатура правилаfunc(ctx Context, specs Specs),Evaluate(ctx, EvalOption{Trace}), backing→spec, порт на TypeScript (rulecat) - 2026-03-22: Первая версия