diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 154886ac..b22137aa 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -1989,6 +1989,7 @@ function parseAlertThresholds(value) { scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), sessionRailFallbackRatio, + crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), source: "yaml-env", }; } @@ -2408,6 +2409,7 @@ function parseAlertThresholds(value) { scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"), scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"), sessionRailFallbackRatio, + crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")), source: "yaml-env", }; } @@ -2795,7 +2797,12 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN ); const crossPageProjectionDiffs = crossPageDiffs.filter((item) => item.diffKind !== "trace-visibility"); const crossPageTraceVisibilityDiffs = crossPageDiffs.filter((item) => item.diffKind === "trace-visibility"); - if (crossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-divergence", severity: "red", summary: "control and observer pages saw different projection state for the same sampled session", count: crossPageProjectionDiffs.length, samples: crossPageProjectionDiffs.slice(0, 20) }); + const crossPageProjectionBudgetMs = alertThresholds.crossPageProjectionDivergenceRedMs; + const timedCrossPageProjectionDiffs = annotateCrossPageDiffTiming(crossPageProjectionDiffs); + const persistentCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs); + const transientCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs); + if (persistentCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-divergence", severity: "red", summary: "control and observer pages saw different projection state for the same sampled session beyond the configured budget", count: persistentCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: persistentCrossPageProjectionDiffs.slice(0, 20) }); + if (transientCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-transient-divergence", severity: "info", summary: "control and observer pages briefly differed near a sampled transition; retained as transient evidence but not treated as persistent projection failure", count: transientCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: transientCrossPageProjectionDiffs.slice(0, 20) }); if (crossPageTraceVisibilityDiffs.length > 0) findings.push({ id: "cross-page-trace-visibility-divergence", severity: "info", summary: "control and observer pages differed only in visible trace row count; this is local disclosure/hydration visibility, not session/message projection divergence", count: crossPageTraceVisibilityDiffs.length, samples: crossPageTraceVisibilityDiffs.slice(0, 20) }); const traceMessageDuplicates = detectTraceMessageDuplication(samples); if (traceMessageDuplicates.length > 0) findings.push({ id: "trace-assistant-message-duplicates-final-response", severity: "amber", summary: "trace rendered assistant message rows that duplicate the sealed final response", count: traceMessageDuplicates.length, samples: traceMessageDuplicates.slice(0, 20) }); @@ -5943,6 +5950,43 @@ function mergeCrossPageDiffRows(...groups) { return rows; } +function annotateCrossPageDiffTiming(rows) { + const groups = new Map(); + for (const row of Array.isArray(rows) ? rows : []) { + const controlAt = Date.parse(String(row?.control?.ts || "")); + const observerAt = Date.parse(String(row?.observer?.ts || "")); + const timestamps = [controlAt, observerAt].filter(Number.isFinite); + const startMs = timestamps.length > 0 ? Math.min(...timestamps) : null; + const endMs = timestamps.length > 0 ? Math.max(...timestamps) : null; + const sessionId = row?.control?.routeSessionId || row?.control?.activeSessionId || row?.observer?.routeSessionId || row?.observer?.activeSessionId || "unknown-session"; + const key = [row?.diffKind || "projection", sessionId].join(":"); + const group = groups.get(key) || { rows: [], firstMs: null, lastMs: null }; + const annotated = { + ...row, + sampleStartAt: startMs === null ? null : new Date(startMs).toISOString(), + sampleEndAt: endMs === null ? null : new Date(endMs).toISOString(), + pairSkewMs: startMs === null || endMs === null ? null : endMs - startMs, + }; + group.rows.push(annotated); + if (startMs !== null && (group.firstMs === null || startMs < group.firstMs)) group.firstMs = startMs; + if (endMs !== null && (group.lastMs === null || endMs > group.lastMs)) group.lastMs = endMs; + groups.set(key, group); + } + const result = []; + for (const group of groups.values()) { + const observedSpanMs = group.firstMs === null || group.lastMs === null ? null : group.lastMs - group.firstMs; + for (const row of group.rows) { + result.push({ + ...row, + observedFirstAt: group.firstMs === null ? null : new Date(group.firstMs).toISOString(), + observedLastAt: group.lastMs === null ? null : new Date(group.lastMs).toISOString(), + observedSpanMs, + }); + } + } + return result; +} + function detectAdjacentCrossPageProjectionDiffs(samples) { const rows = []; const ordered = (Array.isArray(samples) ? samples : []).slice().sort((a, b) => Number(a?.seq ?? 0) - Number(b?.seq ?? 0));