Make the bias experiment reachable, and say why when it is not

The Bias impact tab is empty because no experiment has ever run - zero rows in
bias_impact_report_entity - and nothing in the UI said how one is started.

The empty state now explains what a report is, says how many nodes carry a probe
that can be activated on this run, and offers the action. When something is in
the way it states that instead: a subflow cannot be a baseline, an unfinished run
cannot be compared against a rerun, a flow with no activatable probe has nothing
to measure.

"Create biased rerun" no longer returns silently when no node qualifies - a
button that does nothing and explains nothing is indistinguishable from a broken
one. It now names the reason.

The per-node measure control is shown on interactive blocks instead of hidden.
ChatInteraction cannot be replayed in isolation, so the isolated experiment is
genuinely unavailable there - but hiding the control made an annotated node look
identical to an unannotated one. It is rendered disabled, and the tooltip points
at the full-flow route that does work. That is a rule of the domain, worth
stating rather than concealing.

Splitting the node scan out of biasRerunCandidates keeps this free: the count is
computed from the flow snapshot, while only the capability check needs the
network.

506 frontend tests green; each new assertion fails when its behaviour is undone.
Initial bundle now 2.86 kB over budget, up from 2.21 kB - reported, not raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 22:10:16 +02:00
parent 53ac229790
commit b2d744fe42
8 changed files with 231 additions and 11 deletions

View File

