3. Fallback Trigger Logic Based on OOD Score Thresholds

Why a Threshold Alone Is Not a Trigger

An OOD score is a continuous quantity; a fallback trigger is a discrete decision. The gap between them is where most production failures hide. In the LS-DYNA context, the detector from the baseline case produces a scalar anomaly score s(x) for each simulation run — typically a Mahalanobis distance over features such as peak kinetic energy, contact force integral, and nodal displacement statistics. The trigger must convert that score into one of two solver configurations: the nominal configuration (the tuned, high-fidelity setup) or the conservative configuration (a robust, lower-risk setup).

A naive comparison s(x) > τ is insufficient for three reasons. First, a single threshold produces chatter: a run whose score oscillates around τ across successive restart segments can flip configurations repeatedly, which is both expensive and non-deterministic. Second, the score distribution is asymmetric — nominal runs cluster tightly, while off-nominal runs form a long tail — so a symmetric threshold treats the two error types (false fallback and missed detection) as equally costly when they are not. Third, the trigger must be auditable: when a fallback fires, an engineer needs to know which score, which threshold, and which rule caused it.

Trigger Architecture: Score → Decision → Configuration

The design below separates scoring from decision-making so that the threshold logic can be recalibrated (case 4) without touching the detector (case 1) or the feature extractor (case 2). The trigger consumes a score stream, applies hysteresis and a confirmation window, and emits a configuration selection plus a reason code.

OOD Score Stream s(x) per run / restart segment Hysteresis Band τ_high = 3.20 (enter fallback) τ_low = 2.60 (exit fallback) dead band prevents chatter Confirmation Window N = 3 consecutive segments above τ_high counter ≥ N → fire Fallback Active? Nominal *CONTROL_ ENERGY dt=1.0 Conservative dt=0.6, implicit contact penalty ↑ score banded confirmed no yes Emitted reason code (audit trail) {"run_id":"R-0417","score":3.41,"tau_high":3.20,"tau_low":2.60, "consecutive":3,"decision":"FALLBACK","config":"conservative_v2"} Every trigger event is logged with the exact score and thresholds that caused it.

Implementing the Trigger

The reference implementation below is a self-contained Python module. It defines the trigger state machine, the hysteresis band, and the confirmation counter, and it emits a structured decision record. The thresholds TAU_HIGH = 3.20 and TAU_LOW = 2.60 are illustrative example values chosen to sit above a nominal cluster centered near 1.8; they are not calibrated results and must be replaced by the calibration procedure in case 4.

from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
import json

class Config(Enum):
    NOMINAL = "nominal"
    CONSERVATIVE = "conservative_v2"

@dataclass
class TriggerDecision:
    run_id: str
    score: float
    tau_high: float
    tau_low: float
    consecutive: int
    decision: str
    config: str
    reason: str

class FallbackTrigger:
    """Hysteresis + confirmation-window trigger for LS-DYNA fallback."""

    def __init__(self, tau_high: float = 3.20, tau_low: float = 2.60,
                 confirm_n: int = 3):
        if tau_low >= tau_high:
            raise ValueError("tau_low must be strictly below tau_high")
        self.tau_high = tau_high
        self.tau_low = tau_low
        self.confirm_n = confirm_n
        self._active = False          # current fallback state
        self._above_count = 0         # consecutive segments above tau_high
        self._below_count = 0         # consecutive segments below tau_low

    def evaluate(self, run_id: str, score: float) -> TriggerDecision:
        reason = "within_band"
        if not self._active:
            if score > self.tau_high:
                self._above_count += 1
                reason = f"above_tau_high({self._above_count}/{self.confirm_n})"
                if self._above_count >= self.confirm_n:
                    self._active = True
                    self._above_count = 0
                    reason = "confirmed_fallback"
            else:
                self._above_count = 0
        else:
            if score < self.tau_low:
                self._below_count += 1
                reason = f"below_tau_low({self._below_count}/{self.confirm_n})"
                if self._below_count >= self.confirm_n:
                    self._active = False
                    self._below_count = 0
                    reason = "confirmed_recovery"
            else:
                self._below_count = 0

        cfg = Config.CONSERVATIVE if self._active else Config.NOMINAL
        return TriggerDecision(
            run_id=run_id, score=round(score, 3),
            tau_high=self.tau_high, tau_low=self.tau_low,
            consecutive=self._above_count if not self._active else self._below_count,
            decision="FALLBACK" if self._active else "NOMINAL",
            config=cfg.value, reason=reason,
        )

