Anda memiliki lima implementasi dari fungsi yang sama. Tiga mengembalikan hasil yang sama. Satu sedikit berbeda. Satu melempar exception. Mana yang benar?

Kebanyakan tim secara default menggunakan majority vote. Itu berjalan baik ketika output-nya identik dan error-nya jelas. Itu runtuh pada saat implementasi Anda tidak setuju dengan cara-cara yang halus, atau ketika setiap varian mengembalikan jawaban yang berbeda. N-version programming hanya memecahkan separuh pertama masalah: menjalankan beberapa versi. Separuh yang lebih sulit adalah memutuskan output mana yang dipercaya.

Apa itu N-version programming?

N-version programming adalah teknik fault-tolerance di mana Anda menjalankan beberapa implementasi independen dari spesifikasi yang sama dan menggabungkan hasilnya. Bentuk klasiknya adalah triple modular redundancy: tiga sistem memilih, dan mayoritas menang. Ini bermula dari hardware safety-critical, pikirkan avionik, di mana satu bug bisa menewaskan orang.

Ide yang sama muncul kembali di AI engineering. Ketika Anda meminta LLM untuk menghasilkan kode, Anda mungkin men-sample lima completion yang berbeda. Ketika Anda memiliki parser legacy dan sebuah rewrite, Anda mungkin menjalankan keduanya secara paralel dan membandingkan. Akar hardware-nya terlihat. Kami meminjam arsitektur tanpa selalu meminjam selection logic yang membuatnya berfungsi.

Di hardware, output-nya adalah bit. Di software, output-nya adalah structured data, string, ranking, atau side effect. Majority vote mengasumsikan equality murah untuk dihitung dan umum ditemukan. Untuk sebagian besar masalah software, kedua asumsi tersebut tidak berlaku.

Jebakan voting: mengapa “paling umum” bukan “paling benar”

Saya mengalami ini tahun lalu dengan sebuah refactor search ranking. Kami memiliki lima algoritma ranking: sistem legacy, dua pendekatan berbasis model, dan dua heuristic baseline. Pada sebuah sample query, empat di antaranya mengembalikan daftar top-10 yang berbeda. Tidak ada yang cocok secara persis. Tidak ada mayoritas untuk dipilih.

Ini adalah kasus normal, bukan edge case. Implementasi yang berbeda mengoptimalkan untuk hal-hal yang berbeda. Satu mungkin lebih menyukai recency. Yang lain mungkin memberi bobot pada popularity. Yang ketiga mungkin memiliki bug yang hanya muncul pada hari Selasa. Jika Anda memilih dengan exact string match, Anda akan mengikat diri Anda pada implementasi yang paling mediocre, yang mengembalikan output paling bland dan paling tidak mengejutkan.

Exact-match voting juga gagal secara diam-diam. Dua implementasi dapat mengembalikan jawaban yang sama dan salah karena mereka berbagi asumsi buruk atau bug yang disalin. Correlated failures menghancurkan N-version redundancy sepenuhnya. Jika tiga dari lima parser Anda dilatih pada dataset yang sama dan bias, kesesuaian mereka tidak berarti apa-apa.

Bagaimana differential testing sebenarnya bekerja

Pendekatan yang lebih baik adalah differential testing dengan structured comparison. Alih-alih bertanya “output mana yang identik,” Anda bertanya “output mana yang terbaik menurut kriteria yang bisa saya definisikan dan ukur.”

Mulailah dengan mendefinisikan equivalence function untuk domain Anda. Untuk search ranking, Anda mungkin membandingkan menggunakan normalized discounted cumulative gain (NDCG). Untuk parser JSON, Anda mungkin membandingkan object graph yang dihasilkan. Untuk output string, Anda mungkin menggunakan semantic similarity atau downstream task score. Kuncinya adalah equality menjadi sebuah spektrum, bukan switch biner.

Selanjutnya, definisikan scoring function yang memetakan setiap output ke scalar. Di sinilah domain knowledge masuk. Sebuah scoring function untuk tugas code-generation mungkin menggabungkan compilation success, test pass rate, runtime performance, dan output length. Bobot eksaknya kurang penting daripada fakta bahwa mereka eksplisit.

