Предположение, которое летело слишком близко к Солнцу

В начале 1980-х NASA столкнулась с вопросом, который до сих пор преследует критически важную инженерию: как смириться с багами, которые вы ещё не нашли? Их ответом стало N-version programming. Дать одну и ту же спецификацию трём независимым командам. Запустить все три программы параллельно. Голосовать по выходным данным. Если одна команда допустила баг, две другие переголосуют её.

Это звучит как математический здравый смысл. Но это ошибка.

В 1986 году John Knight и Nancy Leveson опубликовали результаты масштабного эксперимента, финансируемого NASA. Двадцать семь аспирантов в двух университетах написали независимые реализации одной и той же спецификации missile interceptor. Каждая программа в отдельности была чрезвычайно надёжной. Шесть версий вообще никогда не падали. Двадцать три прошли более 99,9% из миллиона случайно сгенерированных тестовых случаев.

Но когда несколько версий падали на одном и том же входе, они падали вместе гораздо чаще, чем предсказывала statistical independence. z-score составил 100,55. На доверительном интервале 99% null hypothesis of independence была разрушена. NASA ставила на то, что человеческая ошибка случайна. Knight и Leveson доказали, что она кластеризуется.

Что на самом деле обещает N-version programming

Логика N-version programming заимствована из аппаратной избыточности. Если привязать три одинаковых гироскопа к ракете и один дрейфует, два других переголосуют дрейф. Отказы независимы, потому что гироскопы — физические объекты, подверженные независимым производственным отклонениям, температурным градиентам и вибрационным модам.

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

Архитектура N-version architecture на практике выглядит так:

from typing import Callable, List, TypeVar

T = TypeVar('T')

def n_version_vote(
    implementations: List[Callable[[float], T]],
    input_value: float
) -> T:
    """Run N versions and return the majority output."""
    results = [impl(input_value) for impl in implementations]
    
    # Simple majority vote
    from collections import Counter
    counts = Counter(str(r) for r in results)
    most_common = counts.most_common(1)[0][0]
    
    # Return the actual value that matched the winning string
    for r in results:
        if str(r) == most_common:
            return r
    
    raise RuntimeError("No consensus")

Этот voter — простая часть. Сложная часть — предположение, скрытое внутри него: что implementations[0], implementations[1] и implementations[2] падают на некоррелированных подмножествах входного пространства. Knight и Leveson показали, что это предположение и есть изъян.

Откуда берутся коррелированные отказы

Эксперимент Knight-Leveson выявил два различных механизма common-mode failure, и ни один не исправляется просьбой к командам «стараться больше».

Первый — это двусмысленность спецификации. В спецификации missile interceptor содержались крайние случаи, которые были по-настоящему сложными. Восемь из двадцати семи программистов некорректно обработали случай, когда три радарные точки были коллинеарны. Ошибки были разными. У одного был off-by-one в индексе массива. Один использовал алгоритм с плохой numerical stability. Один полностью забыл о boundary case. Но отказы кластеризовались на одних и тех же сложных входных данных, потому что сама задача была сложной, а не потому, что программисты были небрежны.

Это «difficulty factor». Когда входные данные находятся на граничном условии, требуют хитрой floating-point math или включают недостаточно специфицированный state transition, независимые команды обычно испытывают трудности в одном и том же месте. Их решения различаются. Их области отказов перекрываются.

Второй механизм — shared mental models. Программисты, обученные по одной и той же программе, применяют одни и те же эвристики. Они тянутся к одному и тому же sorting algorithm, к одному и тому же defensive copy pattern, к одному и тому же epsilon comparison для floating-point equality. Когда это общее поведение по умолчанию неверно для текущей задачи, каждая команда срывается с одного и того же обрыва.

Собственный Software Safety Guidebook NASA в итоге прямо признал это. В нём отмечается, что «many professionals regard N-Version programming as ineffective, or even counter productive». В одном исследовании NASA экспериментального самолёта каждая найденная во время тестирования проблема с ПО исходила из redundancy management system. Основное flight control software было безупречным. N-version layer — это единственное, что ломалось.

Налог на сложность, о котором никто не говорит

Даже если бы N-version programming работало так, как рекламируется, оно взимает высокую плату. Вы платите за три полные реализации. Вы платите за voter, который сам содержит логику. Вы платите за операционные накладные расходы на запуск трёх процессов и согласование их выходных данных.

Этот voter — не тривиальный кусок кода. Он должен обрабатывать ties, timeouts, divergent output formats и случай, когда majority ошибается. Brunelle и Eckhardt продемонстрировали это в 1985 году с операционной системой SIFT. Две новые N-versions переголосовали оригинальную, корректную реализацию и выдали неверный ответ. Система избыточности создала ошибку, которую должна была предотвратить.