Two design choices deserve emphasis. The dead band between tau_low and tau_high means a score that drifts back and forth across a single threshold cannot toggle the configuration; it must cross the opposite boundary and stay there for confirm_n segments. The confirmation window means a single spurious spike — for example, a transient contact-force artifact in one restart segment — cannot fire a fallback on its own.

Exercising the Trigger with a Score Sequence

The following driver feeds a synthetic score sequence through the trigger. The sequence is constructed to demonstrate the three behaviors that matter: a spike that is rejected, a sustained excursion that fires, and a recovery that requires confirmation.

trigger = FallbackTrigger(tau_high=3.20, tau_low=2.60, confirm_n=3)

# (run_id, score) — synthetic sequence for demonstration only
sequence = [
    ("R-0401", 1.72), ("R-0402", 1.81), ("R-0403", 3.55),  # isolated spike
    ("R-0404", 1.90), ("R-0405", 1.77),                     # spike rejected
    ("R-0406", 3.41), ("R-0407", 3.62), ("R-0408", 3.28),  # sustained excursion
    ("R-0409", 3.05), ("R-0410", 2.71),                     # inside dead band
    ("R-0411", 2.44), ("R-0412", 2.31), ("R-0413", 2.18),  # recovery confirmed
]

for run_id, score in sequence:
    d = trigger.evaluate(run_id, score)
    print(json.dumps(d.__dict__))

Expected observable behavior (derived directly from the logic above, not from a live run):

  • R-0403 reports above_tau_high(1/3) and stays NOMINAL; R-0404 resets the counter to zero. The isolated spike is correctly rejected.
  • R-0406 through R-0408 increment the counter to 3; R-0408 emits confirmed_fallback and selects conservative_v2.
  • R-0409 and R-0410 fall inside the dead band and do not start the recovery counter — the trigger remains in fallback.
  • R-0411 through R-0413 accumulate three consecutive scores below tau_low; R-0413 emits confirmed_recovery and returns to nominal.
The confirmation counter is reset on any segment that does not satisfy the current condition. This is deliberate: a "3 out of 5" rule would be more permissive but harder to audit, because the reason code would no longer map to a contiguous window.

Mapping the Decision to an LS-DYNA Configuration

The trigger's output is a configuration selector, not a solver command. The mapping below shows the concrete keyword-level differences between the two configurations. The conservative configuration trades runtime for robustness: a smaller explicit time step, tighter contact penalty scaling, and a switch to implicit for the final stage.

Keyword / ParameterNominalConservative (fallback)Rationale
*CONTROL_TERMINATION DTMIN1.0e-61.0e-7Prevents premature termination on distorted elements
*CONTROL_TIMESTEP TSSFAC0.900.60Smaller scale factor improves stability margin
*CONTACT PENALTY scale1.01.5Reduces penetration in off-nominal contact
*CONTROL_IMPLICIT_GENERALoffon (final stage)Implicit integration for stiff final response
*DATABASE_BINARY_D3PLOT DT1.0e-35.0e-4Finer output for post-fallback diagnosis
Do not hard-code the conservative configuration inside the trigger. Keep the trigger as a pure decision function and resolve the configuration through a lookup keyed by config. This lets you add a third tier (e.g., conservative_v3) or roll back a configuration without re-validating the trigger logic.

Verification and Failure Symptoms

Verify the trigger with three targeted checks before wiring it into a production pipeline. First, a chatter test: feed an alternating sequence [3.3, 2.5, 3.3, 2.5, ...] and confirm the configuration never changes, because neither boundary is crossed confirm_n times consecutively. Second, a boundary test: feed exactly tau_high and confirm the comparison is strict (>), so a score equal to the threshold does not fire. Third, a recovery test: after a confirmed fallback, feed two scores below tau_low followed by one above, and confirm the recovery counter resets.

Common failure symptoms and their causes: if the trigger fires on every run, tau_high is below the nominal cluster mean — recalibrate. If it never fires on known off-nominal runs, confirm_n is too large relative to the number of restart segments available. If the configuration oscillates despite the dead band, the score stream is being evaluated per-timestep rather than per-segment; the trigger assumes one score per run or restart segment, and feeding it finer-grained scores will defeat the confirmation window.

The trigger is correct when its decision record alone is sufficient to reconstruct why a given run used the conservative configuration — score, both thresholds, consecutive count, and reason code. If an engineer cannot answer "why did this run fall back?" from the log, the trigger is not yet auditable.