Files
heygen-com--hyperframes/packages/studio/src/utils/sdkResolverShadow.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

547 lines
24 KiB
TypeScript

/**
* SDK resolver-parity tripwire (telemetry-only).
*
* Checks whether the SDK session resolves the same element id the server
* patch path would target, then optionally verifies value parity after an
* in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence.
*
* Headline signal: `element_not_found` — the resolver divergence class that
* caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see
* this class; this tripwire exists specifically to catch it.
*
* Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag
* `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect
* wild telemetry; flip off / remove once resolver parity is proven).
* Telemetry-only — never writes to disk, never affects the user-visible edit.
*/
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SdkResolverMismatch {
kind:
| "element_not_found"
| "value_mismatch"
| "dispatch_error"
| "animation_not_found"
| "session_empty";
hfId?: string;
animationId?: string;
property?: string;
expected?: string | null;
actual?: string | null | undefined;
error?: string;
}
// ─── Op helpers ───────────────────────────────────────────────────────────────
// Drop studio-internal data-hf-* markers the SDK model doesn't represent.
function isShadowableOp(op: PatchOperation): boolean {
const name =
op.type === "attribute"
? op.property.startsWith("data-")
? op.property
: `data-${op.property}`
: op.type === "html-attribute"
? op.property
: null;
return name === null || !name.startsWith("data-hf-");
}
const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]);
// ─── Read-back helpers ────────────────────────────────────────────────────────
function kebabToCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
function normalizeText(v: string | null | undefined): string | null {
if (v == null) return null;
const t = v.trim();
return t === "" ? null : t;
}
type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
type AttrMap = Record<string, string | null>;
/**
* Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
* (engine/model.ts resolveScoped), NOT via Composition.getElement.
*
* getElement is canonical-only for a bare id by design — it deliberately will
* not resolve a bare id to a non-canonical (sub-composition) element, so that
* removeElement(bareId) and getElement(bareId) agree on the same instance
* (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
* path dispatches the studio's bare data-hf-id, and dispatch resolves it via
* resolveScoped, which locates the leaf anywhere (canonical preferred, else
* first match). So getElement under-resolves a bare leaf that lives inside an
* inlined sub-composition (scopedId "host/leaf") — exactly the false
* `element_not_found` this tripwire was emitting for inlined compositions.
*
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
* match, then first bare match — the resolvability dispatch actually has.
*/
// Count static `data-hf-id="<id>"` occurrences (both quote styles) in source.
// Substring split, not regex — no escaping, and the id never contains a quote.
function countHfIdInSource(source: string, id: string): number {
return (
source.split(`data-hf-id="${id}"`).length - 1 + (source.split(`data-hf-id='${id}'`).length - 1)
);
}
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
const els = session.getElements();
const exact = els.find((el) => el.scopedId === id);
if (exact) return exact;
const matches = els.filter((el) => el.id === id);
return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
}
function checkStyleOp(
op: PatchOperation,
el: FlatEl,
): { expected: string | null; actual: string | null } {
return {
expected: op.value ?? null,
actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null,
};
}
function checkTextOp(
op: PatchOperation,
el: FlatEl,
): { expected: string | null; actual: string | null } {
return { expected: normalizeText(op.value), actual: normalizeText(el.text) };
}
function checkAttrOp(
op: PatchOperation,
el: FlatEl,
): { property: string; expected: string | null; actual: string | null } {
const property =
op.type === "attribute"
? op.property.startsWith("data-")
? op.property
: `data-${op.property}`
: op.property;
return {
property,
expected: op.value ?? null,
actual: (el.attributes as AttrMap)[property] ?? null,
};
}
function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null {
let property: string;
let expected: string | null;
let actual: string | null;
if (op.type === "inline-style") {
property = op.property;
({ expected, actual } = checkStyleOp(op, el));
} else if (op.type === "text-content") {
property = "text";
({ expected, actual } = checkTextOp(op, el));
} else if (op.type === "attribute" || op.type === "html-attribute") {
({ property, expected, actual } = checkAttrOp(op, el));
} else {
return null;
}
if (actual === expected) return null;
return { kind: "value_mismatch", hfId, property, expected, actual };
}
// ─── Core check (pure — testable without flag) ────────────────────────────────
/**
* Run the resolver shadow check against an already-open SDK session.
*
* Returns an array of mismatches (empty = parity). The value-parity check
* dispatches the ops into the session to read the result back, then UNDOES
* those mutations via the captured inverse patches before returning — the
* session ends exactly as it started. This is essential: the session is shared
* with the cutover path, and a residual shadow mutation would make the
* subsequent sdkCutoverPersist see before === after and silently fall back to
* the server path. Telemetry-only; the server path stays authoritative on disk.
*
* Exported for unit tests; call `runResolverShadow` at call sites.
*/
export function sdkResolverShadowCheck(
session: Composition,
hfId: string,
ops: PatchOperation[],
sourceContent?: string,
): SdkResolverMismatch[] {
if (!resolveSnapshot(session, hfId)) {
// Runtime-node filter: an hf-id absent from the on-disk source the SDK
// parsed was never in the static DOM — it belongs to an element a
// composition <script> creates at runtime (e.g. caption word/group spans),
// which the SDK session cannot model by design. That is NOT a resolver bug,
// so suppress it. An hf-id PRESENT in source but missing from the session IS
// a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
// ponytail: substring match; biases toward keeping signal on a loose hit.
if (sourceContent !== undefined && !sourceContent.includes(hfId)) return [];
// Loose match here vs. countHfIdInSource's strict data-hf-id="..." match in the
// caller (runResolverShadow) means an emitted event can carry sourceHfIdCount: 0 —
// see the comment on that field in runResolverShadow for what 0 means in that case.
return [{ kind: "element_not_found", hfId }];
}
const shadowable = ops.filter(isShadowableOp);
if (shadowable.length === 0) return [];
// Silently skip op batches containing unmapped types — not a resolver bug.
if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return [];
// Capture the inverse of the shadow dispatch so we can restore the session.
// `batch` fires a single PatchEvent whose `inversePatches` are already in
// reverse-apply order (session.ts reverses inside buildPatchEvent), so
// applyPatches(inverse) undoes the dispatch with no further reordering. If a
// future SDK refactor ever coalesces batch into a composite with no per-op
// inverse, this restore breaks — keep batch emitting inverse patches.
const inverse: JsonPatchOp[] = [];
const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches));
// restore() runs in `finally` so the patch listener is always removed and the
// session is always undone — even if checkOpValue throws between dispatch and
// return. A residual mutation or leaked listener on the shared session is the
// exact cutover-coupling failure mode this module exists to avoid.
try {
try {
const editOps = patchOpsToSdkEditOps(hfId, shadowable);
session.batch(() => {
for (const op of editOps) session.dispatch(op);
});
} catch (err) {
return [{ kind: "dispatch_error", hfId, error: String(err) }];
}
const el = resolveSnapshot(session, hfId);
if (!el) return [{ kind: "element_not_found", hfId }];
return shadowable
.map((op) => checkOpValue(op, el, hfId))
.filter((m): m is SdkResolverMismatch => m !== null);
} finally {
stopCapture();
if (inverse.length > 0) session.applyPatches(inverse);
}
}
// ─── Attempt counter (denominator for the soak gate) ──────────────────────────
//
// The three emit functions below only fire a PostHog event on divergence —
// parity is silent, by design, to avoid firing on every edit. That leaves no
// way to compute a rate (divergences / attempts): we can count failures but
// never attempts. This counter tracks attempts in memory and rolls them up
// into ONE low-frequency event instead of firing per-attempt, which would
// recreate the exact chattiness problem the divergence-only design avoids.
const attemptCounts: Record<string, number> = {};
/**
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
* outcome (parity or divergence). No flag check of its own — only ever called
* from inside the three emit functions below, after their own
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
*/
export function recordAttempt(opLabel: string): void {
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
ensureAttemptFlushScheduled();
}
/**
* Return the accumulated attempt counts since the last flush (or `null` if
* nothing has been recorded — no point emitting an empty rollup), and reset
* the counter to empty.
*/
export function flushAttemptCounts(): Record<string, number> | null {
const keys = Object.keys(attemptCounts);
if (keys.length === 0) return null;
const snapshot: Record<string, number> = {};
for (const key of keys) {
snapshot[key] = attemptCounts[key];
delete attemptCounts[key];
}
return snapshot;
}
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
let attemptVisibilityHandler: (() => void) | null = null;
function flushAndEmitAttempts(): void {
const counts = flushAttemptCounts();
if (counts === null) return;
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
}
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
// start, so a session that never exercises the tripwire never runs a
// background timer.
function ensureAttemptFlushScheduled(): void {
if (!attemptFlushTimer) {
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
}
if (!attemptVisibilityHandler && typeof document !== "undefined") {
attemptVisibilityHandler = () => {
if (document.visibilityState !== "hidden") return;
flushAndEmitAttempts();
// studioTelemetry.ts registers its own visibilitychange listener (on
// window, at module load) that drains its queue via sendBeacon. Listener
// execution order between that handler and this one (on document,
// registered lazily) is not something to rely on — whichever runs
// first could otherwise beacon-flush before or after this rollup lands
// in the queue. Forcing a beacon flush here makes delivery of this
// rollup event correct regardless of that order.
flushViaBeacon();
};
document.addEventListener("visibilitychange", attemptVisibilityHandler);
}
}
/**
* Test-only: clears the lazy timer/listener singleton state so tests can
* verify the "starts on first attempt" behavior in isolation, without an
* earlier test's real-timer interval (or visibilitychange listener) silently
* surviving into a later test. Does NOT touch attemptCounts — only the
* scheduling state. Not part of the public module contract; only imported
* from sdkResolverShadow.test.ts.
*/
export function __resetAttemptSchedulingForTests(): void {
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
attemptFlushTimer = null;
if (attemptVisibilityHandler && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
}
attemptVisibilityHandler = null;
}
// ─── Telemetry ────────────────────────────────────────────────────────────────
// Redact all user-content values before telemetry: style values and text both
// carry user data. Keep only the length so we can detect truncation without
// leaking the actual bytes.
function redactValue(value: string | null | undefined): string | null | undefined {
if (value == null) return value;
return `[redacted len=${value.length}]`;
}
function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] {
return mismatches.map((m) => ({
...m,
expected: redactValue(m.expected),
actual: redactValue(m.actual),
}));
}
/**
* Run the resolver shadow and emit `sdk_resolver_shadow` telemetry.
* No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false.
* Never throws — any exception inside the shadow is swallowed.
*
* Side-effect-free on the live session: sdkResolverShadowCheck dispatches into
* the session to read values back, then undoes those mutations before returning
* (see below). The session is shared with the cutover path, so it MUST end the
* call exactly as it started.
*/
// Sessions whose empty-session modeling gap has already been reported — one
// event per session instance, not one per edit (the per-edit storm is noise;
// the EXISTENCE of the gap is the signal).
const emptySessionReported = new WeakSet<Composition>();
/**
* An empty session structurally cannot resolve ANY id — a modeling gap (empty
* file, comp shape the SDK can't parse into elements), not a resolver
* divergence, and it can't cut over either, so it stays out of the attempt
* denominator. But silence would blind the tripwire to exactly the class that
* exposed the template-comp bug — so emit ONE distinguishable `session_empty`
* event per session, then skip. Returns true when the caller should skip.
*/
function reportEmptySession(session: Composition, opLabel: string): boolean {
if (session.getElements().length !== 0) return false;
if (!emptySessionReported.has(session)) {
emptySessionReported.add(session);
trackStudioEvent("sdk_resolver_shadow", {
opLabel,
sessionEmpty: true,
sessionElementCount: 0,
mismatchCount: 1,
mismatches: JSON.stringify([{ kind: "session_empty" } satisfies SdkResolverMismatch]),
});
}
return true;
}
export function runResolverShadow(
session: Composition,
hfId: string | null | undefined,
ops: PatchOperation[],
sourceContent?: string,
): void {
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!hfId) return;
try {
if (reportEmptySession(session, "dom-edit")) return;
recordAttempt("dom-edit");
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
// Emit only on divergence — parity is silent, matching recordResolverParity
// and recordAnimationResolverParity. Otherwise this fires a PostHog event on
// every style/text/attr edit (the editor's chattiest path) at default-ON.
if (mismatches.length === 0) return;
const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
const strictCount =
isElementNotFound && sourceContent !== undefined
? countHfIdInSource(sourceContent, hfId)
: undefined;
trackStudioEvent("sdk_resolver_shadow", {
hfId,
// sessionElementCount > 0 + element_not_found = runtime-only element;
// sessionElementCount === 0 = session is empty/broken (actionable).
sessionElementCount: session.getElements().length,
// Count of data-hf-id="<id>" occurrences in source for an emitted
// element_not_found. >1 = duplicate ids → resolver picked the wrong
// instance; =1 = single static node the SDK parse dropped (foreign-content
// exclusion / sub-comp inlining gap); =0 = the runtime-node filter above
// uses a loose substring match (biased toward keeping signal) while this
// count uses a strict attribute match — see sourceLooseMatchOnly below.
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
// Loose suppression check matched (kept this event) but the strict
// attribute count came back 0 — see the sourceHfIdCount comment above.
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
mismatchCount: mismatches.length,
mismatches: JSON.stringify(redactMismatches(mismatches)),
});
} catch {
// never propagate from the shadow path
}
}
/**
* Record element-resolution parity for an element-targeted op WITHOUT
* dispatching. Read-only: emits a single `element_not_found` event when the SDK
* can't resolve a target the server path is addressing. This extends the
* tripwire beyond the DOM-edit path (runResolverShadow) to the other
* element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for
* the headline resolver signal, without the cost/mutation of a value check.
*
* No-op when the shadow flag is off; never throws; never mutates the session.
*/
export async function recordResolverParity(
session: Composition | null | undefined,
hfId: string | null | undefined,
opLabel: string,
readSource?: () => Promise<string | undefined>,
): Promise<void> {
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!session || !hfId) return;
try {
if (reportEmptySession(session, opLabel)) return;
recordAttempt(opLabel);
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
// and the caller runs its own session mutation synchronously right after this call
// returns. getElements() caches and that cache is invalidated on dispatch, so reading
// the count after an await would silently reflect POST-edit state, not the pre-edit
// state this field exists to diagnose.
const sessionElementCount = session.getElements().length;
// Cheap check passed above, so the source read only runs on a real divergence.
let source: string | undefined;
let sourceReadFailed = false;
if (readSource) {
try {
source = await readSource();
} catch {
source = undefined; // fail-open: a read error must not drop a real divergence
sourceReadFailed = true;
}
}
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
if (source !== undefined && !source.includes(hfId)) return;
const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
trackStudioEvent("sdk_resolver_shadow", {
hfId,
opLabel,
sessionElementCount,
// sourceHfIdCount: strict data-hf-id="..." attribute count. Can be 0 even
// on an emitted (non-suppressed) event — the suppression check above is a
// loose substring match (biased toward keeping signal); see the longer
// comment on this field in runResolverShadow for the full explanation.
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
// Loose suppression check matched (kept this event) but the strict
// attribute count came back 0 — hfId appeared as plain text (class name,
// comment, script string) but never as a data-hf-id="..." attribute.
// Lets telemetry consumers filter this cohort without parsing the
// sourceHfIdCount comment above.
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
// The reader was wired but threw — distinguishes "read failed, emitted
// fail-open without the suppression/count checks" from "no reader wired".
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
mismatchCount: 1,
mismatches: JSON.stringify([
{ kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
]),
});
} catch {
// never propagate from the shadow path
}
}
/**
* Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT
* dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
* the animationId the server GSAP path is addressing — the GSAP-edit-surface
* analogue of element_not_found. The SDK's resolvable animation ids are the
* located ids attached to elements (buildAnimationIdMap) OR any id parsed from
* the script regardless of DOM match (getAllAnimationIds) — a target absent
* from both is a resolver divergence.
*
* No-op when the shadow flag is off; never throws; never mutates the session.
*/
export function recordAnimationResolverParity(
session: Composition | null | undefined,
animationId: string,
opLabel: string,
): void {
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!session || !animationId) return;
try {
recordAttempt(opLabel);
const elements = session.getElements();
const resolves =
elements.some((el) => el.animationIds.includes(animationId)) ||
session.getAllAnimationIds().has(animationId);
if (resolves) return; // SDK locates the animation — parity
trackStudioEvent("sdk_resolver_shadow", {
animationId,
opLabel,
sessionElementCount: elements.length,
mismatchCount: 1,
mismatches: JSON.stringify([
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
]),
});
} catch {
// never propagate from the shadow path
}
}
// ─── Soak gate ────────────────────────────────────────────────────────────────
/**
* Evaluate the soak-gate exit criterion.
*
* A clean soak window has zero `element_not_found` divergences. When that
* condition holds, resolver parity is proven and the flag can be retired.
*/
export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" {
return divergenceCount === 0 ? "parity-proven" : "divergence-detected";
}