Сложность накапливается способами, которые трудно измерить, пока они не укусят вас. Теперь у вас есть три deployables для версионирования, три test matrices для поддержки и три команды, которые будут по-разному интерпретировать изменения спецификации, когда требования неизбежно изменятся. Площадь поверхности для человеческой ошибки растёт быстрее, чем улучшается надёжность.

Что на самом деле работает взамен

NASA не отказалась от fault tolerance. Они отказались от конкретного предположения, что diversity приходит из independence. Современные high-assurance системы используют комбинацию техник, которые адресуют реальные режимы отказов, выявленные Knight и Leveson.

Formal methods for the critical path. Вместо того чтобы надеяться, что три команды правильно интерпретируют спецификацию, запишите спецификацию в форме, проверяемой машиной. Инструменты вроде TLA+, Coq и SPIN верифицируют, что дизайн удовлетворяет своим invariants, прежде чем кто-либо напишет строчку кода реализации. Собственный Remote Agent Experiment NASA использовал SPIN для нахождения concurrency bugs, которые проскользнули через обширное тестирование.

Diversity through dissimilar technology stacks. Если вам нужна redundancy, сделайте её глубокой. Система управления полётом Airbus A330 использует dissimilar hardware architectures, разные programming languages и независимые compilers для своих primary и backup channels. Цель — не просто независимые команды, а независимые failure modes на каждом уровне стека.

Simplification over duplication. Software Safety Guidebook NASA в конечном итоге рекомендует N-version programming только для «small simple functions». Урок неброский, но эффективный: самая надёжная система — та, о которой достаточно просто рассуждать напрямую. Каждая строчка redundancy management code — это строчка, которая может упасть.

Вот как на практике выглядит упрощённый, specification-driven подход. Вместо трёх непрозрачных реализаций, голосующих по выходным данным, закодируйте critical invariant явно и проверяйте его во время выполнения:

from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class LaunchCommand:
    thrust_level: float  # 0.0 to 1.0
    abort_flag: bool
    
    def is_valid(self) -> bool:
        """Runtime invariant check derived from the formal spec."""
        if not (0.0 <= self.thrust_level <= 1.0):
            return False
        if self.abort_flag and self.thrust_level > 0.0:
            return False
        return True

def execute_command(cmd: LaunchCommand) -> Optional[str]:
    if not cmd.is_valid():
        raise ValueError("Invariant violation: command violates safety spec")
    
    # Execute only after the single, explicit check passes.
    return f"Executing thrust={cmd.thrust_level}, abort={cmd.abort_flag}"

Это не N-version programming. Это одна реализация с одной явной, тестируемой safety boundary. Усилия должны направляться на эту границу. А не на надежду, что две из трёх команд сделали всё правильно.

Современное эхо

Статья Knight и Leveson 1986 года несёт предупреждение, которое становится всё актуальнее с каждым годом. Коррелированные отказы не требуют коррелированных команд. Они требуют коррелированных входных данных и коррелированной сложности. По мере распространения инструментов AI-assisted coding мы проводим новый N-version experiment в планетарном масштабе. Модели, обученные на overlapping corpora, промптированные похожими паттернами, производят код с общими failure modes.

Недавние исследования LLM code generation указывают на co-error rates от 15% до 30% для AI-generated components. beta factor, доля отказов, приписываемых common cause, возможно, уже превышает значения, измеренные Knight и Leveson для human-programmer. Мы повторяем эксперимент с большим числом участников и тем же ошибочным предположением.

NASA усвоила этот урок дорогой ценой. Вам не нужны три реализации. Вам нужна одна реализация с invariant, который вы можете сформулировать, проверить и которому можно доверять. Всё остальное — оптимизм, замаскированный под инженерию.

FAQ

Что такое N-version programming?

N-version programming — это техника software fault tolerance, при которой несколько независимых команд реализуют одну и ту же спецификацию. Программы запускаются параллельно, и voter выбирает majority output, исходя из предположения, что независимые команды делают независимые ошибки.

Действительно ли NASA использовала N-version programming?

Да. NASA финансировала ранние исследования multi-version software, и в какой-то момент некоторые политические документы фактически требовали N-version programming для fault-tolerant systems. Sequence запуска двигателя Space Shuttle использовала её, хотя NASA позже ограничила её применение небольшими простыми функциями после того, как эмпирические исследования выявили её ограничения.

Что представлял собой эксперимент Knight-Leveson?

В 1986 году John Knight и Nancy Leveson поручили двадцати семи программистам написать независимые реализации спецификации missile interceptor. За миллион тестов программы демонстрировали значительно больше coincident failures, чем предсказывала бы statistical independence, отвергая ключевое предположение, лежащее в основе N-version programming.

Стоит ли вообще использовать N-version programming?

В ограниченных случаях. Текущие рекомендации NASA предполагают, что это может быть уместно для небольших, чётко определённых функций, где может быть обеспечена true diversity. Для общей разработки приложений стоимость сложности и риск коррелированных отказов перевешивают выгоды. Formal methods, runtime invariant checking и simplification обычно являются более удачными вложениями.