Dengan score yang ada, selection menjadi optimasi sederhana. Pilih output dengan score tertinggi. Jika ada tie, gunakan fallback heuristic, seperti lebih memilih implementasi dengan historical error rate terendah.

Berikut adalah selector yang bisa Anda adaptasi. Ia menjalankan N varian, men-score setiap output, dan mengembalikan yang terbaik beserta metadata diagnostik:

from dataclasses import dataclass
from typing import Callable, List, Optional, TypeVar
import statistics

T = TypeVar("T")

@dataclass
class VariantResult:
    variant_id: str
    output: Optional[T]
    error: Optional[Exception]
    score: float = 0.0

class NVersionSelector:
    def __init__(
        self,
        score_fn: Callable[[T], float],
        equivalence_fn: Optional[Callable[[T, T], float]] = None,
        tie_breaker: Optional[Callable[[List[VariantResult]], VariantResult]] = None,
    ):
        self.score_fn = score_fn
        self.equivalence_fn = equivalence_fn or (lambda a, b: 1.0 if a == b else 0.0)
        self.tie_breaker = tie_breaker

    def select(self, variants: List[Callable[..., T]], *args, **kwargs) -> VariantResult:
        results: List[VariantResult] = []

        for variant in variants:
            try:
                output = variant(*args, **kwargs)
                results.append(VariantResult(
                    variant_id=variant.__name__,
                    output=output,
                    error=None,
                ))
            except Exception as exc:
                results.append(VariantResult(
                    variant_id=variant.__name__,
                    output=None,
                    error=exc,
                ))

        # Score only successful runs
        for r in results:
            if r.output is not None:
                r.score = self.score_fn(r.output)

        # Filter to successful, scored results
        valid = [r for r in results if r.error is None and r.output is not None]
        if not valid:
            raise RuntimeError("All variants failed")

        best_score = max(r.score for r in valid)
        candidates = [r for r in valid if r.score == best_score]

        if len(candidates) == 1:
            return candidates[0]

        if self.tie_breaker:
            return self.tie_breaker(candidates)

        return self._default_tie_break(candidates, valid)

    def _default_tie_break(
        self, candidates: List[VariantResult], all_valid: List[VariantResult]
    ) -> VariantResult:
        # Prefer the candidate whose output is most similar to other outputs
        def consensus_score(candidate: VariantResult) -> float:
            similarities = [
                self.equivalence_fn(candidate.output, other.output)
                for other in all_valid
                if other.variant_id != candidate.variant_id
            ]
            return statistics.mean(similarities) if similarities else 0.0

        return max(candidates, key=consensus_score)

score_fn adalah tempat Anda meng-encode apa artinya “terbaik” untuk masalah Anda. equivalence_fn menangani kasus di mana score tie dengan mengukur seberapa similar sebuah output dengan sisanya. Ini menggeneralisasi majority vote menjadi sesuatu yang bekerja ketika tidak ada dua output yang cocok secara persis.

Men-scoring implementasi dengan multi-criteria evaluation

Sebuah single scalar score bersih, tapi berbahaya jika Anda menyatukan terlalu banyak dimensi menjadi satu angka. Saya lebih suka pendekatan two-tier.

Tier satu adalah hard filter. Output yang gagal compilation, melanggar invariant, atau crash langsung dibuang. Tidak ada partial credit.

Tier dua adalah soft score untuk yang survive. Ini bisa menjadi weighted sum, tapi pertahankan bobotnya eksplisit dan dapat disesuaikan. Jika Anda menemukan selector Anda secara konsisten lebih menyukai jawaban yang cepat tapi salah, Anda bisa menaikkan correctness weight tanpa menulis ulang arsitektur.

