Day 52 Hard Chaos Engineering Correctness Oracle Differential Testing Resilience

Adding a Correctness Oracle to Chaos EngineeringUnder a Fault, "Still Alive" ≠ "Got the Answer Right"

Problem Scenario + Requirements

You have a real-time GMV aggregation API: 1B order rows across 256 shards, must return "a merchant's 30-day sales" within p99 < 200ms at 50k QPS. You already do Day 44 chaos engineering — daily injection of shard timeouts, replica lag, packet loss, verifying the system "survives".

But there's a hole: one drill injected "shard-137 timeout" and the service didn't crash — error rate 0, p99 even faster — because it silently skipped that shard and returned the 255/256 partial sum as if complete. The merchant's GMV was 0.4% short, with zero alerts. This is gray failure's differential observability (Microsoft, HotOS 2017): the failure detector saw nothing while the application was being harmed.

Core thesis: traditional fault injection only ships an availability oracle (still returning 2xx? error rate < X?), not a correctness oracle (is the returned value right under the fault?). "Silently wrong result" is more dangerous than "hard crash" — a crash alerts; a wrong answer nobody notices. This issue adds that missing eye.

Constraints: the oracle must run online; must tolerate legitimate degradation (partial results allowed by design); and must not itself over-fire, or it repeats Day 51 — under a low base rate, false positives drown the real ones.

High-Level Architecture (generator → dual-path → normalize → diff → three-state verdict)

