From f9afa4421e6a7adb079eaed53ebfa3a49d09acce Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 14 Jul 2026 02:47:55 -0300 Subject: [PATCH 1/2] feat: add streaming diff oven --- .codex/hooks.json | 51 +++ README.md | 2 +- bin/burnlist.mjs | 2 + .../burnlists/draft/260101-001/burnlist.md | 20 + .../notes/burnlists/draft/260101-001/goal.md | 27 ++ scripts/verify.mjs | 5 +- .../dashboard/checklist-progress-chart.js | 169 ++++++++ .../differential-testing-progress-chart.js | 114 +++--- .../differential-testing-renderer.js | 42 +- .../dashboard/differential-testing.css | 6 + .../burnlist/dashboard/repo-graph-layout.js | 288 +++++++++++++ skills/burnlist/dashboard/src/app.tsx | 34 +- .../dashboard/src/checklist-dashboard.tsx | 121 ++++-- skills/burnlist/dashboard/src/index.css | 149 ++++++- .../burnlist/dashboard/src/streaming-diff.tsx | 359 +++++++++++++++++ skills/burnlist/dashboard/streaming-diff.css | 380 ++++++++++++++++++ .../burnlist/ovens/streaming-diff/detail.json | 41 ++ .../ovens/streaming-diff/instructions.md | 23 ++ .../scripts/burnlist-dashboard-server.mjs | 193 ++++++++- .../scripts/checklist-progress-chart.test.mjs | 31 ++ .../scripts/dashboard-routes.test.mjs | 49 ++- ...ifferential-testing-data-contract.test.mjs | 57 ++- ...ential-testing-transport-renderer.test.mjs | 3 +- ...erential-testing-transport-server.test.mjs | 1 + .../differential-testing-transport.mjs | 9 +- skills/burnlist/scripts/repo-map.test.mjs | 42 ++ .../scripts/streaming-diff-contract.mjs | 228 +++++++++++ .../scripts/streaming-diff-contract.test.mjs | 114 ++++++ .../burnlist/scripts/streaming-diff-hook.mjs | 245 +++++++++++ .../scripts/streaming-diff-server.test.mjs | 159 ++++++++ streaming-diff-demo.txt | 4 + 31 files changed, 2810 insertions(+), 158 deletions(-) create mode 100644 .codex/hooks.json create mode 100644 fixtures/sample-repo/notes/burnlists/draft/260101-001/burnlist.md create mode 100644 fixtures/sample-repo/notes/burnlists/draft/260101-001/goal.md create mode 100644 skills/burnlist/dashboard/checklist-progress-chart.js create mode 100644 skills/burnlist/dashboard/repo-graph-layout.js create mode 100644 skills/burnlist/dashboard/src/streaming-diff.tsx create mode 100644 skills/burnlist/dashboard/streaming-diff.css create mode 100644 skills/burnlist/ovens/streaming-diff/detail.json create mode 100644 skills/burnlist/ovens/streaming-diff/instructions.md create mode 100644 skills/burnlist/scripts/checklist-progress-chart.test.mjs create mode 100644 skills/burnlist/scripts/streaming-diff-contract.mjs create mode 100644 skills/burnlist/scripts/streaming-diff-contract.test.mjs create mode 100644 skills/burnlist/scripts/streaming-diff-hook.mjs create mode 100644 skills/burnlist/scripts/streaming-diff-server.test.mjs create mode 100644 streaming-diff-demo.txt diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..f69eccf --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,51 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/skills/burnlist/scripts/streaming-diff-hook.mjs\" start", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/skills/burnlist/scripts/streaming-diff-hook.mjs\" prompt", + "timeout": 30 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Edit|Write|apply_patch|exec", + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/skills/burnlist/scripts/streaming-diff-hook.mjs\" pre", + "timeout": 150 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|Edit|Write|apply_patch|exec", + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/skills/burnlist/scripts/streaming-diff-hook.mjs\" post", + "timeout": 150 + } + ] + } + ] + } +} diff --git a/README.md b/README.md index 4d0a5a4..6ed7150 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Burnlist logo Burnlist +# ⟁ Burnlist A real-time, non-invasive tracker for agents. A Burnlist stores work in a repo-local, shrinking Markdown checklist and renders progress in a local observer dashboard. Burnlist owns task state, not implementation, testing, or delivery. diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 3e60459..123dfba 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -128,6 +128,8 @@ Options: --ovens-dir Override custom Oven storage. --runs-dir Override Run snapshot storage. --oven-data Bind one Oven to a read-only normalized JSON payload. + --streaming-diff-dir + Read hook-attributed Streaming Diff thread feeds. --version, -v Print the installed Burnlist version. --help, -h Show this help.`); process.exit(0); diff --git a/fixtures/sample-repo/notes/burnlists/draft/260101-001/burnlist.md b/fixtures/sample-repo/notes/burnlists/draft/260101-001/burnlist.md new file mode 100644 index 0000000..57e99f8 --- /dev/null +++ b/fixtures/sample-repo/notes/burnlists/draft/260101-001/burnlist.md @@ -0,0 +1,20 @@ +# Sample Burnlist + +Status: Burnlist Final +Updated: 2026-01-01 +Repo: `fixtures/sample-repo` +Goal: ./goal.md + +## Active Checklist +- [ ] B1 | Verify fixture protocol + Files/search: `fixtures/sample-repo/notes/burnlists/draft/260101-001` + Action: Run the bundled protocol checker against this fixture. + Done/delete when: The checker exits successfully. + Validate: `burnlist --plan fixtures/sample-repo/notes/burnlists/draft/260101-001/burnlist.md --check` +- [ ] B2 | Review default Oven docs + Files/search: `docs/progress.md docs/compare.md` + Action: Confirm Checklist and Compare are described as distinct default Ovens. + Done/delete when: Both docs describe their own state model and question. + Validate: `npm run verify` + +## Completed diff --git a/fixtures/sample-repo/notes/burnlists/draft/260101-001/goal.md b/fixtures/sample-repo/notes/burnlists/draft/260101-001/goal.md new file mode 100644 index 0000000..0f9dbec --- /dev/null +++ b/fixtures/sample-repo/notes/burnlists/draft/260101-001/goal.md @@ -0,0 +1,27 @@ +# Sample Goal + +Repo: `fixtures/sample-repo` + +## Goal + +Provide a tiny fake Burnlist that exercises the protocol checker. + +## Guardrails + +Do not treat this fixture as real project history. + +## Proof Authority + +The protocol checker is the only proof authority for this fixture. + +## Ordering Intent + +Keep the first item focused on protocol validity before any documentation review. + +## Stop Conditions + +Stop if the fixture no longer validates with the bundled checker. + +## Handoff + +Use this fixture for repository verification only. diff --git a/scripts/verify.mjs b/scripts/verify.mjs index fd4ce2f..0de2c9a 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -406,9 +406,10 @@ assertSourceExcludes("skills/burnlist/scripts/burnlist-dashboard-server.mjs", '" assertSourceExcludes("skills/burnlist/dashboard/src/app.tsx", '"/targets"', "React dashboard still exposes the removed Targets route."); assertSourceExcludes("skills/burnlist/scripts/oven-contract.mjs", '"target"', "Oven contract still accepts the removed Target widget."); assertSkillSet(["burnlist"]); -assertBuiltInOvenSet(["checklist", "differential-testing"]); +assertBuiltInOvenSet(["checklist", "differential-testing", "streaming-diff"]); assertBuiltInOven("checklist", "Checklist"); assertBuiltInOven("differential-testing", "Differential Testing"); +assertBuiltInOven("streaming-diff", "Streaming Diff"); assertDifferentialTestingContractAssets(); assertPublishablePackage(); @@ -423,6 +424,8 @@ run(process.execPath, [ "skills/burnlist/scripts/registry.test.mjs", "skills/burnlist/scripts/repo-map.test.mjs", "skills/burnlist/scripts/repo-state.test.mjs", + "skills/burnlist/scripts/streaming-diff-contract.test.mjs", + "skills/burnlist/scripts/streaming-diff-server.test.mjs", ]); run(process.execPath, ["scripts/register-skills.mjs", "--force-global", "--dry-run"], { diff --git a/skills/burnlist/dashboard/checklist-progress-chart.js b/skills/burnlist/dashboard/checklist-progress-chart.js new file mode 100644 index 0000000..a460269 --- /dev/null +++ b/skills/burnlist/dashboard/checklist-progress-chart.js @@ -0,0 +1,169 @@ +function finiteNumber(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); +} + +function niceCeiling(value) { + if (!(value > 0)) return 1; + const magnitude = 10 ** Math.floor(Math.log10(value)); + const scaled = value / magnitude; + const factor = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10; + return factor * magnitude; +} + +function burnAxis(rawMaximum) { + const maximum = Math.max(1, Math.ceil(rawMaximum)); + if (maximum <= 5) { + return { maximum, ticks: Array.from({ length: maximum + 1 }, (_, index) => index) }; + } + const step = niceCeiling(maximum / 4); + const axisMaximum = Math.ceil(maximum / step) * step; + return { + maximum: axisMaximum, + ticks: Array.from({ length: axisMaximum / step + 1 }, (_, index) => index * step), + }; +} + +function normalizedHistory(history) { + const sorted = (Array.isArray(history) ? history : []) + .map((point, index) => ({ + time: Date.parse(point?.time), + done: finiteNumber(point?.done), + remaining: Math.max(0, finiteNumber(point?.remaining)), + total: Math.max(0, finiteNumber(point?.total)), + percent: clamp(finiteNumber(point?.percent), 0, 100), + index, + })) + .filter((point) => Number.isFinite(point.time)) + .sort((left, right) => left.time - right.time || left.index - right.index); + const collapsed = []; + for (const point of sorted) { + if (collapsed.at(-1)?.time === point.time) collapsed[collapsed.length - 1] = point; + else collapsed.push(point); + } + return collapsed; +} + +function compactTimeScale(points, minimumTime, maximumTime) { + const idleThreshold = 30 * 60_000; + const compactIdleGap = 8 * 60_000; + const anchors = [...new Set([ + minimumTime, + ...points.map((point) => point.time).filter((time) => time > minimumTime && time < maximumTime), + maximumTime, + ])].sort((left, right) => left - right); + const segments = []; + let displayEnd = 0; + for (let index = 1; index < anchors.length; index += 1) { + const start = anchors[index - 1]; + const end = anchors[index]; + const elapsed = Math.max(0, end - start); + const displayElapsed = elapsed > idleThreshold ? compactIdleGap : elapsed; + segments.push({ start, end, displayStart: displayEnd, displayEnd: displayEnd + displayElapsed }); + displayEnd += displayElapsed; + } + const project = (time) => { + const clamped = clamp(Number(time), minimumTime, maximumTime); + const segment = segments.find((candidate) => clamped <= candidate.end) ?? segments.at(-1); + if (!segment || segment.end <= segment.start) return 0; + const ratio = (clamped - segment.start) / (segment.end - segment.start); + return segment.displayStart + (segment.displayEnd - segment.displayStart) * ratio; + }; + const unproject = (displayTime) => { + const clamped = clamp(Number(displayTime), 0, displayEnd); + const segment = segments.find((candidate) => clamped <= candidate.displayEnd) ?? segments.at(-1); + if (!segment || segment.displayEnd <= segment.displayStart) return minimumTime; + const ratio = (clamped - segment.displayStart) / (segment.displayEnd - segment.displayStart); + return segment.start + (segment.end - segment.start) * ratio; + }; + return { span: Math.max(1, displayEnd), project, unproject }; +} + +function stepPath(points, x, y, valueForPoint) { + if (!points.length) return ""; + const commands = [`M ${x(points[0]).toFixed(1)} ${y(valueForPoint(points[0])).toFixed(1)}`]; + for (let index = 1; index < points.length; index += 1) { + const point = points[index]; + const previous = points[index - 1]; + commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(previous)).toFixed(1)}`); + commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(point)).toFixed(1)}`); + } + return commands.join(" "); +} + +export function buildChecklistProgressChart(history, mode = "done", { width = 640, height = 180 } = {}) { + const safeWidth = Math.max(360, Math.round(width)); + const safeHeight = Math.max(160, Math.round(height)); + const plot = { left: 0, top: 0, right: safeWidth, bottom: safeHeight }; + const points = normalizedHistory(history); + const fallbackTime = Date.now(); + const series = points.length ? points : [{ time: fallbackTime, done: 0, remaining: 0, total: 0, percent: 0 }]; + const first = series[0]; + const renderSeries = first.done > 0 + ? [{ ...first, done: 0, remaining: first.total, percent: 0, synthetic: true }, ...series] + : series; + const minimumTime = series[0].time; + const maximumTime = series.at(-1).time; + const timeline = compactTimeScale(series, minimumTime, maximumTime); + const valueForPoint = mode === "burn" ? (point) => point.remaining : (point) => point.percent; + const rawMaximum = Math.max(0, ...renderSeries.map(valueForPoint)); + const burnScale = burnAxis(rawMaximum); + const valueMaximum = mode === "burn" ? burnScale.maximum : 100; + const x = (point) => plot.left + (timeline.project(point.time) / timeline.span) * (plot.right - plot.left); + const y = (value) => plot.bottom - (clamp(value, 0, valueMaximum) / valueMaximum) * (plot.bottom - plot.top); + const path = stepPath(renderSeries, x, y, valueForPoint); + const baseline = y(0).toFixed(1); + const firstX = x(renderSeries[0]).toFixed(1); + const lastX = x(renderSeries.at(-1)).toFixed(1); + const area = `${path} L ${lastX} ${baseline} L ${firstX} ${baseline} Z`; + const yTicks = mode === "burn" ? burnScale.ticks : [0, 25, 50, 75, 100]; + const xTicks = Array.from({ length: 6 }, (_, index) => { + const ratio = index / 5; + return { + time: timeline.unproject(timeline.span * ratio), + x: plot.left + (plot.right - plot.left) * ratio, + edge: index === 0 ? "start" : index === 5 ? "end" : "middle", + }; + }); + const markers = []; + for (let index = 1; index < renderSeries.length; index += 1) { + const previous = renderSeries[index - 1]; + const point = renderSeries[index]; + const markerX = clamp(x(point), plot.left + 8, plot.right - 8); + if (point.total > previous.total) { + markers.push({ + type: "split", + x: markerX, + y: Math.min(plot.bottom - 2, y(valueForPoint(point)) + 13), + title: `Split/add: ${point.total - previous.total} item${point.total - previous.total === 1 ? "" : "s"}`, + }); + } + if (point.done > previous.done) { + markers.push({ + type: "completion", + x: markerX, + y: Math.max(plot.top + 6, y(valueForPoint(point)) - 9), + title: `Completed ${point.done - previous.done} item${point.done - previous.done === 1 ? "" : "s"}`, + }); + } + } + const last = series.at(-1); + return { + width: safeWidth, + height: safeHeight, + mode, + timeScale: "compact", + plot, + path, + area, + yTicks: yTicks.map((value) => ({ value, y: y(value), label: mode === "done" ? `${Math.round(value)}%` : String(Math.round(value)) })), + xTicks, + markers, + points: series.map((point) => ({ ...point, x: x(point), y: y(valueForPoint(point)), value: valueForPoint(point) })), + last: { ...last, x: x(last), y: y(valueForPoint(last)), value: valueForPoint(last) }, + }; +} diff --git a/skills/burnlist/dashboard/differential-testing-progress-chart.js b/skills/burnlist/dashboard/differential-testing-progress-chart.js index 62a1d8e..efa7d33 100644 --- a/skills/burnlist/dashboard/differential-testing-progress-chart.js +++ b/skills/burnlist/dashboard/differential-testing-progress-chart.js @@ -295,7 +295,10 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f : null; const displayTimeSpan = compactTimeline ? compactTimeline.span : timeSpan; const displayTime = (time) => compactTimeline ? compactTimeline.project(time) : time - minTime; - const progressValueForPoint = (point) => Math.max(0, Math.min(100, point.percent)); + const progressValueForPoint = (point) => { + const percent = Math.max(0, Math.min(100, point.percent)); + return isDrivingParityPage ? 100 - percent : percent; + }; const valueForPoint = isFailedChart ? failedFieldValueForPoint : progressValueForPoint; const failedVisibleDomainSource = isFailedChart && failedFieldSeries.length ? failedFieldSeries.filter((point) => point.time >= minTime && point.time <= maxTime) @@ -450,7 +453,7 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f : Math.round(value) + "%"; svg.setAttribute( "aria-label", - isDeltaChart ? "Overall failed-sample deviation ratio over time" : isFailedChart ? "Failed state points over time" : "Completion percentage over time", + isDeltaChart ? "Overall failed-sample deviation ratio over time" : isFailedChart ? "Failed state points over time" : isDrivingParityPage ? "Remaining gap over time" : "Completion percentage over time", ); svg.classList.toggle("range-zoomable", useRangeZoom); svg.classList.toggle("failed-chart", isFailedChart); @@ -470,6 +473,15 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f svg.dataset.domainMax = String(maxTime); svg.dataset.timeScale = compactTimeline ? "compact" : "all"; svg.replaceChildren(); + if (!isFailedChart && isDrivingParityPage) { + const remainingArea = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + remainingArea.setAttribute("class", "progress-remaining-area"); + remainingArea.setAttribute("x", String(seriesPad.left)); + remainingArea.setAttribute("y", String(pad.top)); + remainingArea.setAttribute("width", String(innerWidth)); + remainingArea.setAttribute("height", String(innerHeight)); + svg.append(remainingArea); + } const yTicks = isDeltaChart ? [0, 1, 2, 3, 4].map((index) => valueMin + ((valueMax - valueMin) * index) / 4) : isFailedChart @@ -485,23 +497,23 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f if (hideEdgeDrivingParityTick) continue; const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("class", "grid-line"); - const rightValueLabelInset = isFailedChart && yAxisInsidePlot ? 36 : 0; + const rightValueLabelInset = yAxisInsidePlot ? 36 : 0; line.setAttribute("x1", String(seriesPad.left)); line.setAttribute("x2", String(width - seriesPad.right - rightValueLabelInset)); line.setAttribute("y1", String(tickY)); line.setAttribute("y2", String(tickY)); svg.append(line); - if (!isFailedChart && value === 0) continue; + if (!isFailedChart && (value === 0 || (isDrivingParityPage && value === 100))) continue; const backdrop = document.createElementNS("http://www.w3.org/2000/svg", "rect"); backdrop.setAttribute("class", "label-backdrop"); - backdrop.setAttribute("x", "0"); + const valueAxisOnRight = yAxisInsidePlot; + backdrop.setAttribute("x", String(valueAxisOnRight ? width - axisLabelWidth : 0)); backdrop.setAttribute("y", String(labelY - 8)); backdrop.setAttribute("width", String(axisLabelWidth)); backdrop.setAttribute("height", "16"); svg.append(backdrop); const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); text.setAttribute("class", "axis-label y-axis-label"); - const valueAxisOnRight = isFailedChart && yAxisInsidePlot; text.setAttribute("x", String(valueAxisOnRight ? width - 4 : yAxisInsidePlot ? 4 : seriesPad.left - 8)); text.setAttribute("y", String(labelY)); text.setAttribute("text-anchor", valueAxisOnRight || !yAxisInsidePlot ? "end" : "start"); @@ -519,7 +531,7 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f if (!isFailedChart) { const area = document.createElementNS("http://www.w3.org/2000/svg", "path"); area.setAttribute("class", "progress-area"); - area.setAttribute("d", areaPath); + area.setAttribute("d", isDrivingParityPage ? chartCeilingAreaPath(fallback, progressValueForPoint) : areaPath); svg.append(area); const line = document.createElementNS("http://www.w3.org/2000/svg", "path"); line.setAttribute("class", "progress-line"); @@ -663,7 +675,7 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f } if (failedRunTickMarkerLayer?.childElementCount) svg.append(failedRunTickMarkerLayer); if (failedFieldLine) svg.append(failedFieldLine); - if (!isFailedChart) for (let index = 1; index < fallback.length; index += 1) { + if (!isFailedChart && !isDrivingParityPage) for (let index = 1; index < fallback.length; index += 1) { const previous = fallback[index - 1]; const point = fallback[index]; const completed = Number.isFinite(previous.done) && Number.isFinite(point.done) && point.done > previous.done; @@ -758,44 +770,41 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f } } -export function rollingStandardDeviationScores(ratios, centeredRatios, activeStart, rollingRadius) { - return centeredRatios.map((residual, index) => { - if (!(ratios[index] > 0)) return 0; - const warmingUp = activeStart >= 0 && index < activeStart + rollingRadius; - const window = centeredRatios.slice( - warmingUp ? activeStart : Math.max(activeStart, index - rollingRadius), - warmingUp ? index + 1 : Math.min(centeredRatios.length, index + rollingRadius + 1), - ); - if (window.length < 2) return 0; - const mean = window.reduce((sum, value) => sum + value, 0) / window.length; - const variance = window.reduce((sum, value) => sum + (value - mean) ** 2, 0) / window.length; - const standardDeviation = Math.sqrt(variance); - return standardDeviation > Number.EPSILON ? residual / standardDeviation : 0; - }); +export function percentileCappedFrameDeltaSeries(residuals, activeStart = 0, percentile = .98) { + const start = Math.max(0, Math.min(residuals.length, Number.isSafeInteger(activeStart) ? activeStart : 0)); + const values = residuals.map((value, index) => index < start ? 0 : value); + const magnitudes = values.slice(start).map((value) => Math.abs(value)).sort((left, right) => left - right); + const normalizedPercentile = Math.max(0, Math.min(1, Number(percentile) || 0)); + const percentileIndex = Math.floor(Math.max(0, magnitudes.length - 1) * normalizedPercentile); + const limit = Math.max(.00001, magnitudes[percentileIndex] || 0); + return { + values, + limit, + clippedCount: values.filter((value) => Math.abs(value) > limit).length, + }; } export function renderDifferentialTestingFrameDeltaChart(svg, metrics) { if (!svg || typeof svg.replaceChildren !== "function") return; - const ratios = Array.isArray(metrics?.frameDeviationRatios) - ? metrics.frameDeviationRatios.map((value) => Math.max(0, Number(value) || 0)) + const residuals = Array.isArray(metrics?.frameSignedResiduals) + ? metrics.frameSignedResiduals.map((value) => Number(value) || 0) : []; const measuredWidth = Math.round(svg.getBoundingClientRect().width || svg.parentElement?.getBoundingClientRect().width || svg.clientWidth || 640); const width = Math.max(360, measuredWidth); const measuredHeight = Math.round(svg.getBoundingClientRect().height || svg.clientHeight || parseFloat(getComputedStyle(svg).height) || 200); const height = Math.max(160, measuredHeight); svg.setAttribute("viewBox", "0 0 " + width + " " + height); - svg.setAttribute("aria-label", "Current-run overall frame deviation residual normalized by a 31-frame rolling standard deviation"); + svg.setAttribute("aria-label", "Largest signed candidate minus reference residual by frame; display capped at the 98th percentile with clipped values marked"); svg.classList.remove("range-zoomable", "failed-chart", "goal-reached"); svg.classList.add("delta-chart"); delete svg.dataset.domainMin; delete svg.dataset.domainMax; delete svg.dataset.timeScale; svg.replaceChildren(); - if (ratios.length < 2) return; + if (residuals.length < 2) return; - const frameCount = ratios.length; - const rollingMedianRadius = 15; - const activeStart = ratios.findIndex((value) => value > 0); + const frameCount = residuals.length; + const activeStart = residuals.findIndex((value) => value !== 0); const hasMetricFirstFailingFrame = metrics?.firstFailingFrame !== null && metrics?.firstFailingFrame !== undefined && metrics?.firstFailingFrame !== ""; @@ -803,36 +812,18 @@ export function renderDifferentialTestingFrameDeltaChart(svg, metrics) { const firstFailingFrame = hasMetricFirstFailingFrame && Number.isFinite(metricFirstFailingFrame) ? Math.max(-1, Math.min(frameCount - 1, Math.round(metricFirstFailingFrame))) : activeStart; - const centeredRatios = ratios.map((value, index) => { - if (!(value > 0)) return 0; - const warmingUp = activeStart >= 0 && index < activeStart + rollingMedianRadius; - const window = ratios - .slice( - warmingUp ? activeStart : Math.max(activeStart, index - rollingMedianRadius), - warmingUp ? index + 1 : Math.min(frameCount, index + rollingMedianRadius + 1), - ) - .filter((candidate) => candidate > 0) - .sort((left, right) => left - right); - if (!window.length) return 0; - const middle = Math.floor(window.length / 2); - const rollingMedian = window.length % 2 - ? window[middle] - : (window[middle - 1] + window[middle]) / 2; - return value - rollingMedian; - }); - const standardizedRatios = rollingStandardDeviationScores(ratios, centeredRatios, activeStart, rollingMedianRadius); - const maxResidual = Math.max(0.00001, ...standardizedRatios.map((value) => Math.abs(value))); - const limit = maxResidual * 1.16; + const frameDeltas = percentileCappedFrameDeltaSeries(residuals, Math.max(0, firstFailingFrame)); + const limit = frameDeltas.limit; const zeroY = height / 2; const x = (index) => index / Math.max(1, frameCount - 1) * width; - const y = (value) => zeroY - (value / limit) * (height * 0.44); + const y = (value) => zeroY - (Math.max(-limit, Math.min(limit, value)) / limit) * (height * 0.44); const svgElement = (name, attributes = {}) => { const element = document.createElementNS("http://www.w3.org/2000/svg", name); for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value)); return element; }; const formatRatio = (value) => { - const digits = Math.abs(value) >= 1 ? 4 : 5; + const digits = Math.abs(value) >= 1 ? 2 : 3; return Number(value).toFixed(digits).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); }; @@ -854,7 +845,7 @@ export function renderDifferentialTestingFrameDeltaChart(svg, metrics) { appendBand(firstFailingFrame, frameCount - 1, true); } - const horizontalTicks = [-limit / 2, 0, limit / 2]; + const horizontalTicks = [-limit, 0, limit]; for (const value of horizontalTicks) { const tickY = y(value); if (value !== 0) { @@ -939,8 +930,8 @@ export function renderDifferentialTestingFrameDeltaChart(svg, metrics) { const passSegments = []; const failSegments = []; for (let index = 0; index < frameCount - 1; index += 1) { - const startValue = standardizedRatios[index]; - const endValue = standardizedRatios[index + 1]; + const startValue = frameDeltas.values[index]; + const endValue = frameDeltas.values[index + 1]; const startX = x(index); const endX = x(index + 1); const appendSegment = (segments, x1, value1, x2, value2) => { @@ -954,6 +945,21 @@ export function renderDifferentialTestingFrameDeltaChart(svg, metrics) { } if (passSegments.length) svg.append(svgElement("path", { class: "frame-delta-line-pass", d: passSegments.join(" ") })); if (failSegments.length) svg.append(svgElement("path", { class: "frame-delta-line-fail", d: failSegments.join(" ") })); + frameDeltas.values.forEach((value, index) => { + if (Math.abs(value) <= limit) return; + const markerX = x(index); + const markerY = y(value); + const marker = svgElement("path", { + class: "frame-delta-outlier", + d: value > 0 + ? `M${(markerX - 3).toFixed(1)},${(markerY + 5).toFixed(1)}L${markerX.toFixed(1)},${markerY.toFixed(1)}L${(markerX + 3).toFixed(1)},${(markerY + 5).toFixed(1)}Z` + : `M${(markerX - 3).toFixed(1)},${(markerY - 5).toFixed(1)}L${markerX.toFixed(1)},${markerY.toFixed(1)}L${(markerX + 3).toFixed(1)},${(markerY - 5).toFixed(1)}Z`, + }); + const title = svgElement("title"); + title.textContent = `${value > 0 ? "+" : ""}${formatRatio(value)} signed residual`; + marker.append(title); + svg.append(marker); + }); if (firstFailingFramePercentLabel) svg.append(firstFailingFramePercentLabel); if (firstFailingFrameLabel) svg.append(firstFailingFrameLabel); } diff --git a/skills/burnlist/dashboard/differential-testing-renderer.js b/skills/burnlist/dashboard/differential-testing-renderer.js index 84974ed..73f86c6 100644 --- a/skills/burnlist/dashboard/differential-testing-renderer.js +++ b/skills/burnlist/dashboard/differential-testing-renderer.js @@ -78,15 +78,18 @@ export function differentialProgressChartHistory(payload, { mode = "value" } = { export function differentialExactPrefixFrameDeltaMetrics(payload, metrics) { const ratios = metrics?.frameDeviationRatios; + const residuals = metrics?.frameSignedResiduals; const latest = Array.isArray(payload?.progress) ? payload.progress.at(-1) : null; const clearedFrame = Number(latest?.frame); const frameCount = Number(latest?.frames); - if (!Array.isArray(ratios) || !Number.isSafeInteger(clearedFrame) || !Number.isSafeInteger(frameCount) - || frameCount !== ratios.length || clearedFrame < 0 || clearedFrame > frameCount - || ratios.some((value) => !Number.isFinite(Number(value)) || Number(value) < 0)) return null; + if (!Array.isArray(ratios) || !Array.isArray(residuals) || !Number.isSafeInteger(clearedFrame) || !Number.isSafeInteger(frameCount) + || frameCount !== ratios.length || frameCount !== residuals.length || clearedFrame < 0 || clearedFrame > frameCount + || ratios.some((value) => !Number.isFinite(Number(value)) || Number(value) < 0) + || residuals.some((value) => !Number.isFinite(Number(value)))) return null; return { ...metrics, frameDeviationRatios: ratios.map((value, frame) => frame < clearedFrame ? 0 : Number(value)), + frameSignedResiduals: residuals.map((value, frame) => frame < clearedFrame ? 0 : Number(value)), firstFailingFrame: clearedFrame < frameCount ? clearedFrame : -1, }; } @@ -166,7 +169,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { const RED = "#ef4444"; const telemetryAvailability = differentialTelemetryAvailability(payload); const state = { - chart: "delta", + chart: "current", progressChart: "delta", sort: fieldPage?.sort ?? (telemetryAvailability.status === "comparable" ? "changed" : "default"), filter: fieldPage?.filter ?? "all", @@ -383,12 +386,17 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { const marker = stateClass === "improved" ? "▲" : stateClass === "worsened" ? "▼" : "⦁"; const deltaText = deltaPercent === null ? "—" : percent(deltaPercent); const resultText = frameDelta === null ? "—" : count(Math.abs(frameDelta)); - const result = marker !== "⦁" - ? `${marker}${resultText}` - : resultText; + const result = frameDelta === null + ? "—" + : marker === "⦁" + ? resultText + : `${marker}${resultText}`; const frame = !Number.isSafeInteger(Number(entry.frame)) ? "—" : count(entry.frame); + const tick = entry.firstFailingTick == null || !Number.isSafeInteger(Number(entry.firstFailingTick)) + ? "—" + : String(Number(entry.firstFailingTick)); const done = !Number.isSafeInteger(Number(entry.frame)) || !Number(entry.frames) ? "—" : `${Math.round(Math.max(0, Math.min(1, Number(entry.frame) / Number(entry.frames))) * 100)}%`; - return `
${escapeHtml(formatLogRelativeMinutes(entry.timestamp, now))}${frame}${result}${deltaText}${done}
`; + return `
${escapeHtml(formatLogRelativeMinutes(entry.timestamp, now))}${frame}×${tick}${result}${deltaText}${done}
`; }).join(""); const placeholders = Array.from( { length: Math.max(0, 8 - visibleEntries.length) }, @@ -564,7 +572,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { ? "No changed fields in this telemetry." : state.telemetryAvailability.reason : "No fields match the current view."; - return `
${escapeHtml(message)}
`; + return `
${escapeHtml(message)}
`; } return `
${fields.map((field, index) => { const expanded = state.expanded.has(field.id); @@ -755,9 +763,9 @@ export function mountDifferentialTestingDashboard(root, oven, payload, {
- + - +
@@ -1499,6 +1507,12 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { border-radius: 1px; color: var(--muted); } + .hybrid-empty { + border: 0; + border-radius: 8px; + background: var(--card); + color: rgba(168,168,168,.76); + }
@@ -1611,7 +1625,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { const scenarioKpi = kpiItem({ className: "driving-parity-kpi-scenario", title: subtitleParts.join(" · "), - visual: '', + visual: '', heading: "Scenario", headingClass: "differential-scenario-heading", value: scenarioSelector(), @@ -1629,8 +1643,8 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { .replace('
', '
') .replace('', `

Fields List(${count(state.payload.summary?.fields?.total ?? state.payload.fields.length)})

`) .replace('