def score_generated_code(output: str) -> float:
    if not compiles(output):
        return -1.0  # Hard filter

    tests_passed = run_test_suite(output)
    execution_time = benchmark(output)
    line_count = len(output.splitlines())

    # Weighted sum on survivors only
    return (
        0.6 * tests_passed
        + 0.3 * (1.0 / (1.0 + execution_time))
        + 0.1 * (1.0 / (1.0 + line_count))
    )

Bobot di atas adalah arbitrer. Tune mereka terhadap held-out validation set, dan perbarui ketika kebutuhan Anda berubah. Jangan mengubur mereka di dalam class di mana mereka menjadi tidak terlihat.

Ketika ini runtuh: correlated failures dan runtime cost

N-version selection tidak gratis. Menjalankan lima varian berarti lima kali compute, lima kali latency, dan lima kali beban maintenance. Jika implementasi Anda adalah panggilan LLM, biaya tersebut diukur dalam dolar dan detik. Jika mereka adalah microservice, diukur dalam queue depth dan thread count.

Risiko yang lebih besar adalah correlated failure. Lima implementasi tidak memberi Anda lima draw independen dari distribusi bug. Mereka berbagi bahasa, library, training data, dan penulis manusia. Jika kelima implementasi menggunakan regular expression yang sama untuk parse date, mereka semua akan rusak pada input malformed yang sama. Diversity sulit untuk ditegakkan. Ini memerlukan auditing aktif untuk shared dependencies dan copied logic.

Ada juga pertanyaan apa yang harus dilakukan ketika selector itu sendiri salah. Jika scoring function Anda memiliki blind spot, Anda akan secara konsisten memilih output yang buruk dan tidak pernah menyadarinya. Monitor selection distribution. Jika satu varian tidak pernah dipilih, ia entah useless atau score function Anda bias. Bagaimanapun, Anda harus menyelidikinya.

Menyatukannya: selector yang bisa Anda ship hari ini

Anda tidak memerlukan distributed system untuk memulai. Bungkus fungsi yang ada Anda dalam sebuah selector, tambahkan satu implementasi alternatif, dan definisikan satu scoring criterion yang penting bagi Anda. Jalankan mereka secara paralel. Bandingkan output-nya. Log score-nya.

Setelah seminggu logging, tinjau kasus-kasus di mana varian tidak setuju. Ketidaksetujuan tersebut adalah sebuah hadiah. Ini memberi tahu Anda di mana spesifikasi Anda ambigu, di mana score function Anda salah, atau di mana satu implementasi memiliki bug yang tidak dimiliki yang lain.

Scale up secara bertahap. Dua varian dengan selector yang baik mengalahkan lima varian dengan majority vote.

FAQ: correlated failures, stateful systems, dan runtime overhead

Bagaimana jika kelima implementasi mengembalikan hasil yang berbeda?

Ini adalah kasus yang diharapkan untuk masalah non-trivial. Gunakan scoring function untuk meranking output alih-alih mencari exact match. Jika Anda tidak bisa mendefinisikan score function yang baik, masalahnya bukan selector. Masalahnya adalah Anda belum tahu apa artinya “benar” untuk domain Anda.

Bagaimana cara mencegah correlated failures antar varian?

Audit untuk shared dependencies, copied code, dan training data yang sama. Paksa setidaknya satu varian untuk menggunakan bahasa atau framework yang berbeda. Tujuannya adalah uncorrelated error distributions, yang lebih sulit daripada yang terdengar.

Apakah N-version programming berfungsi untuk stateful systems?

Ini berfungsi buruk untuk sistem dengan side effect. Jika fungsi Anda menulis ke database atau men-charge kartu kredit, menjalankannya lima kali adalah destruktif. N-version selection paling baik untuk pure functions, read-only queries, atau operasi di mana Anda dapat men-stage side effect setelah selection.

Berapa banyak overhead yang ditambahkan oleh menjalankan lima varian?

Latency dibatasi oleh varian terlambat kecuali Anda menjalankan mereka secara paralel. CPU dan memory scale secara linear. Mulai dengan dua varian dan ukur sebelum berkomitmen pada lima. Biaya operasional seringkali lebih penting daripada selection logic.