graph TD
    GEN["Scenario generator
AI/rules produce faults"] --> INJ["Fault injector
FIT · shard timeout/replica lag"] Q["Same request
+ frozen seed"] --> REF["Reference path
no fault · independent impl"] Q --> EXP["Experiment path
fault injected"] INJ -.applies.-> EXP REF --> NR["Normalize
sort/quantize/mask nondet"] EXP --> NE["Normalize"] NR --> CMP{"Differential compare
checksum"} NE --> CMP CMP -->|match| PASS["✅ PASS"] CMP -->|bounded diff| DEG["🟡 Acceptable degradation
tag + account"] CMP -->|out-of-bounds| BUG["🔴 Correctness bug
→ auto-abort + archive"] classDef gen fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef path fill:#0e2030,stroke:#5eead4,color:#e8eef5 classDef norm fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef bad fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class GEN,INJ gen class REF,EXP path class NR,NE,CMP norm class BUG bad

Key: the reference path must be fault-free and ideally an independent implementation to serve as ground truth; diffs are normalized before judging, to avoid nondeterminism spam

Four components, each with one job: the generator produces fault scenarios (AI sits only here); the injector hits only the experiment path; the reference path supplies "what should have been returned"; normalize + diff turn a "silently wrong result" into a diffable signal landing on a three-state verdict, not two.

Key Techniques

1. Differential oracle: build ground truth from a "reference run", not hardcoded expectations

Principle: the hardest part of a correctness oracle is "where does the right answer come from". A hardcoded golden file only covers fixed inputs; 5w-QPS real traffic can't be enumerated. Differential testing (McKeeman 1998) offers another route: feed the same input to two supposedly-equivalent executions and compare. In chaos, the "two executions" = fault-free reference path vs fault-injected experiment path; if they disagree after normalization, the fault caused a wrong output. Ground truth is "run out", covering the long tail as traffic does.

Trade-off (three oracle kinds):

In practice, combine: differential catches "value is wrong", invariants backstop the common-mode case where the reference path itself is wrong.

# Differential oracle skeleton (Python, pseudo-code)
def differential_check(req, fault):
    seed = freeze_seed(req)                 # freeze time/random so paths compare
    ref = run_reference(req, seed)          # reference: no fault, independent impl
    exp = run_with_fault(req, seed, fault)  # experiment: fault injected
    nr, ne = normalize(ref), normalize(exp)
    if checksum(nr) == checksum(ne):
        return "PASS"
    return classify(nr, ne, fault)          # → three-state verdict (technique 3)
Real cases:

2. Normalization layer: skip it and false positives will drown you

Principle: both paths' outputs carry inherent nondeterminism: different pagination order, different float-summation order (under IEEE 754 a+b+c ≠ c+b+a), now()/auto-increment IDs/random tie-breaks, unstable map iteration. A raw checksum never matches and the oracle becomes a noise source. Normalization erases "irrelevant differences" and keeps only "semantic differences": total ordering (sort by a stable key), float quantization (round to business precision, e.g. money to cents), masking nondeterministic functions (time/random/hostname → placeholder).

Trade-off (normalization strength):
# Normalize: erase "irrelevant diffs", keep semantics (pseudo-code)
def normalize(rows):
    out = []
    for r in rows:
        r = mask_nondeterministic(r)   # now()/rand/host/auto_id → placeholder
        r = {k: quantize(v, PRECISION[k]) for k, v in r.items()}  # money→cents
        out.append(r)
    out.sort(key=lambda r: r[STABLE_KEY])   # total order: kill pagination/concurrency reordering
    return out
Real cases:

3. Three-state verdict: under a fault, "the result changed" is often legitimate

Principle: a two-state (pass/fail) verdict necessarily over-fires in chaos, because many degradations are by design. Returning "255/256 partial results + a partial=true tag" after a shard timeout is correct; reading a 1-second-old value due to replica lag is legal under eventual consistency. So the verdict is three-state: PASS (identical after normalization), acceptable degradation (diff within a pre-declared degradation contract — tagged partial, or gap < the declared staleness window), correctness bug (out of bounds, or claims complete while missing data). Key distinction: missing data without declaring it = bug; missing data while honestly declaring partial = acceptable.

Trade-off: why not two-state? Two-state reports legitimate degradation as failure, FP rate spikes, and in a low-base-rate regime (Day 51) it drowns the real reports; engineers then bulk-silence, muting real bugs too. Three-state files "degradation" in its own bucket — accounted but not paged — keeping the paging precision floor intact. The cost is writing an explicit degradation contract — but you should have one anyway (it is your SLA semantics).

def classify(ref, exp, fault):
    if exp.claims_complete and exp.rows < ref.rows:
        return "CORRECTNESS_BUG"          # 🔴 claims complete but missing data → silent wrong
    if exp.partial and within_degradation_contract(exp, fault):
        return "ACCEPTABLE_DEGRADATION"   # 🟡 honestly declared + inside contract
    if numeric_gap(ref, exp) <= staleness_budget(fault):
        return "ACCEPTABLE_DEGRADATION"   # 🟡 gap allowed by eventual consistency
    return "CORRECTNESS_BUG"              # 🔴 out of bounds
Real cases:

4. Independent reference path + AI only in the generator seat

Principle: the differential oracle's Achilles heel is correlated failure — if the reference and experiment paths share the same buggy code, both compute wrong together, the checksum still matches, and the oracle hands you a false PASS. Defense: run the reference on an as-independent-as-possible implementation or path (different code, no fault, even an offline batch recompute) so failure modes are uncorrelated. This mirrors Day 51's "independent oracle backstop vs self-appointed judge": a shared blind spot degrades "multiply to cut FP" into "no cut". Corollary: let AI/LLM sit only in the generator seat — producing devious fault scenarios and injection-point permutations — but never judge. The judge must be a deterministic diff + contract check, else you've placed a component that itself "silently errs" inside the correctness loop.

Trade-off (choosing the reference path):
Real cases:

Scaling & Optimization

Common Pitfalls + Interview Follow-ups

1. Verifying availability and thinking you verified correctness. "Error rate 0 + normal p99" is fully compatible with "silently wrong result". Follow-up: after injecting a shard timeout the service returned faster — good news or danger sign? (Answer: likely skipped the shard and returned partial — danger sign.)
2. Diffing without normalizing. Float-sum order, pagination order, now() make the oracle fire every time; it gets turned off within a week. Follow-up: both paths are "right", why do checksums differ?
3. Two-state verdict. Reports legitimate degradation as failure; FPs drown the real ones (see Day 51). Follow-up: is a partial result a bug? (Answer: honestly declared = 🟡, falsely claiming complete = 🔴.)
4. Reference and experiment paths share buggy code. Correlated failure → false PASS. Follow-up: both paths call the same wrong aggregation function — will the oracle catch it? (No.)
5. Letting an LLM be the correctness judge. The judge itself silently errs — an unreliable oracle inside the correctness loop (see Day 44/51). AI should produce scenarios, not adjudicate.

Deeper Resources

Deeper Reflection (click to expand)

1. If the reference path itself occasionally errs (e.g. it depends on the same downstream), the differential oracle gives a false PASS. Besides "use an independent impl", what low-cost means reduce this common-mode risk?

The core is make the two paths' failures uncorrelated, or make common-mode errors catchable by a third signal:

  • Layer on an invariant oracle: even if both paths agree, independently check "sum ≥ sum of non-negative parts", "row count = distinct-key cardinality", "money conserved" — common-mode errors often violate one.
  • Version stagger: run the reference on the last stable version's code or last snapshot, so a bug the current version introduces can't taint both paths at once.
  • Multi-reference voting: costly but strong — three impls take a majority; common-mode now needs "the majority wrong at once" to slip (the Day 51 multiplication idea).
  • Known-answer probes: periodically feed synthetic queries with known results; if even those compute wrong, the shared pipeline is broken.

Cost order: invariants < version stagger < probes < multi-reference. In production, "invariants + probes" as a baseline; multi-reference only on critical paths.

2. How do you set the "float quantization precision" in normalization? Too coarse → false negatives, too fine → false positives. Is there a principled choice rather than a guess?

Principle: quantization precision = the smallest semantic unit the business tolerates, not machine float precision.

  • Money: quantize to cents. Anything finer is summation-order noise, erase it; a gap still visible at cents is a real error.
  • First measure the no-fault baseline jitter: run a batch of dual paths with no fault, look at the distribution of residual diffs after normalization, and set the grain so "baseline jitter lands in the same bucket" — turning "how fine is noise" from a guess into a measurement.
  • Layered quantization: different precision per field (money to cents, ratios to 0.1%, counts exact); one global precision is necessarily too coarse or too fine for some fields.

Anti-pattern: a single global "fuzzy threshold" for approximate comparison — that just hides the FP/FN knob inside a magic number.

3. A differential oracle feeds "the same input to both paths", but in a live-write system the two paths may read different underlying data (concurrent writes, replica lag). How do you define "the same input" then?

The catch: availability drills can just "fire the same request twice", but correctness drills require both paths to see the same world state, else the diff comes from the data, not the fault. Means:

  • Snapshot/MVCC anchor: both paths read the same snapshot/read timestamp (Postgres snapshot, TiDB tso), freezing the underlying data into a comparable constant — cleanest.
  • Only do online diff for replayable reads: push the write side into a "record + offline recompute" reconciliation diff, not forcing a live dual path.
  • Model lag into the contract: accept the two paths may differ by a staleness window, fold it into the 🟡 verdict (staleness_budget), rather than pretend perfect alignment.

Essence: a correctness oracle presupposes reproducible input; when you can't get snapshot semantics, explicitly demote the non-reproducible part to "acceptable difference" rather than let it masquerade as a bug or be silenced.

4. After shipping the correctness oracle, the team finds "acceptable degradation" accounts for 95% of alerts. Good or bad? How should you use this number?

It's a signal, to be broken down:

  • If most 🟡 is "the same degradation recurring": some fault mode frequently degrades the system — availability fine, but users continuously get incomplete results. That's a product-quality issue; fix it or tighten the contract, don't lull yourself by keeping it "acceptable" forever.
  • If 🟡 is scattered and all in-contract: degradation matches design, the oracle works. Move 🟡 from the paging channel to an accounting/trend channel and page only on 🔴 — holding the precision floor (Day 51).
  • Dangerous reading: loosen the verdict because 95% is 🟡, folding some 🔴 into 🟡 — hand-crafting a blind spot.

Right use: 🟡 is a trend metric (degradation rate, like error-budget burn), 🔴 is what pages. Rising 🟡 is a leading signal to "go pay down debt", not a reason to "tune the threshold to shut it up".

5. Traditional chaos's steady-state hypothesis is "business metrics (e.g. orders/sec) show no significant difference between experiment and control". After adding a correctness oracle, how should the hypothesis extend? Do they conflict?

Extension: the original hypothesis is aggregate, statistical — "did the throughput/success curve collapse". The correctness oracle adds per-request, semantic — "is the returned value right". Two-part new hypothesis: business metrics no significant difference; 🔴 rate ≈ 0, 🟡 rate within contract.

They conflict, and valuably so: the classic trap is the curve is perfectly normal (① passes), yet the 🔴 rate spikes (② fails) — gray failure: skipping a shard returns faster, the latency curve looks better, but the result is wrong. With only ①, you'd rule the experiment "pass" and ship a silent bug.

The two aren't redundant but orthogonal: ① catches "collapsed", ② catches "quietly wrong". True steady state = neither collapsed nor wrong — which is exactly why an availability oracle can never replace a correctness oracle.