Attribute violations to the causing step and render witness evidence (#59)

* feat(ltl): attribute violations to the obligation origin step

* feat(verifier): label evaluator observations with the runner step index

* feat(trace): carry the causing step in violation witnesses and summary

* feat(replay): move the violation marker to the causing step

* feat(replay-ui): render witness evidence in the violations panel

* feat(replay-ui): wire witnesses and step jump into violation panels

* fix(ltl): treat next obligations as vacuous at run end

* fix(runner): give the finalize trace record its own step index
This commit is contained in:
pj authored and GitHub committed 2026-06-05 23:46:36 +05:30
1 parent 19a470121f
commit 9958b0ddc8
15 files changed
+719 -85

No files matched your search

+51
View File
@@ -97,6 +97,57 @@
font-size: 13px;
}
.violations-panel-witness {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.violations-panel-witness-line {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
}
.violations-panel-witness-key {
flex-shrink: 0;
min-width: 96px;
color: var(--text-muted);
font-size: 11px;
text-transform: lowercase;
}
.violations-panel-witness-value {
word-break: break-all;
white-space: pre-wrap;
margin: 0;
}
.violations-panel-witness-step {
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 6px;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.violations-panel-witness-step:hover {
border-color: var(--border-strong);
}
.violations-panel-witness-evidence {
display: flex;
flex-direction: column;
gap: 3px;
margin: 0;
}
.violations-panel-residual {
margin-top: 6px;
font-size: 12px;
+69 -1
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { ResidualNode } from "../types";
import type { ResidualNode, Witness } from "../types";
import ResidualNodeView from "../components/ResidualNode";
import "./ViolationsPanel.css";
@@ -7,8 +7,10 @@ export interface ViolationsPanelProps {
propertyNames: string[];
violations: string[];
residuals?: Record<string, ResidualNode>;
witnesses?: Record<string, Witness>;
onJumpToFirstViolation: () => void;
hasFirstViolation: boolean;
onJumpToStep?: (step: number) => void;
/** When true, only render violated rows and hide the header button row. */
violationsOnly?: boolean;
}
@@ -36,12 +38,74 @@ function statusFor(
return "pending";
}
function formatValue(value: unknown): string {
const encoded = JSON.stringify(value);
return encoded === undefined ? String(value) : encoded;
}
function WitnessView({
witness,
onJumpToStep,
open,
}: {
witness: Witness;
onJumpToStep?: (step: number) => void;
open: boolean;
}) {
const evidence = Object.entries(witness.extractors ?? {})
.filter(([, value]) => value !== null && value !== undefined)
.sort(([a], [b]) => a.localeCompare(b));
return (
<div className="violations-panel-witness">
{witness.reason ? (
<div className="violations-panel-witness-line">
<span className="violations-panel-witness-key">
{witness.is_error ? "error" : "reason"}
</span>
<span className="violations-panel-witness-value">{witness.reason}</span>
</div>
) : null}
{witness.step ? (
<div className="violations-panel-witness-line">
<span className="violations-panel-witness-key">caused at</span>
{onJumpToStep ? (
<button
type="button"
className="violations-panel-witness-step"
onClick={() => onJumpToStep(witness.step as number)}
>
step {witness.step}
</button>
) : (
<span className="violations-panel-witness-value">step {witness.step}</span>
)}
</div>
) : null}
{evidence.length > 0 ? (
<details className="violations-panel-residual" open={open}>
<summary>witness</summary>
<dl className="violations-panel-witness-evidence">
{evidence.map(([name, value]) => (
<div key={name} className="violations-panel-witness-line">
<dt className="violations-panel-witness-key">{name}</dt>
<dd className="violations-panel-witness-value">{formatValue(value)}</dd>
</div>
))}
</dl>
</details>
) : null}
</div>
);
}
export default function ViolationsPanel({
propertyNames,
violations,
residuals,
witnesses,
onJumpToFirstViolation,
hasFirstViolation,
onJumpToStep,
violationsOnly = false,
}: ViolationsPanelProps) {
const violationSet = useMemo(() => new Set(violations), [violations]);
@@ -84,6 +148,7 @@ export default function ViolationsPanel({
<ul className="violations-panel-list">
{rows.map(({ name, status }) => {
const residual = residuals?.[name];
const witness = status === "violated" ? witnesses?.[name] : undefined;
return (
<li key={name} className="violations-panel-row" data-status={status}>
<div className="violations-panel-row-head">
@@ -96,6 +161,9 @@ export default function ViolationsPanel({
</span>
<span className="violations-panel-name">{name}</span>
</div>
{witness ? (
<WitnessView witness={witness} onJumpToStep={onJumpToStep} open={violationsOnly} />
) : null}
{residual ? (
<details className="violations-panel-residual">
<summary>residual</summary>
+10
View File
@@ -126,6 +126,8 @@ export default function RunDetail() {
const violationsAfter = nextStep?.violations ?? violationsBefore;
const residualsBefore = currentStep?.residuals;
const residualsAfter = nextStep?.residuals ?? residualsBefore;
const witnessesBefore = currentStep?.witnesses;
const witnessesAfter = nextStep?.witnesses ?? witnessesBefore;
const exceptionsForStep = currentStep?.exceptions;
const beforeTabs: TabDefinition[] = [
@@ -157,8 +159,10 @@ export default function RunDetail() {
propertyNames={history?.names ?? []}
violations={violationsBefore}
residuals={residualsBefore}
witnesses={witnessesBefore}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
onJumpToStep={goTo}
/>
),
},
@@ -176,8 +180,10 @@ export default function RunDetail() {
propertyNames={history?.names ?? []}
violations={violationsBefore}
residuals={residualsBefore}
witnesses={witnessesBefore}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
onJumpToStep={goTo}
violationsOnly
/>
),
@@ -213,8 +219,10 @@ export default function RunDetail() {
propertyNames={history?.names ?? []}
violations={violationsAfter}
residuals={residualsAfter}
witnesses={witnessesAfter}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
onJumpToStep={goTo}
/>
),
},
@@ -232,8 +240,10 @@ export default function RunDetail() {
propertyNames={history?.names ?? []}
violations={violationsAfter}
residuals={residualsAfter}
witnesses={witnessesAfter}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
onJumpToStep={goTo}
violationsOnly
/>
),
+11
View File
@@ -121,6 +121,16 @@ export interface ExtractorChange {
curr: unknown;
}
export interface Witness {
reason?: string;
is_error?: boolean;
// step is the step the failed obligation originated at: the causing step,
// which for deferred obligations (next, eventually) is earlier than the
// step whose record carries the witness.
step?: number;
extractors?: Record<string, unknown>;
}
export interface Step {
step: number;
timestamp: string;
@@ -134,4 +144,5 @@ export interface Step {
residuals?: Record<string, ResidualNode>;
metrics?: Metrics;
extractor_changes?: Record<string, ExtractorChange>;
witnesses?: Record<string, Witness>;
}