diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 60997180..c6b57709 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -1259,6 +1259,7 @@ function buildSampleMetrics(samples, control) { }); const turnTiming = buildTurnTimingTable(samples, timeline); const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {})); + const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : []; const withTotal = timeline.filter((item) => item.totalElapsedSeconds !== null).length; const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length; const diagnostics = timeline.filter((item) => item.diagnosticSeen).length; @@ -1274,11 +1275,15 @@ function buildSampleMetrics(samples, control) { turnColumns: turnTiming.columns.length, turnTimingRows: turnTiming.rows.length, turnCellsWithTotalElapsed: turnCells.filter((item) => item.totalElapsedSeconds !== null).length, - turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length + turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length, + turnTimingNonMonotonicCount: turnTimingNonMonotonic.length, + turnTimingTotalElapsedDecreaseCount: turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds").length, + turnTimingRecentUpdateDecreaseCount: turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds").length }, rounds, turnColumns: turnTiming.columns, turnTimingTable: turnTiming.rows, + turnTimingNonMonotonic, timeline }; } @@ -1338,7 +1343,73 @@ function buildTurnTimingTable(samples, timeline) { cells }); } - return { columns, rows }; + return { columns, rows, nonMonotonic: detectTurnTimingNonMonotonic(columns, rows) }; +} + +function detectTurnTimingNonMonotonic(columns, rows) { + const events = []; + for (const column of columns) { + const previousByMetric = new Map(); + for (const row of rows) { + const cell = row.cells?.[column.id]; + if (!cell) continue; + for (const metric of ["totalElapsedSeconds", "recentUpdateSeconds"]) { + const value = cell[metric]; + if (value === null || value === undefined || !Number.isFinite(Number(value))) continue; + const current = Number(value); + const previous = previousByMetric.get(metric); + if (previous && current < previous.value) { + events.push({ + columnId: column.id, + columnLabel: column.label, + metric, + fromSeq: previous.seq, + fromTs: previous.ts, + fromValue: previous.value, + toSeq: row.seq ?? null, + toTs: row.ts ?? null, + toValue: current, + delta: current - previous.value, + traceId: cell.traceId ?? column.traceId ?? null, + messageId: cell.messageId ?? column.messageId ?? null, + promptIndex: cell.promptIndex ?? row.promptIndex ?? null, + source: cell.source ?? column.source ?? null, + valuesRedacted: true + }); + } + if (previous && metric === "recentUpdateSeconds") { + const elapsedMs = Date.parse(String(row.ts ?? "")) - Date.parse(String(previous.ts ?? "")); + const elapsedSeconds = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs / 1000 : null; + const allowedIncrease = elapsedSeconds === null ? 10 : Math.max(10, elapsedSeconds + 5); + const increase = current - previous.value; + if (increase > allowedIncrease) { + events.push({ + columnId: column.id, + columnLabel: column.label, + metric: "recentUpdateSeconds", + anomaly: "jump", + fromSeq: previous.seq, + fromTs: previous.ts, + fromValue: previous.value, + toSeq: row.seq ?? null, + toTs: row.ts ?? null, + toValue: current, + delta: increase, + sampleDeltaSeconds: elapsedSeconds, + allowedIncreaseSeconds: allowedIncrease, + traceId: cell.traceId ?? column.traceId ?? null, + messageId: cell.messageId ?? column.messageId ?? null, + promptIndex: cell.promptIndex ?? row.promptIndex ?? null, + source: cell.source ?? column.source ?? null, + valuesRedacted: true + }); + } + } + previousByMetric.set(metric, { value: current, seq: row.seq ?? null, ts: row.ts ?? null }); + } + } + } + return events; } function turnMetricItems(sample, timelineItem) { @@ -1610,7 +1681,11 @@ function renderTurnTimingTable(sampleMetrics) { lines.push("| " + cells.map(escapeMarkdownCell).join(" | ") + " |"); } const columnLines = columns.map((column) => "- " + column.label + ": source=" + (column.source || "-") + " prompt=" + (column.promptIndex ?? "-") + " lastPrompt=" + (column.lastPromptIndex ?? "-") + " firstSeq=" + (column.firstSeq ?? "-") + " lastSeq=" + (column.lastSeq ?? "-") + " traceId=" + (column.traceId || "-") + " messageId=" + (column.messageId || "-")).join("\n"); - return lines.join("\n") + "\n\n列说明:\n" + columnLines; + const nonMonotonic = Array.isArray(sampleMetrics?.turnTimingNonMonotonic) ? sampleMetrics.turnTimingNonMonotonic : []; + const nonMonotonicLines = nonMonotonic.length > 0 + ? nonMonotonic.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " " + item.metric + (item.anomaly ? " " + item.anomaly : "") + " " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " allowed=" + (item.allowedIncreaseSeconds ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") + : "- 未观察到数值下降事件。"; + return lines.join("\n") + "\n\n列说明:\n" + columnLines + "\n\n非单调/跳增事件(仅报表暴露,不做下游 repair):\n" + nonMonotonicLines; } function formatMetricCell(value) {