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); }