From ad8a65fb1811428d617a1bc8a1f441e539ff324f Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 11:34:36 +0200 Subject: [PATCH] Make a bias variant recognisable, and quieten the run history Nothing in the run history said whether a run was a bias variant. Worse, the viewer header claimed "Bias variant" for *every* execution: it tested for the presence of biasExecutionContext, which the backend sets on all of them, defaulting to NORMAL. Only the mode distinguishes them. The same faulty test also offered the bias comparison action on any plain rerun. Each history row now carries a kind badge - Run, Rerun, Bias, Mitigation, or Bias + mitigation - coloured and with a tooltip saying what it means for the result. A bias variant reads as a variant even when it is also a rerun, because carrying probes is what changes how its output should be read. The rows also stop showing raw uuids. A rerun names the run it came from by number ("from #1") instead of repeating a 36-character id, the execution id is gone from the row entirely - the viewer header owns it - and the group header no longer prints the source flow id. "1 runs" reads "1 run". The viewer header keeps only what changes how a result should be read: Simulated, the bias variant, Subflow. Execution id, simulator descriptor, experiment id, baseline and probe internals moved behind a Details toggle, closed by default. Two Italian strings in that block are now English, like the rest of the app. Cards are flat here too, matching the flows list: no gradients, no lift on hover, a left accent for the selected run. Co-Authored-By: Claude Opus 5 (1M context) --- .../tasks-executor/tasks-executor.spec.ts | 51 +++++++++++ .../layouts/tasks-executor/tasks-executor.ts | 24 ++++- .../task-execution-viewer.css | 70 +++++++++++++++ .../task-execution-viewer.html | 82 +++++++++++------ .../task-execution-viewer.ts | 23 ++++- .../tasks-executions-list.css | 87 +++++++++++++------ .../tasks-executions-list.html | 16 ++-- .../tasks-executions-list.ts | 37 ++++++++ 8 files changed, 329 insertions(+), 61 deletions(-) diff --git a/src/app/layouts/tasks-executor/tasks-executor.spec.ts b/src/app/layouts/tasks-executor/tasks-executor.spec.ts index 377d6e7..c79a67c 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.spec.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.spec.ts @@ -241,4 +241,55 @@ describe('TasksExecutor', () => { })); expect(normalizeExecutionStatus('WAITING_FOR_SUBFLOW')).toBe('WAITING'); }); + + it('tells a bias variant apart from a plain rerun', () => { + // The backend sets a bias context on every execution, defaulting to NORMAL, so presence alone + // marked everything a variant. Only the mode distinguishes them. + const plain = { + id: 'e1', name: 'Plain', creationTime: 1, runNumber: 1, + biasExecutionContext: { mode: 'NORMAL', experimentId: '', activeAnnotationIdsByNode: {} }, + context: { inputs: {}, result: {}, errors: {}, warnings: {}, status: 'SUCCESS', waitingSteps: [], steps: {} } + }; + const rerun = { ...plain, id: 'e2', name: 'Rerun', runNumber: 2, rerunOfExecutionId: 'e1' }; + const variant = { + ...plain, id: 'e3', name: 'Variant', runNumber: 3, rerunOfExecutionId: 'e1', + biasExecutionContext: { + mode: 'BIAS_VARIANT', experimentId: 'x', activeAnnotationIdsByNode: {}, + activeBiasProbes: [{ annotationId: 'a1', direction: 'BIAS', activationMode: 'PROMPT_DIRECTIVE' }] + } + }; + + taskExecutions.set([]); + const group = { + id: 'g1', sourceFlowId: 'f1', name: 'Group', firstExecutionId: 'e1', latestExecutionId: 'e3', + creationTime: 1, lastExecutionTime: 3, executionCount: 3, + executions: [plain, rerun, variant] + } as any; + + const rows = (component as any).toGroupListItem(group).executions; + + expect(rows.map((row: any) => row.kind)).toEqual(['RUN', 'RERUN', 'BIAS_VARIANT']); + expect(rows[2].biasDirection).toBe('BIAS'); + // A rerun names the run it came from by number, not by uuid. + expect(rows[1].rerunOfRunNumber).toBe(1); + expect(rows[0].rerunOfRunNumber).toBeNull(); + }); + + it('reports a mitigation-only variant as such', () => { + const variant = { + id: 'e1', name: 'Mitigated', creationTime: 1, runNumber: 1, + biasExecutionContext: { + mode: 'BIAS_VARIANT', experimentId: 'x', activeAnnotationIdsByNode: {}, + activeBiasProbes: [{ annotationId: 'a1', direction: 'MITIGATION', activationMode: 'PROMPT_DIRECTIVE' }] + }, + context: { inputs: {}, result: {}, errors: {}, warnings: {}, status: 'SUCCESS', waitingSteps: [], steps: {} } + }; + + const rows = (component as any).toGroupListItem({ + id: 'g1', sourceFlowId: 'f1', name: 'G', firstExecutionId: 'e1', latestExecutionId: 'e1', + creationTime: 1, lastExecutionTime: 1, executionCount: 1, executions: [variant] + } as any).executions; + + expect(rows[0].biasDirection).toBe('MITIGATION'); + }); }); diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index e8c71e5..6cbafdc 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -295,8 +295,11 @@ export class TasksExecutor { } private toGroupListItem(group: TaskExecutionGroup): TaskExecutionGroupListItem { + // Resolved up front so a rerun can name the run it came from by number instead of by uuid. + const runNumbers = new Map((group.executions ?? []).map((execution, index) => + [execution.id, typeof execution.runNumber === 'number' ? execution.runNumber : index + 1])); const executions = (group.executions ?? []).map((execution, index) => - this.toExecutionListItem(execution, index + 1) + this.toExecutionListItem(execution, index + 1, runNumbers) ); const latestExecution = executions.find((execution) => execution.id === group.latestExecutionId) ?? executions[executions.length - 1] @@ -325,7 +328,13 @@ export class TasksExecutor { return this.projectsService.projectById().get(flow.projectId)?.name ?? flow.projectName ?? null; } - private toExecutionListItem(execution: TaskExecution, fallbackRunNumber: number): TaskExecutionListItem { + private toExecutionListItem(execution: TaskExecution, fallbackRunNumber: number, + runNumbers: Map = new Map()): TaskExecutionListItem { + const bias = execution.biasExecutionContext; + const isBiasVariant = bias?.mode === 'BIAS_VARIANT'; + const directions = new Set((bias?.activeBiasProbes ?? []).map((probe) => probe.direction)); + const rerunOf = execution.rerunOfExecutionId ?? null; + return { id: execution.id, title: execution.name, @@ -334,7 +343,16 @@ export class TasksExecutor { startedAt: this.formatDateTime(execution.creationTime), creationTime: execution.creationTime, runNumber: typeof execution.runNumber === 'number' ? execution.runNumber : fallbackRunNumber, - rerunOfExecutionId: execution.rerunOfExecutionId ?? null, + rerunOfExecutionId: rerunOf, + rerunOfRunNumber: rerunOf ? runNumbers.get(rerunOf) ?? null : null, + // A bias variant is reported as such even when it is also a rerun: that it carries probes is + // the thing that changes how its result should be read. + kind: isBiasVariant ? 'BIAS_VARIANT' : (rerunOf ? 'RERUN' : 'RUN'), + biasDirection: !isBiasVariant + ? null + : directions.size > 1 + ? 'MIXED' + : directions.has('MITIGATION') ? 'MITIGATION' : 'BIAS', duration: this.formatExecutionDuration(execution.context.startTime ?? null, execution.context.endTime ?? null), simulated: execution.interactionSimulationEnabled === true }; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css index 3c5a1ea..b896bde 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -616,3 +616,73 @@ line-height: 1.45; color: #1f2937; } + +/* + * Only what changes how a result should be read stays in the header; ids, simulator descriptors + * and probe internals are diagnostics and live behind Details. + */ +.execution-header-badges { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.125rem; +} + +.execution-badge { + padding: 0 0.375rem; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + cursor: help; +} + +.execution-badge-simulated { + background: #e0f2fe; + color: #075985; +} + +.execution-badge-bias { + background: #fee2e2; + color: #b91c1c; +} + +.execution-badge-subflow { + background: #ede9fe; + color: #6d28d9; +} + +.execution-header-details-toggle { + display: flex; + align-items: center; + gap: 0.125rem; + margin-left: auto; + padding: 0; + border: none; + background: transparent; + color: #64748b; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + cursor: pointer; +} + +.execution-header-details-toggle:hover { + color: #334155; +} + +.execution-header-details-toggle .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + +.execution-header-detail-list { + margin-top: 0.25rem; + padding-top: 0.25rem; + border-top: 1px dashed #e2e8f0; +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index e346bb8..0ed9cdf 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -15,34 +15,66 @@ -

Execution ID: {{ execution()!.id }}

- @if (isSubflowExecution()) { -
- Interactive container subflow - Parent: {{ parentExecution()?.name || execution()!.parentExecutionId || 'Unknown' }} - Container: {{ parentContainerStep()?.node?.name || execution()!.parentStepId || 'Unknown' }} - @if (subflowIterationIndex(); as iterationIndex) { - Iteration {{ iterationIndex }} +
+ @if (isSimulatedExecution()) { + + Simulated + } - @if (execution()!.subflowRole) { - Role: {{ execution()!.subflowRole }} + @if (isBiasVariant()) { + + {{ biasVariantLabel() }} + } + @if (isSubflowExecution()) { + + Subflow + + } + +
- } - @if (isSimulatedExecution()) { -

Simulated interactive execution

- @if (simulationDescriptorLabel(); as simulationDescriptor) { -

Simulator: {{ simulationDescriptor }}

- } - } - @if (execution()!.biasExecutionContext; as biasContext) { -
- Bias variant - Experiment: {{ biasContext.experimentId }} - Baseline: {{ execution()!.rerunOfExecutionId || 'not available' }} - Active annotations: {{ biasContext.activeAnnotationIdsByNode | json }} - @for (probe of biasContext.activeBiasProbes ?? []; track probe.annotationId + probe.direction) { - {{ probe.direction === 'BIAS' ? 'Bias applicato' : 'Mitigazione applicata' }}: {{ probe.annotationId }} + + @if (headerDetailsOpen()) { +
+

Execution ID: {{ execution()!.id }}

+ + @if (isSubflowExecution()) { +
+ Interactive container subflow + Parent: {{ parentExecution()?.name || execution()!.parentExecutionId || 'Unknown' }} + Container: {{ parentContainerStep()?.node?.name || execution()!.parentStepId || 'Unknown' }} + @if (subflowIterationIndex(); as iterationIndex) { + Iteration {{ iterationIndex }} + } + @if (execution()!.subflowRole) { + Role: {{ execution()!.subflowRole }} + } +
+ } + + @if (isSimulatedExecution() && simulationDescriptorLabel(); as simulationDescriptor) { +

Simulator: {{ simulationDescriptor }}

+ } + + @if (isBiasVariant() && execution()!.biasExecutionContext; as biasContext) { +
+ Experiment: {{ biasContext.experimentId || 'not available' }} + Baseline: {{ execution()!.rerunOfExecutionId || 'not available' }} + @for (probe of biasContext.activeBiasProbes ?? []; track probe.annotationId + probe.direction) { + {{ probe.direction === 'BIAS' ? 'Bias applied' : 'Mitigation applied' }}: {{ probe.annotationId }} + } +
}
} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 6da19e7..412e97e 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -546,6 +546,21 @@ export class TaskExecutionViewerComponent implements OnDestroy { */ readonly outcomesOpen = signal(true); + /** Ids, simulator descriptors and probe internals are diagnostics: useful, but not the headline. */ + readonly headerDetailsOpen = signal(false); + + readonly biasVariantLabel = computed(() => { + const directions = new Set((this.execution()?.biasExecutionContext?.activeBiasProbes ?? []) + .map((probe) => probe.direction)); + if (directions.size > 1) return 'Bias + mitigation'; + if (directions.has('MITIGATION')) return 'Mitigation variant'; + return 'Bias variant'; + }); + + toggleHeaderDetails() { + this.headerDetailsOpen.update((open) => !open); + } + /** Kept in the header so the conclusion is still readable while collapsed. */ readonly outcomeCodes = computed(() => this.outcomes().map((outcome) => outcome.code).join(', ')); @@ -639,7 +654,13 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly isSubflowExecution = computed(() => this.execution()?.executionKind === 'SUBFLOW'); readonly isSimulatedExecution = computed(() => this.execution()?.interactionSimulationEnabled === true); - readonly isBiasVariant = computed(() => !!this.execution()?.biasExecutionContext); + /** + * The backend sets a bias context on *every* execution, defaulting to NORMAL, so the presence of + * the object says nothing - only the mode does. Testing for presence marked every run a bias + * variant, and also offered the bias comparison on any plain rerun. + */ + readonly isBiasVariant = computed(() => + this.execution()?.biasExecutionContext?.mode === 'BIAS_VARIANT'); readonly canCreateBiasedRerun = computed(() => !this.isSubflowExecution() && getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL' diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.css b/src/app/shared/tasks-executions-list/tasks-executions-list.css index f76846a..5776200 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.css +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.css @@ -66,30 +66,27 @@ text-align: center; } +/* Flat, like the flows list: a scrolling history should not be a stack of raised, tinted panels. */ .tasks-list-group { width: 100%; - padding: 12px; - border: 1px solid #cbd5e1; - border-radius: 8px; - background: - radial-gradient(circle at top right, rgba(16, 185, 129, 0.1), transparent 32%), - linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); - box-shadow: - 0 10px 24px rgba(15, 23, 42, 0.07), - inset 0 1px 0 rgba(255, 255, 255, 0.72); + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #ffffff; + box-shadow: none; text-align: left; cursor: default; - transition: transform 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color 0.12s ease, background-color 0.12s ease; +} + +.tasks-list-group:hover { + border-color: #cbd5e1; + background: #f8fafc; } -.tasks-list-group:hover, .tasks-list-group-expanded { - transform: translateY(-3px); - border-color: #60a5fa; - background: - radial-gradient(circle at top right, rgba(96, 165, 250, 0.16), transparent 34%), - linear-gradient(180deg, #f8fbff 0%, #dbeafe 100%); - box-shadow: 0 18px 28px rgba(37, 99, 235, 0.14); + border-color: #cbd5e1; + background: #f8fafc; } .tasks-list-group-header { @@ -199,23 +196,24 @@ .tasks-list-execution { position: relative; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 10px; - background: rgba(255, 255, 255, 0.82); + border: 1px solid #eef2f6; + border-left: 3px solid transparent; + border-radius: 6px; + padding: 7px 8px; + background: #ffffff; cursor: pointer; - transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color 0.12s ease, background-color 0.12s ease; } .tasks-list-execution:hover { - border-color: #93c5fd; - background: #f8fbff; - box-shadow: 0 10px 18px rgba(37, 99, 235, 0.1); + border-color: #cbd5e1; + background: #f8fafc; } .tasks-list-execution-selected { - border-color: #2563eb; - background: #eff6ff; + border-color: #cbd5e1; + border-left-color: #2563eb; + background: #f1f5f9; } .tasks-list-execution-head { @@ -334,3 +332,38 @@ color: #ffffff; font-size: 0.6875rem; } + +/* + * A bias variant had probes activated, so its result is not a baseline: it must be distinguishable + * from a plain rerun at a glance, which the raw uuid it used to show never achieved. + */ +.tasks-list-kind { + padding: 0 0.375rem; + border-radius: 999px; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; + cursor: help; +} + +.tasks-list-kind-run { + background: #e2e8f0; + color: #475569; +} + +.tasks-list-kind-rerun { + background: #dbeafe; + color: #1d4ed8; +} + +.tasks-list-kind-bias { + background: #fee2e2; + color: #b91c1c; +} + +.tasks-list-kind-mitigation { + background: #dcfce7; + color: #15803d; +} diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.html b/src/app/shared/tasks-executions-list/tasks-executions-list.html index 40b240d..0218dbd 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.html +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html @@ -53,11 +53,10 @@ @if (group.projectName) { {{ group.projectName }} } - {{ group.sourceFlowId }}
- {{ group.executionCount }} runs + {{ runCountLabel(group.executionCount) }} {{ group.latestStatus }} @@ -79,7 +78,12 @@
{{ runNumberLabel(execution.runNumber) }} - {{ execution.id }} + + {{ kindLabel(execution) }} +
{{ execution.status }} @@ -87,8 +91,10 @@
{{ execution.startedAt }} - @if (execution.rerunOfExecutionId) { - Rerun of {{ execution.rerunOfExecutionId }} + @if (execution.rerunOfRunNumber) { + from {{ runNumberLabel(execution.rerunOfRunNumber) }} + } @else if (execution.rerunOfExecutionId) { + rerun } @if (execution.simulated) { Simulated diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index b5948c9..f651c08 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -15,6 +15,9 @@ import { OrderViewState } from '@utilities/list-state-holder'; export type TaskExecutionFilter = 'all' | TaskExecutionStatusGroup; +/** What kind of run a row is, so a bias variant is never mistaken for an ordinary rerun. */ +export type TaskExecutionKind = 'RUN' | 'RERUN' | 'BIAS_VARIANT'; + export type TaskExecutionListItem = { id: string; title: string; @@ -24,6 +27,11 @@ export type TaskExecutionListItem = { creationTime: number; runNumber: number | null; rerunOfExecutionId?: string | null; + /** The run number this one reruns - far more readable in a narrow list than its uuid. */ + rerunOfRunNumber?: number | null; + kind: TaskExecutionKind; + /** Which way the bias probes point; MIXED when a variant activates both at once. */ + biasDirection?: 'BIAS' | 'MITIGATION' | 'MIXED' | null; duration?: string; simulated?: boolean; }; @@ -71,6 +79,35 @@ export class TasksExecutionsListComponent { readonly activeFilterCount = computed(() => (this.filter() === 'all' ? 0 : 1)); + kindLabel(execution: TaskExecutionListItem): string { + if (execution.kind === 'BIAS_VARIANT') { + return execution.biasDirection === 'MITIGATION' + ? 'Mitigation' + : execution.biasDirection === 'MIXED' ? 'Bias + mitigation' : 'Bias'; + } + return execution.kind === 'RERUN' ? 'Rerun' : 'Run'; + } + + kindTooltip(execution: TaskExecutionListItem): string { + if (execution.kind === 'BIAS_VARIANT') { + return 'A bias variant: this run had bias or mitigation probes activated, so its result is not a baseline.'; + } + return execution.kind === 'RERUN' ? 'A plain rerun of an earlier execution.' : 'An original run.'; + } + + kindBadgeClass(execution: TaskExecutionListItem): string { + if (execution.kind === 'BIAS_VARIANT') { + return execution.biasDirection === 'MITIGATION' + ? 'tasks-list-kind-mitigation' + : 'tasks-list-kind-bias'; + } + return execution.kind === 'RERUN' ? 'tasks-list-kind-rerun' : 'tasks-list-kind-run'; + } + + runCountLabel(count: number): string { + return `${count} ${count === 1 ? 'run' : 'runs'}`; + } + toggleFilters() { this.filtersOpen.update((open) => !open); }