@ -2,7 +2,32 @@
.bias-report-list__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; font-size: .84rem; }
.bias-report-list__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; font-size: .84rem; }
.bias-report-list__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }
.bias-report-list__empty { color: #64748b; font-size: .84rem; margin: 0; padding: .5rem; }
/* An empty tab that only said "none yet" left the user with no idea how one is produced. */
.bias-report-list__empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: .5rem;
color: #64748b;
font-size: .84rem;
padding: .5rem;
}
.bias-report-list__empty p { margin: 0; }
.bias-report-list__empty-lead { color: #334155; font-weight: 600; }
.bias-report-list__empty-body { line-height: 1.45; }
/* The precondition that is in the way, stated instead of left to be guessed from a dead button. */
.bias-report-list__empty-blocked {
border-left: 3px solid #f59e0b;
background: #fffbeb;
border-radius: 4px;
padding: .4rem .55rem;
color: #92400e;
line-height: 1.45;
}
.bias-report-list__row {
align-items: center;

View File

@ -21,7 +21,25 @@
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
} @else if (!reports().length) {
<p class="bias-report-list__empty">No bias impact reports for this execution yet.</p>
<div class="bias-report-list__empty">
<p class="bias-report-list__empty-lead">No bias impact reports for this run yet.</p>
<p class="bias-report-list__empty-body">
A report compares this run against the same run with its bias or mitigation probes turned
on, and shows which node outputs it changed. Reports appear here once you produce one.
</p>
@if (blockedReason(); as reason) {
<p class="bias-report-list__empty-blocked">{{ reason }}</p>
} @else {
<p class="bias-report-list__empty-body">
{{ annotatedNodeCount() }}
{{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }}
that can be activated on this run.
</p>
<button type="button" mat-flat-button (click)="startExperimentRequested.emit()">
Run a biased rerun
</button>
}
</div>
} @else {
@for (report of reports(); track report.id) {
<button type="button" class="bias-report-list__row" (click)="openDetail(report.id)">

View File

@ -53,6 +53,46 @@ describe('BiasImpactReportListComponent', () => {
fixture = TestBed.createComponent(BiasImpactReportListComponent);
});
it('explains how a report is produced, and offers the action, when there are none', () => {
// "No bias impact reports yet" alone left no clue that reports come from an experiment you
// have to start, nor where to start one.
listBiasImpactReports.mockReturnValue(of([]));
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.componentRef.setInput('annotatedNodeCount', 3);
fixture.componentRef.setInput('blockedReason', null);
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('No bias impact reports for this run yet');
expect(text).toContain('3 nodes carry a probe');
const started = vi.fn();
fixture.componentInstance.startExperimentRequested.subscribe(started);
fixture.nativeElement.querySelector('.bias-report-list__empty button').click();
expect(started).toHaveBeenCalledTimes(1);
});
it('states the precondition in the way, instead of offering an action that cannot work', () => {
listBiasImpactReports.mockReturnValue(of([]));
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.componentRef.setInput('annotatedNodeCount', 0);
fixture.componentRef.setInput('blockedReason', 'This run has not finished yet.');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.bias-report-list__empty-blocked').textContent)
.toContain('has not finished yet');
expect(fixture.nativeElement.querySelector('.bias-report-list__empty button')).toBeNull();
});
it('counts one annotated node in the singular', () => {
listBiasImpactReports.mockReturnValue(of([]));
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.componentRef.setInput('annotatedNodeCount', 1);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('1 node carries a probe');
});
it('reloads when a dialog reports that one has just been produced', () => {
// The experiment and compare dialogs render over this still-mounted tab. Without this the tab
// kept saying there were no reports for the execution whose report the user was just reading.

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReport } from '@models/bias-impact';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
@ -23,6 +23,13 @@ export class BiasImpactReportListComponent {
private lastReloadToken = 0;
readonly executionId = input<string | null>(null);
/** How many nodes an experiment could act on, so the empty state can be concrete. */
readonly annotatedNodeCount = input<number>(0);
/** Why an experiment cannot be started, or null when it can; supplied by the host. */
readonly blockedReason = input<string | null>(null);
/** The user wants to start one. The host owns the dialog, so it decides what "start" means. */
readonly startExperimentRequested = output<void>();
readonly reports = signal<BiasImpactReport[]>([]);
readonly loading = signal(false);

View File

@ -121,6 +121,72 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
fixture.detectChanges();
}
it('shows the measure control on an interactive node, disabled, saying what is possible instead', () => {
// ChatInteraction cannot be replayed in isolation. Hiding the control made an annotated node
// look identical to an unannotated one, with nothing to indicate the full-flow route exists.
component.data.data.capabilities = {
visualRole: 'ACTIVITY',
terminal: false,
biasAnnotationsAllowed: true,
allowsIncomingConnections: true,
allowsOutgoingConnections: true,
canDependOnOtherNodes: false,
canHaveDependentNodes: false
};
component.data.data.biasAnnotations = [
{ id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
];
(component as any).biasCapabilities = { isolatedExperimentSupported: false };
setStepConfig({ __executionStatusGroup: 'FINAL' });
expect(component.hasMeasurableBiasAnnotations()).toBe(true);
expect(component.canMeasureBiasImpact()).toBe(false);
const button = fixture.nativeElement.querySelector('.llm-bias-impact-trigger');
expect(button).not.toBeNull();
expect(button.disabled).toBe(true);
expect(button.getAttribute('title')).toContain('Create biased rerun');
});
it('enables the measure control where an isolated experiment is supported and the run is final', () => {
component.data.data.capabilities = {
visualRole: 'ACTIVITY',
terminal: false,
biasAnnotationsAllowed: true,
allowsIncomingConnections: true,
allowsOutgoingConnections: true,
canDependOnOtherNodes: false,
canHaveDependentNodes: false
};
component.data.data.biasAnnotations = [
{ id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
];
(component as any).biasCapabilities = { isolatedExperimentSupported: true };
setStepConfig({ __executionStatusGroup: 'FINAL' });
expect(component.canMeasureBiasImpact()).toBe(true);
expect(component.measureBiasImpactTooltip()).toBe('Measure bias impact');
});
it('still points at the final-state precondition when the run is not finished', () => {
component.data.data.capabilities = {
visualRole: 'ACTIVITY',
terminal: false,
biasAnnotationsAllowed: true,
allowsIncomingConnections: true,
allowsOutgoingConnections: true,
canDependOnOtherNodes: false,
canHaveDependentNodes: false
};
component.data.data.biasAnnotations = [
{ id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
];
(component as any).biasCapabilities = { isolatedExperimentSupported: true };
setStepConfig({ __executionStatusGroup: 'RUNNING' });
expect(component.canMeasureBiasImpact()).toBe(false);
expect(component.measureBiasImpactTooltip()).toContain('final state');
});
it('shows a container working on its subflow, which its own status never said', () => {
// The container's status is WAITING_FOR_SUBFLOW, so it took none of the in-progress styling
// and sat inert for minutes while its child ran - indistinguishable from a stuck run.

View File

@ -501,17 +501,34 @@ export class TaskStepNodeComponent {
].join('\n');
}
/**
* Whether the node shows the measure control at all: it has probes worth measuring.
*
* It deliberately does *not* require isolatedExperimentSupported. A user-interactive block -
* ChatInteraction, HumanDecision - cannot be replayed in isolation, and hiding the button there
* left an annotated node looking identical to an unannotated one. The control is shown and
* disabled, and the tooltip says which experiment is available instead.
*/
hasMeasurableBiasAnnotations(): boolean {
return this.isBiasCapable()
&& this.executableBiasAnnotations().length > 0
&& this.biasCapabilities?.isolatedExperimentSupported === true;
return this.isBiasCapable() && this.executableBiasAnnotations().length > 0;
}
private supportsIsolatedBiasExperiment(): boolean {
return this.biasCapabilities?.isolatedExperimentSupported === true;
}
canMeasureBiasImpact(): boolean {
return this.hasMeasurableBiasAnnotations() && this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
return this.hasMeasurableBiasAnnotations()
&& this.supportsIsolatedBiasExperiment()
&& this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
}
measureBiasImpactTooltip(): string {
if (!this.supportsIsolatedBiasExperiment()) {
// A rule of the domain, not a fault: say what *is* possible rather than only what is not.
return 'This node cannot be replayed on its own, so there is no isolated experiment for it. '
+ 'Use "Create biased rerun" on the toolbar to measure it as part of the whole flow.';
}
if (this.blockConfiguration?.['__executionStatusGroup'] !== 'FINAL') {
return 'Available once the execution reaches a final state';
}

View File

@ -535,7 +535,11 @@
}
</div>
} @else {
<app-bias-impact-report-list [executionId]="execution()!.id" />
<app-bias-impact-report-list
[executionId]="execution()!.id"
[annotatedNodeCount]="biasAnnotatedNodeCount()"
[blockedReason]="biasExperimentBlockedReason()"
(startExperimentRequested)="openBiasedRerunDialog()" />
}
</div>
</aside>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, inject, input, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationService } from '@services/notifications/notification';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@ -120,6 +121,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private biasRerunDialog = inject(BiasRerunDialogService);
private biasCompareDialog = inject(BiasCompareDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
private notifications = inject(NotificationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private lastExecutionId: string | null = null;
@ -682,6 +684,28 @@ export class TaskExecutionViewerComponent implements OnDestroy {
&& getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
&& !this.biasRerunOpening()
);
/** How many nodes an experiment could act on. Sync, so the empty state can say it for free. */
readonly biasAnnotatedNodeCount = computed(() => this.biasAnnotatedNodes().length);
/**
* Why a bias experiment cannot be started here, or null when it can.
*
* The preconditions were only ever expressed as a disabled control or, worse, a silent return.
* Stated as a sentence they are also what the empty Bias impact tab needs to explain itself.
*/
readonly biasExperimentBlockedReason = computed<string | null>(() => {
if (this.isSubflowExecution()) {
return 'A subflow run cannot be a baseline. Open its parent run to start an experiment.';
}
if (getExecutionStatusGroup(this.execution()?.context.status) !== 'FINAL') {
return 'This run has not finished yet. An experiment compares it against a rerun, so it needs a completed baseline.';
}
if (this.biasAnnotatedNodeCount() === 0) {
return 'No node in this flow carries a bias probe that can be activated. Add an instruction to a bias or mitigation annotation in the editor first.';
}
return null;
});
readonly canCompareBiasExecution = computed(() =>
!this.isSubflowExecution()
&& this.isBiasVariant()
@ -1081,7 +1105,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.biasRerunOpening.set(true);
try {
const candidates = await this.biasRerunCandidates();
if (!candidates.length) return;
if (!candidates.length) {
// Returning silently made the button indistinguishable from a broken one. The reason is
// knowable: either nothing carries an activatable probe, or the nodes that do are of a
// type the backend will not run a full-flow experiment on.
this.notifications.show(
this.biasExperimentBlockedReason()
?? 'None of the annotated nodes in this flow support a full-flow bias experiment.',
'info',
6000
);
return;
}
this.biasRerunDialog.open({
executionId: execution.id,
candidates,
@ -1534,8 +1569,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return this.getExecutionDependencies().some((dependency) => String(dependency.targetId) === stepId);
}
private async biasRerunCandidates(): Promise<BiasRerunCandidate[]> {
const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
/**
* The nodes carrying something an experiment could activate. Split out of biasRerunCandidates
* because it needs no network: the capability check does, and the empty state wants a count
* without firing a request per node type just to render a sentence.
*/
private biasAnnotatedNodes(): Array<{ nodeId: string; nodeName: string; node: FlowNode }> {
return this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
const node = mergeExecutionStepNode(
step,
this.execution()?.flowSnapshot ?? this.sourceFlowData()
@ -1552,7 +1592,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const annotations = (block.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.biasProbe) || isProbeExecutable(annotation.mitigationProbe));
return annotations.length ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...block, biasAnnotations: annotations } }] : [];
});
}
private async biasRerunCandidates(): Promise<BiasRerunCandidate[]> {
const candidates = this.biasAnnotatedNodes();
const resolved = await Promise.all(candidates.map(async (candidate) => {
const capabilities = await firstValueFrom(
candidate.node.nodeFamily === 'container'