diff --git a/src/app/shared/bias-annotations/bias-annotations.spec.ts b/src/app/shared/bias-annotations/bias-annotations.spec.ts
index 048ed9a..ba37a2f 100644
--- a/src/app/shared/bias-annotations/bias-annotations.spec.ts
+++ b/src/app/shared/bias-annotations/bias-annotations.spec.ts
@@ -4,6 +4,7 @@ import { BiasAnnotationsDescriptor } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { EditorStateHolder } from '@stores/flow-editor';
import { vi } from 'vitest';
+import { of } from 'rxjs';
import { BiasAnnotationsComponent } from './bias-annotations';
const descriptor: BiasAnnotationsDescriptor = {
@@ -33,7 +34,22 @@ describe('BiasAnnotationsComponent', () => {
await TestBed.configureTestingModule({
imports: [BiasAnnotationsComponent],
providers: [
- { provide: BlocksService, useValue: { biasAnnotationsDescriptor: signal(descriptor) } },
+ {
+ provide: BlocksService,
+ useValue: {
+ biasAnnotationsDescriptor: signal(descriptor),
+ retrieveBiasCapabilities: () => of({
+ blockType: 'LLMBlock', supported: true, isolatedExperimentSupported: true,
+ fullFlowExperimentSupported: true, externalSideEffects: false,
+ configurationDependent: false, activationModes: ['PROMPT_DIRECTIVE']
+ }),
+ retrieveBiasCapabilitiesForInstance: () => of({
+ blockType: 'LLMBlock', supported: true, isolatedExperimentSupported: true,
+ fullFlowExperimentSupported: true, externalSideEffects: false,
+ configurationDependent: false, activationModes: ['PROMPT_DIRECTIVE']
+ })
+ }
+ },
{ provide: EditorStateHolder, useValue: { flowValidationErrors: validationErrors } }
]
}).compileComponents();
@@ -93,4 +109,14 @@ describe('BiasAnnotationsComponent', () => {
expect(component.serverError(0, 'category')).toBe('Category required');
expect(component.serverError(1, 'category')).toBeNull();
});
+
+ it('maps typed mock-output probe errors to nested fields', () => {
+ validationErrors.set([{
+ code: 'BIAS_PROBE_MOCK_OUTPUT_TYPE_MISMATCH',
+ id: 'block-1',
+ field: 'biasAnnotations[0].behavioralProbe.mockOutputs.response',
+ message: 'Response must be text'
+ }]);
+ expect(component.serverError(0, 'behavioralProbe.mockOutputs.response')).toBe('Response must be text');
+ });
});
diff --git a/src/app/shared/bias-annotations/bias-annotations.ts b/src/app/shared/bias-annotations/bias-annotations.ts
index 2174d3f..4c72e30 100644
--- a/src/app/shared/bias-annotations/bias-annotations.ts
+++ b/src/app/shared/bias-annotations/bias-annotations.ts
@@ -1,9 +1,19 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { BiasAnnotation, BiasAnnotationOption, BiasAnnotationsDescriptor, FlowValidationError } from '@models/flow';
+import {
+ BiasAnnotation,
+ BiasAnnotationOption,
+ BiasAnnotationsDescriptor,
+ BehavioralProbe,
+ FlowBlock,
+ FlowValidationError,
+ isProbeExecutable
+} from '@models/flow';
+import { BIAS_PROBE_ERROR_CODES } from '@models/bias-impact';
import { BlocksService } from '@services/blocks/blocks';
import { EditorStateHolder } from '@stores/flow-editor';
+import { BehavioralProbeEditorComponent } from '@shared/behavioral-probe-editor/behavioral-probe-editor';
type BiasField = {
key: string;
@@ -18,13 +28,14 @@ type BiasField = {
const BIAS_ERROR_CODES = new Set([
'TOO_MANY_BIAS_ANNOTATIONS', 'NULL_BIAS_ANNOTATION', 'DUPLICATE_BIAS_ANNOTATION_ID',
- 'BIAS_CATEGORY_REQUIRED', 'BIAS_SEVERITY_REQUIRED', 'BIAS_ISSUE_REQUIRED', 'BIAS_FIELD_TOO_LONG'
+ 'BIAS_CATEGORY_REQUIRED', 'BIAS_SEVERITY_REQUIRED', 'BIAS_ISSUE_REQUIRED', 'BIAS_FIELD_TOO_LONG',
+ ...BIAS_PROBE_ERROR_CODES
]);
@Component({
selector: 'app-bias-annotations',
standalone: true,
- imports: [CommonModule, FormsModule],
+ imports: [CommonModule, FormsModule, BehavioralProbeEditorComponent],
templateUrl: './bias-annotations.html',
styleUrl: './bias-annotations.css',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -34,6 +45,7 @@ export class BiasAnnotationsComponent {
private readonly editorState = inject(EditorStateHolder);
@Input({ required: true }) blockId = '';
+ @Input() block: FlowBlock | null = null;
@Input() annotations: BiasAnnotation[] = [];
@Input() readonly = false;
@Output() annotationsChange = new EventEmitter
();
@@ -62,6 +74,7 @@ export class BiasAnnotationsComponent {
return Object.entries(properties)
.filter(([key]) => !generated.has(key))
+ .filter(([key]) => key !== 'behavioralProbe')
.map(([key, raw]) => {
const field = this.record(raw);
const maxLength = Number(field['maxLength']);
@@ -152,6 +165,14 @@ export class BiasAnnotationsComponent {
return field.options.find((option) => option.value === value)?.description ?? field.description ?? null;
}
+ probeExecutable(annotation: BiasAnnotation): boolean {
+ return isProbeExecutable(annotation.behavioralProbe);
+ }
+
+ updateProbe(probe: BehavioralProbe | undefined) {
+ this.draft = { ...this.draft, behavioralProbe: probe };
+ }
+
valueLength(field: string): number {
return String(this.draft[field] ?? '').length;
}
diff --git a/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.css b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.css
new file mode 100644
index 0000000..221dee5
--- /dev/null
+++ b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.css
@@ -0,0 +1,7 @@
+.bias-experiment-dialog__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
+.bias-experiment-dialog { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: 680px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, 680px); z-index: 10021; }
+header, footer { align-items: center; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; } header { border-bottom: 1px solid #e2e8f0; } header h3, header p { margin: 0; } header p { color: #64748b; font-size: .85rem; }
+.bias-experiment-dialog__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; } .bias-experiment-dialog__content > p { margin: 0; }
+fieldset { display: grid; gap: .5rem; } fieldset label, .bias-experiment-dialog__content > label { color: #334155; font-size: .87rem; }
+.bias-experiment-dialog__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; padding: .6rem .7rem; }
+.bias-experiment-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; } footer { border-top: 1px solid #e2e8f0; justify-content: flex-end; }
diff --git a/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html
new file mode 100644
index 0000000..9e7188c
--- /dev/null
+++ b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html
@@ -0,0 +1,24 @@
+@if (state(); as currentState) {
+
+
+
+ @if (report(); as completedReport) {
+
+ } @else {
+
+
Run the selected probes against this completed execution step.
+
+
+
+
+ @if (currentJob(); as job) {
{{ job.status === 'QUEUED' ? 'Experiment queued…' : 'Experiment running…' }}
}
+ @if (inlineError(); as error) {
{{ error }}
}
+
+
+ }
+
+}
diff --git a/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.ts b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.ts
new file mode 100644
index 0000000..de9b54c
--- /dev/null
+++ b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.ts
@@ -0,0 +1,135 @@
+import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
+import { SideEffectPolicySelectorComponent } from '@shared/side-effect-policy-selector/side-effect-policy-selector';
+import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
+import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
+import { NotificationService } from '@services/notifications/notification';
+import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { BiasImpactJob, BiasImpactReport, BiasSideEffectError, ExternalSideEffectPolicy } from '@models/bias-impact';
+import { Subscription } from 'rxjs';
+
+@Component({
+ selector: 'app-bias-impact-experiment-dialog-host',
+ standalone: true,
+ imports: [FormsModule, MatButtonModule, BiasImpactReportViewerComponent, SideEffectPolicySelectorComponent],
+ templateUrl: './bias-impact-experiment-dialog.html',
+ styleUrl: './bias-impact-experiment-dialog.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class BiasImpactExperimentDialogHostComponent {
+ private readonly dialog = inject(BiasImpactExperimentDialogService);
+ private readonly executions = inject(TaskExecutionsService);
+ private readonly confirmation = inject(ConfirmDialogService);
+ private readonly notifications = inject(NotificationService);
+ private readonly destroyRef = inject(DestroyRef);
+ private pollSubscription: Subscription | null = null;
+
+ readonly state = this.dialog.state;
+ readonly selectedAnnotationIds = signal([]);
+ readonly repetitions = signal(3);
+ readonly includeRawOutputs = signal(true);
+ readonly policy = signal('BLOCK');
+ readonly submitting = signal(false);
+ readonly currentJob = signal(null);
+ readonly inlineError = signal(null);
+ readonly report = signal(null);
+
+ constructor() {
+ effect(() => {
+ const state = this.state();
+ this.cancelPolling();
+ this.selectedAnnotationIds.set(state?.annotations.map((annotation) => String(annotation.id ?? '')).filter(Boolean) ?? []);
+ this.repetitions.set(3);
+ this.includeRawOutputs.set(true);
+ this.policy.set('BLOCK');
+ this.submitting.set(false);
+ this.currentJob.set(null);
+ this.inlineError.set(null);
+ this.report.set(null);
+ });
+ this.destroyRef.onDestroy(() => this.cancelPolling());
+ }
+
+ toggleAnnotation(id: string, checked: boolean) {
+ this.selectedAnnotationIds.update((current) => checked ? [...new Set([...current, id])] : current.filter((value) => value !== id));
+ }
+
+ updateRepetitions(value: number) {
+ this.repetitions.set(Math.min(10, Math.max(1, Number.isFinite(value) ? Math.round(value) : 3)));
+ }
+
+ async submit() {
+ const state = this.state();
+ if (!state || this.submitting() || this.currentJob()) return;
+ if (!this.selectedAnnotationIds().length) {
+ this.inlineError.set('Select at least one executable bias annotation.');
+ return;
+ }
+
+ let confirmExternalSideEffects = false;
+ if (this.policy() === 'REQUIRE_CONFIRMATION') {
+ confirmExternalSideEffects = await this.confirmation.open('This experiment may invoke real external HTTP or MCP calls. Do you want to continue?');
+ if (!confirmExternalSideEffects) return;
+ }
+
+ this.submitting.set(true);
+ this.inlineError.set(null);
+ this.executions.runBiasImpactExperiment(state.executionId, state.stepId, {
+ annotationIds: this.selectedAnnotationIds(),
+ repetitions: this.repetitions(),
+ includeRawOutputs: this.includeRawOutputs(),
+ externalSideEffectPolicy: this.policy(),
+ confirmExternalSideEffects
+ }).subscribe({
+ next: (job) => this.startPolling(job),
+ error: (error) => this.handleInitialError(error)
+ });
+ }
+
+ close() {
+ this.cancelPolling();
+ this.dialog.close();
+ }
+
+ private startPolling(job: BiasImpactJob) {
+ this.currentJob.set(job);
+ this.pollSubscription = this.executions.pollBiasImpactJob(job.id).subscribe({
+ next: (nextJob) => {
+ this.currentJob.set(nextJob);
+ if (!nextJob.terminal) return;
+ this.submitting.set(false);
+ this.cancelPolling();
+ if (nextJob.status === 'COMPLETED' && nextJob.report) this.report.set(nextJob.report);
+ else this.inlineError.set(nextJob.errorMessage || 'The bias impact experiment failed.');
+ },
+ error: (error) => {
+ this.submitting.set(false);
+ this.currentJob.set(null);
+ this.inlineError.set(error instanceof Error ? error.message : 'Unable to retrieve the experiment status.');
+ }
+ });
+ }
+
+ private handleInitialError(error: unknown) {
+ this.submitting.set(false);
+ const sideEffectError = error as Partial;
+ if (sideEffectError.reason === 'SIDE_EFFECT_BLOCKED' || sideEffectError.reason === 'CONFIRMATION_REQUIRED') {
+ this.inlineError.set(sideEffectError.message ?? 'External side effects require a different policy.');
+ return;
+ }
+ const status = (error as { status?: number })?.status;
+ if (status === 404 || status === 400) {
+ this.notifications.show(error instanceof Error ? error.message : 'The execution is no longer eligible for this experiment.', 'error');
+ this.close();
+ return;
+ }
+ this.inlineError.set(error instanceof Error ? error.message : 'Unable to start the bias impact experiment.');
+ }
+
+ private cancelPolling() {
+ this.pollSubscription?.unsubscribe();
+ this.pollSubscription = null;
+ }
+}
diff --git a/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.css b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.css
new file mode 100644
index 0000000..9cdb582
--- /dev/null
+++ b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.css
@@ -0,0 +1,21 @@
+.bias-impact-report { color: #1e293b; display: grid; gap: 1rem; }
+.bias-impact-report__header, .bias-impact-report__section-heading, .bias-impact-report__node-heading, .bias-impact-report__routing { align-items: center; display: flex; gap: .65rem; justify-content: space-between; }
+.bias-impact-report__header h3, .bias-impact-report__section h4 { margin: 0; }
+.bias-impact-report__header time { color: #64748b; font-size: .84rem; }
+.bias-impact-report__kind { background: #e0e7ff; border-radius: 999px; color: #3730a3; font-size: .72rem; font-weight: 700; padding: .2rem .5rem; }
+.bias-impact-report__metadata { display: grid; gap: .55rem; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin: 0; }
+.bias-impact-report__metadata div { background: #f8fafc; border-radius: .4rem; padding: .55rem; }
+dt { color: #64748b; font-size: .73rem; } dd { font-size: .82rem; margin: .15rem 0 0; overflow-wrap: anywhere; }
+.bias-impact-report__summary { background: #eff6ff; border-left: 3px solid #2563eb; margin: 0; padding: .6rem .75rem; }
+.bias-impact-report__section { border-top: 1px solid #e2e8f0; padding-top: .9rem; }
+.bias-impact-report__metrics { display: flex; flex-wrap: wrap; gap: .5rem 1.2rem; font-size: .86rem; margin: .6rem 0; }
+.bias-impact-report__empty { color: #64748b; font-size: .87rem; }
+.bias-impact-report__downstream { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .45rem; margin-top: .7rem; padding: .7rem; }
+.bias-impact-report__node-heading { justify-content: flex-start; } .bias-impact-report__node-heading span { color: #64748b; font-size: .8rem; } .bias-impact-report__node-heading span.changed { color: #b45309; font-weight: 700; }
+.bias-impact-report__downstream p { font-size: .84rem; margin: .5rem 0; }
+.bias-impact-report__routing { border-bottom: 1px solid #f1f5f9; font-size: .86rem; padding: .45rem 0; justify-content: flex-start; }
+.bias-impact-report__side-effects { margin: .55rem 0 0; padding-left: 1.2rem; } .bias-impact-report__side-effects li { margin: .3rem 0; } .bias-impact-report__side-effects span { color: #7c3aed; font-size: .78rem; font-weight: 700; }
+.bias-impact-report__notice { background: #fffbeb; border-left: 3px solid #f59e0b; font-size: .84rem; margin: 0; padding: .6rem .75rem; }
+.bias-impact-report__warnings { background: #fff7ed; border: 1px solid #fed7aa; border-radius: .45rem; padding: .8rem; }
+code { font-size: .78rem; overflow-wrap: anywhere; }
+@media (max-width: 700px) { .bias-impact-report__header, .bias-impact-report__section-heading { align-items: flex-start; flex-direction: column; } }
diff --git a/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.html b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.html
new file mode 100644
index 0000000..e850c18
--- /dev/null
+++ b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.html
@@ -0,0 +1,89 @@
+@if (report; as currentReport) {
+
+
+
+
+ - Baseline execution
- {{ currentReport.baselineExecutionId }}
+ @if (currentReport.biasedExecutionId) { - Biased execution
- {{ currentReport.biasedExecutionId }}
}
+ - Experiment
- {{ currentReport.experimentId }}
+ @if (currentReport.nodeId) { - Node
- {{ currentReport.nodeId }}
}
+ - Annotations
- {{ currentReport.annotationIds.join(', ') || 'None' }}
+ - Repetitions
- {{ currentReport.repetitions }}
+
+
+ {{ currentReport.summary }}
+
+
+ Immediate impact
+
+ Output changed: {{ currentReport.immediateImpact.outputChanged ? 'Yes' : 'No' }}
+ Change rate: {{ percent(currentReport.immediateImpact.changeRate) }}
+ Maximum text difference: {{ textDifference(currentReport.immediateImpact.maximumTextDifference) }}
+
+
+
+
+
+
+
Downstream impact
+
+
+ @if (downstreamEntries.length === 0) {
+ No downstream impact was recorded. This is normal for an isolated step experiment.
+ }
+ @for (entry of downstreamEntries; track entry.nodeId) {
+
+
+ {{ entry.nodeName || entry.nodeId }}
+ {{ entry.nodeId }}
+ {{ entry.changed ? 'Changed' : 'Unchanged' }}
+
+ Status: {{ entry.baselineStatus }} → {{ entry.biasedStatus }}
+
+
+ }
+
+
+
+ Routing changes
+ @if (currentReport.routingChanges.length === 0) { No routing changes recorded.
}
+ @for (change of currentReport.routingChanges; track change.nodeId) {
+ {{ change.nodeId }}{{ change.baselineBranch }} → {{ change.biasedBranch }}
+ }
+
+
+ @if (currentReport.mockedSideEffects.length > 0) {
+
+ Mocked side effects
+
+ @for (sideEffect of currentReport.mockedSideEffects; track sideEffect.nodeId) {
+ - {{ sideEffect.nodeName || sideEffect.nodeId }}
{{ sideEffect.nodeId }} {{ sideEffect.kind }}
+ }
+
+
+ }
+
+ @if (!currentReport.rawOutputsIncluded) {
+ Raw output maps and lists were intentionally omitted from this report.
+ }
+
+
+ Warnings
+ @if (currentReport.warnings.length === 0) { No warnings reported.
}
+ @for (warning of currentReport.warnings; track warning) { {{ warning }}
}
+
+
+}
diff --git a/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.spec.ts b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.spec.ts
new file mode 100644
index 0000000..e71d77a
--- /dev/null
+++ b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.spec.ts
@@ -0,0 +1,64 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BiasImpactReport } from '@models/bias-impact';
+import { BiasImpactReportViewerComponent } from './bias-impact-report-viewer';
+
+describe('BiasImpactReportViewerComponent', () => {
+ let fixture: ComponentFixture;
+
+ const report: BiasImpactReport = {
+ id: 'report-1',
+ experimentId: 'experiment-1',
+ kind: 'FULL_FLOW',
+ baselineExecutionId: 'execution-base',
+ biasedExecutionId: 'execution-biased',
+ nodeId: null,
+ annotationIds: ['annotation-1'],
+ repetitions: 3,
+ createdAt: '2026-07-21T10:00:00.000Z',
+ rawOutputsIncluded: false,
+ immediateImpact: {
+ outputChanged: true,
+ changeRate: .5,
+ maximumTextDifference: .25,
+ baselineOutput: 'baseline',
+ biasedOutputs: ['biased']
+ },
+ downstreamImpact: [
+ { nodeId: 'node-changed', nodeName: 'Changed node', baselineStatus: 'COMPLETED', biasedStatus: 'COMPLETED', changed: true, baselineOutputs: 'a', biasedOutputs: 'b' },
+ { nodeId: 'node-same', nodeName: 'Same node', baselineStatus: 'COMPLETED', biasedStatus: 'COMPLETED', changed: false, baselineOutputs: 'a', biasedOutputs: 'a' }
+ ],
+ routingChanges: [{ nodeId: 'router', baselineBranch: 'yes', biasedBranch: 'no' }],
+ mockedSideEffects: [{ nodeId: 'http', nodeName: 'HTTP request', kind: 'HTTP' }],
+ summary: 'The biased variant changed the output.',
+ warnings: []
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [BiasImpactReportViewerComponent] }).compileComponents();
+ fixture = TestBed.createComponent(BiasImpactReportViewerComponent);
+ fixture.componentInstance.report = report;
+ });
+
+ it('renders metadata, impact sections, side effects and the raw-output notice', () => {
+ fixture.detectChanges();
+ const text = fixture.nativeElement.textContent;
+
+ expect(text).toContain('execution-base');
+ expect(text).toContain('execution-biased');
+ expect(text).not.toContain('Node\n');
+ expect(text).toContain('Change rate: 50.0%');
+ expect(text).toContain('router');
+ expect(text).toContain('HTTP request');
+ expect(text).toContain('intentionally omitted');
+ expect(text).toContain('No warnings reported.');
+ });
+
+ it('filters downstream entries to changed nodes', () => {
+ fixture.componentInstance.changedOnly = true;
+ fixture.detectChanges();
+
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain('Changed node');
+ expect(text).not.toContain('Same node');
+ });
+});
diff --git a/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.ts b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.ts
new file mode 100644
index 0000000..0df5ff4
--- /dev/null
+++ b/src/app/shared/bias-impact-report-viewer/bias-impact-report-viewer.ts
@@ -0,0 +1,32 @@
+import { CommonModule, DatePipe } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { BiasDownstreamImpactEntry, BiasImpactReport } from '@models/bias-impact';
+import { BiasOutputDiffComponent } from '../bias-output-diff/bias-output-diff';
+
+@Component({
+ selector: 'app-bias-impact-report-viewer',
+ standalone: true,
+ imports: [CommonModule, DatePipe, FormsModule, BiasOutputDiffComponent],
+ templateUrl: './bias-impact-report-viewer.html',
+ styleUrl: './bias-impact-report-viewer.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class BiasImpactReportViewerComponent {
+ @Input() report: BiasImpactReport | null = null;
+
+ changedOnly = false;
+
+ get downstreamEntries(): BiasDownstreamImpactEntry[] {
+ const entries = this.report?.downstreamImpact ?? [];
+ return this.changedOnly ? entries.filter((entry) => entry.changed) : entries;
+ }
+
+ percent(value: number): string {
+ return `${(value * 100).toFixed(1)}%`;
+ }
+
+ textDifference(value: number): string {
+ return `${value.toFixed(3)} (0 = identical, 1 = maximally different)`;
+ }
+}
diff --git a/src/app/shared/bias-output-diff/bias-output-diff.css b/src/app/shared/bias-output-diff/bias-output-diff.css
new file mode 100644
index 0000000..6543e7b
--- /dev/null
+++ b/src/app/shared/bias-output-diff/bias-output-diff.css
@@ -0,0 +1,9 @@
+.bias-output-diff { display: grid; gap: .8rem; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
+.bias-output-diff__variants { display: grid; gap: .8rem; }
+.bias-output-diff__column { background: #f8fafc; border: 1px solid #dbe4ef; border-radius: .45rem; min-width: 0; padding: .7rem; }
+.bias-output-diff__baseline { border-left: 3px solid #2563eb; }
+.bias-output-diff__biased { border-left: 3px solid #a855f7; }
+h4 { color: #334155; font-size: .78rem; margin: 0 0 .5rem; text-transform: uppercase; }
+pre { font-family: inherit; margin: 0; overflow: auto; white-space: pre-wrap; word-break: break-word; }
+.bias-output-diff__empty { color: #64748b; margin: .4rem 0; }
+@media (max-width: 700px) { .bias-output-diff { grid-template-columns: 1fr; } }
diff --git a/src/app/shared/bias-output-diff/bias-output-diff.html b/src/app/shared/bias-output-diff/bias-output-diff.html
new file mode 100644
index 0000000..55e22e0
--- /dev/null
+++ b/src/app/shared/bias-output-diff/bias-output-diff.html
@@ -0,0 +1,26 @@
+
+
+
{{ baselineLabel }}
+ @if (isText(baselineOutput)) {
+
{{ baselineOutput }}
+ } @else {
+
+ }
+
+
+
+ @if (biasedOutputs.length === 0) {
+
No biased output is available.
+ }
+ @for (output of biasedOutputs; track $index) {
+
+
{{ variantLabel }}{{ biasedOutputs.length > 1 ? ' ' + ($index + 1) : '' }}
+ @if (isText(output)) {
+
{{ output }}
+ } @else {
+
+ }
+
+ }
+
+
diff --git a/src/app/shared/bias-output-diff/bias-output-diff.spec.ts b/src/app/shared/bias-output-diff/bias-output-diff.spec.ts
new file mode 100644
index 0000000..f0c3f31
--- /dev/null
+++ b/src/app/shared/bias-output-diff/bias-output-diff.spec.ts
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BiasOutputDiffComponent } from './bias-output-diff';
+
+describe('BiasOutputDiffComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [BiasOutputDiffComponent] }).compileComponents();
+ fixture = TestBed.createComponent(BiasOutputDiffComponent);
+ });
+
+ it('renders text baseline and every biased variant side by side', () => {
+ fixture.componentInstance.baselineOutput = 'neutral';
+ fixture.componentInstance.biasedOutputs = ['biased one', 'biased two'];
+ fixture.detectChanges();
+
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain('neutral');
+ expect(text).toContain('Biased output 1');
+ expect(text).toContain('Biased output 2');
+ });
+
+ it('uses the JSON viewer for complex outputs', () => {
+ fixture.componentInstance.baselineOutput = { result: true };
+ fixture.componentInstance.biasedOutputs = [{ result: false }];
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelectorAll('app-json-viewer').length).toBe(2);
+ });
+});
diff --git a/src/app/shared/bias-output-diff/bias-output-diff.ts b/src/app/shared/bias-output-diff/bias-output-diff.ts
new file mode 100644
index 0000000..e6182f6
--- /dev/null
+++ b/src/app/shared/bias-output-diff/bias-output-diff.ts
@@ -0,0 +1,22 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { JsonViewerComponent } from '../json-viewer/json-viewer';
+
+@Component({
+ selector: 'app-bias-output-diff',
+ standalone: true,
+ imports: [CommonModule, JsonViewerComponent],
+ templateUrl: './bias-output-diff.html',
+ styleUrl: './bias-output-diff.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class BiasOutputDiffComponent {
+ @Input() baselineOutput: unknown;
+ @Input() biasedOutputs: unknown[] = [];
+ @Input() baselineLabel = 'Baseline';
+ @Input() variantLabel = 'Biased output';
+
+ isText(value: unknown): value is string {
+ return typeof value === 'string';
+ }
+}
diff --git a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css
new file mode 100644
index 0000000..77c2003
--- /dev/null
+++ b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css
@@ -0,0 +1,5 @@
+.bias-rerun-dialog__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
+.bias-rerun-dialog { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: 680px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, 680px); z-index: 10021; }
+header, footer { align-items: center; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; } header { border-bottom: 1px solid #e2e8f0; } h3, p { margin: 0; } header p { color: #64748b; font-size: .85rem; }
+.bias-rerun-dialog__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; } fieldset { display: grid; gap: .5rem; } label { color: #334155; font-size: .87rem; }
+.bias-rerun-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; padding: .6rem .7rem; } footer { border-top: 1px solid #e2e8f0; justify-content: flex-end; }
diff --git a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html
new file mode 100644
index 0000000..5f604b7
--- /dev/null
+++ b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html
@@ -0,0 +1,18 @@
+@if (state(); as currentState) {
+
+
+
+
+ @for (candidate of currentState.candidates; track candidate.nodeId) {
+
+ }
+
+ @if (inlineError(); as error) {
{{ error }}
}
+
+
+
+}
diff --git a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts
new file mode 100644
index 0000000..a7862ac
--- /dev/null
+++ b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts
@@ -0,0 +1,80 @@
+import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { ExternalSideEffectPolicy, BiasSideEffectError } from '@models/bias-impact';
+import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
+import { BiasRerunDialogService } from '@services/dialogs/bias-rerun-dialog';
+import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { SideEffectPolicySelectorComponent } from '@shared/side-effect-policy-selector/side-effect-policy-selector';
+
+@Component({
+ selector: 'app-bias-rerun-dialog-host',
+ standalone: true,
+ imports: [FormsModule, MatButtonModule, SideEffectPolicySelectorComponent],
+ templateUrl: './bias-rerun-dialog.html',
+ styleUrl: './bias-rerun-dialog.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class BiasRerunDialogHostComponent {
+ private readonly dialog = inject(BiasRerunDialogService);
+ private readonly executions = inject(TaskExecutionsService);
+ private readonly confirmation = inject(ConfirmDialogService);
+ readonly state = this.dialog.state;
+ readonly selectedAnnotationIdsByNode = signal>({});
+ readonly policy = signal('BLOCK');
+ readonly creating = signal(false);
+ readonly inlineError = signal(null);
+
+ constructor() {
+ effect(() => {
+ const state = this.state();
+ this.selectedAnnotationIdsByNode.set(Object.fromEntries((state?.candidates ?? []).map((candidate) => [candidate.nodeId, []])));
+ this.policy.set('BLOCK');
+ this.creating.set(false);
+ this.inlineError.set(null);
+ });
+ }
+
+ selectedIds(nodeId: string): string[] { return this.selectedAnnotationIdsByNode()[nodeId] ?? []; }
+
+ toggleAnnotation(nodeId: string, annotationId: string, checked: boolean) {
+ this.selectedAnnotationIdsByNode.update((current) => {
+ const selected = new Set(current[nodeId] ?? []);
+ if (checked) selected.add(annotationId); else selected.delete(annotationId);
+ return { ...current, [nodeId]: [...selected] };
+ });
+ }
+
+ hasExternalSideEffects(): boolean {
+ return (this.state()?.candidates ?? []).some((candidate) => this.selectedIds(candidate.nodeId).length > 0 && candidate.capabilities.externalSideEffects);
+ }
+
+ async submit() {
+ const state = this.state();
+ if (!state || this.creating()) return;
+ const activations = Object.entries(this.selectedAnnotationIdsByNode())
+ .filter(([, annotationIds]) => annotationIds.length > 0)
+ .map(([nodeId, annotationIds]) => ({ nodeId, annotationIds }));
+ if (!activations.length) { this.inlineError.set('Select at least one executable annotation.'); return; }
+
+ let confirmExternalSideEffects = false;
+ if (this.policy() === 'REQUIRE_CONFIRMATION') {
+ confirmExternalSideEffects = await this.confirmation.open('This biased rerun may invoke real external HTTP or MCP calls. Do you want to continue?');
+ if (!confirmExternalSideEffects) return;
+ }
+ this.creating.set(true);
+ this.inlineError.set(null);
+ this.executions.createBiasedRerun(state.executionId, { activations, externalSideEffectPolicy: this.policy(), confirmExternalSideEffects }).subscribe({
+ next: (execution) => { state.onCreated(execution); this.dialog.close(); },
+ error: (error) => {
+ this.creating.set(false);
+ const sideEffectError = error as Partial;
+ this.inlineError.set(sideEffectError.reason === 'SIDE_EFFECT_BLOCKED' || sideEffectError.reason === 'CONFIRMATION_REQUIRED'
+ ? sideEffectError.message ?? 'External side effect policy prevented the rerun.'
+ : error instanceof Error ? error.message : 'Unable to create the biased rerun.');
+ }
+ });
+ }
+
+ close() { if (!this.creating()) this.dialog.close(); }
+}
diff --git a/src/app/shared/json-viewer/json-viewer.css b/src/app/shared/json-viewer/json-viewer.css
new file mode 100644
index 0000000..43ed85f
--- /dev/null
+++ b/src/app/shared/json-viewer/json-viewer.css
@@ -0,0 +1,8 @@
+.json-viewer { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .78rem; overflow: auto; }
+.json-viewer__label { color: #475569; font-family: inherit; font-weight: 700; margin-bottom: .35rem; }
+.json-viewer__group { min-width: max-content; }
+.json-viewer__group > summary { cursor: pointer; color: #1e40af; outline-offset: 2px; }
+.json-viewer__children { border-left: 1px solid #cbd5e1; margin: .3rem 0 .2rem .35rem; padding-left: .7rem; }
+.json-viewer__entry { align-items: flex-start; display: flex; gap: .45rem; line-height: 1.55; }
+.json-viewer__key { color: #7c3aed; white-space: nowrap; }
+.json-viewer__primitive { color: #0f172a; font-family: inherit; white-space: pre-wrap; word-break: break-word; }
diff --git a/src/app/shared/json-viewer/json-viewer.html b/src/app/shared/json-viewer/json-viewer.html
new file mode 100644
index 0000000..990bd5a
--- /dev/null
+++ b/src/app/shared/json-viewer/json-viewer.html
@@ -0,0 +1,34 @@
+
+ @if (label) {
+ {{ label }}
+ }
+
+
+ @if (isExpandable(value)) {
+
+ {{ summary(value) }}
+
+ @if (isArray(value)) {
+ @for (item of value; track $index) {
+
+ [{{ $index }}]
+
+
+ }
+ } @else if (isObject(value)) {
+ @for (entry of entries(value); track entry.key) {
+
+ {{ entry.key }}
+
+
+ }
+ }
+
+
+ } @else {
+ {{ formatPrimitive(value) }}
+ }
+
+
+
+
diff --git a/src/app/shared/json-viewer/json-viewer.spec.ts b/src/app/shared/json-viewer/json-viewer.spec.ts
new file mode 100644
index 0000000..01cb1b5
--- /dev/null
+++ b/src/app/shared/json-viewer/json-viewer.spec.ts
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { JsonViewerComponent } from './json-viewer';
+
+describe('JsonViewerComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [JsonViewerComponent] }).compileComponents();
+ fixture = TestBed.createComponent(JsonViewerComponent);
+ });
+
+ it('renders an expandable recursive object tree', () => {
+ fixture.componentInstance.value = { answer: { enabled: true }, values: [1, 2] };
+ fixture.detectChanges();
+
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain('Object (2)');
+ expect(text).toContain('answer');
+ expect(text).toContain('enabled');
+ expect(text).toContain('true');
+ expect(text).toContain('[0]');
+ });
+
+ it('formats primitive strings as JSON strings', () => {
+ expect(fixture.componentInstance.formatPrimitive('hello')).toBe('"hello"');
+ expect(fixture.componentInstance.formatPrimitive(null)).toBe('null');
+ });
+});
diff --git a/src/app/shared/json-viewer/json-viewer.ts b/src/app/shared/json-viewer/json-viewer.ts
new file mode 100644
index 0000000..2a27271
--- /dev/null
+++ b/src/app/shared/json-viewer/json-viewer.ts
@@ -0,0 +1,62 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+/** Lightweight, dependency-free JSON tree for diagnostic and report views. */
+@Component({
+ selector: 'app-json-viewer',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './json-viewer.html',
+ styleUrl: './json-viewer.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class JsonViewerComponent {
+ @Input() value: unknown;
+ @Input() label: string | null = null;
+ @Input() initiallyExpanded = true;
+
+ private readonly collapsedPaths = new Set();
+
+ isArray(value: unknown): value is unknown[] {
+ return Array.isArray(value);
+ }
+
+ isObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+ }
+
+ isExpandable(value: unknown): boolean {
+ return this.isArray(value) || this.isObject(value);
+ }
+
+ entries(value: Record): Array<{ key: string; value: unknown }> {
+ return Object.entries(value).map(([key, entryValue]) => ({ key, value: entryValue }));
+ }
+
+ summary(value: unknown): string {
+ if (this.isArray(value)) return `Array (${value.length})`;
+ if (this.isObject(value)) return `Object (${Object.keys(value).length})`;
+ return this.formatPrimitive(value);
+ }
+
+ formatPrimitive(value: unknown): string {
+ if (typeof value === 'string') return `"${value}"`;
+ if (value === undefined) return 'undefined';
+ if (value === null) return 'null';
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+
+ isExpanded(path: string): boolean {
+ return this.initiallyExpanded && !this.collapsedPaths.has(path);
+ }
+
+ onToggle(path: string, event: Event) {
+ const expanded = (event.target as HTMLDetailsElement).open;
+ if (expanded) this.collapsedPaths.delete(path);
+ else this.collapsedPaths.add(path);
+ }
+}
diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html
index ee71bb4..92850a9 100644
--- a/src/app/shared/nodes/generic-node/generic-node.html
+++ b/src/app/shared/nodes/generic-node/generic-node.html
@@ -512,6 +512,7 @@
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts
index 5aadee3..098bf5e 100644
--- a/src/app/shared/nodes/generic-node/generic-node.ts
+++ b/src/app/shared/nodes/generic-node/generic-node.ts
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { BiasAnnotation, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
+import { BiasAnnotation, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
@@ -694,6 +694,24 @@ export class GenericNodeComponent implements OnDestroy {
return Array.isArray(value) ? value as BiasAnnotation[] : [];
}
+ get biasBlock(): FlowBlock | null {
+ const nodeData = this.data?.data as Record | undefined;
+ const blockId = this.blockId;
+ const typeName = this.blockType;
+ if (!nodeData || !blockId || !typeName) return null;
+ return {
+ id: blockId,
+ name: this.name,
+ position: nodeData['position'] as { x: number; y: number } | undefined,
+ inputs: this.resolvePorts('input').map((port) => ({ ...port })),
+ outputs: this.resolvePorts('output').map((port) => ({ ...port })),
+ specificConfiguration: this.blockConfiguration ?? {},
+ typeName,
+ nodeFamily: 'block',
+ biasAnnotations: this.biasAnnotations
+ };
+ }
+
private get biasAnnotationsProperty(): string {
const descriptorSignal = (this.blocksService as BlocksService & {
biasAnnotationsDescriptor?: () => { blockProperty?: string } | null
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css
index 5635a6f..0380a45 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.css
+++ b/src/app/shared/nodes/task-step-node/task-step-node.css
@@ -616,3 +616,17 @@
white-space: pre-wrap;
word-break: break-word;
}
+.llm-bias-impact-trigger {
+ align-items: center;
+ background: #eff6ff;
+ border: 1px solid #93c5fd;
+ border-radius: .35rem;
+ color: #1d4ed8;
+ cursor: pointer;
+ display: inline-flex;
+ font-size: .9rem;
+ justify-content: center;
+ margin-left: auto;
+ padding: .3rem .4rem;
+}
+.llm-bias-impact-trigger:hover { background: #dbeafe; }
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html
index 7072908..4ea85fc 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.html
+++ b/src/app/shared/nodes/task-step-node/task-step-node.html
@@ -59,6 +59,17 @@
{{ name }}
+ @if (canMeasureBiasImpact()) {
+
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts
index b5dc3e2..1d30b0a 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.ts
@@ -2,13 +2,16 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
-import { BlockInteractionContract, BlockType, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
+import { BiasAnnotation, BlockInteractionContract, BlockType, FlowData, FlowPort, isProbeExecutable, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
+import { BiasCapabilities } from '@models/bias-impact';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
+import { take } from 'rxjs';
import {
collectSchemaFlowDataFields,
isFlowDataFieldPath,
@@ -114,6 +117,7 @@ export class TaskStepNodeComponent {
private cdr = inject(ChangeDetectorRef);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private taskExecutionsService = inject(TaskExecutionsService);
+ private biasImpactExperimentDialog = inject(BiasImpactExperimentDialogService);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@@ -137,6 +141,7 @@ export class TaskStepNodeComponent {
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
schemaReady = false;
+ biasCapabilities: BiasCapabilities | null = null;
private blockSchema: Record
| null = null;
private blockDescriptor: BlockType | null = null;
@@ -170,6 +175,7 @@ export class TaskStepNodeComponent {
this.rebuildDisplayState();
void this.loadSchemaContext();
+ this.loadBiasCapabilities();
}
private rebuildDisplayState() {
@@ -423,6 +429,36 @@ export class TaskStepNodeComponent {
return this.stepStatus() === 'COMPLETED';
}
+ executableBiasAnnotations(): BiasAnnotation[] {
+ const annotations = this.data?.data?.biasAnnotations;
+ return Array.isArray(annotations)
+ ? annotations.filter((annotation): annotation is BiasAnnotation => !!annotation && isProbeExecutable(annotation.behavioralProbe))
+ : [];
+ }
+
+ canMeasureBiasImpact(): boolean {
+ return this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL'
+ && this.executableBiasAnnotations().length > 0
+ && this.biasCapabilities?.isolatedExperimentSupported === true;
+ }
+
+ measureBiasImpact(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const executionId = this.executionId();
+ const stepId = this.executionNodeId();
+ const capabilities = this.biasCapabilities;
+ if (!executionId || !stepId || !capabilities || !this.canMeasureBiasImpact()) return;
+ this.biasImpactExperimentDialog.open({
+ executionId,
+ stepId,
+ nodeId: String(this.data?.id ?? stepId),
+ nodeName: this.nodeTitle(),
+ annotations: this.executableBiasAnnotations(),
+ capabilities
+ });
+ }
+
isRunning(): boolean {
return this.stepStatus() === 'RUNNING';
}
@@ -485,6 +521,21 @@ export class TaskStepNodeComponent {
return this.data?.data?.specificConfiguration ?? null;
}
+ private loadBiasCapabilities() {
+ const blockType = this.blockType;
+ if (!blockType || this.isContainerNode()) return;
+ this.blocksService.retrieveBiasCapabilities(blockType).pipe(take(1)).subscribe({
+ next: (capabilities) => {
+ this.biasCapabilities = capabilities;
+ this.cdr.markForCheck();
+ },
+ error: () => {
+ this.biasCapabilities = null;
+ this.cdr.markForCheck();
+ }
+ });
+ }
+
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;
diff --git a/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.css b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.css
new file mode 100644
index 0000000..60c747d
--- /dev/null
+++ b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.css
@@ -0,0 +1,5 @@
+.side-effect-policy-selector { display: grid; gap: .45rem; }
+.side-effect-policy-selector h4 { font-size: .9rem; margin: 0; }
+.side-effect-policy-selector label { color: #334155; font-size: .86rem; }
+.side-effect-policy-selector__warning { background: #fff7ed; border-left: 3px solid #f97316; color: #9a3412; font-size: .84rem; margin: 0; padding: .55rem .65rem; }
+.side-effect-policy-selector__note { color: #0369a1; font-size: .82rem; margin: 0; }
diff --git a/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.html b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.html
new file mode 100644
index 0000000..aa6a8ba
--- /dev/null
+++ b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.html
@@ -0,0 +1,10 @@
+
diff --git a/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.spec.ts b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.spec.ts
new file mode 100644
index 0000000..a235c22
--- /dev/null
+++ b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { SideEffectPolicySelectorComponent } from './side-effect-policy-selector';
+
+describe('SideEffectPolicySelectorComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [SideEffectPolicySelectorComponent] }).compileComponents();
+ fixture = TestBed.createComponent(SideEffectPolicySelectorComponent);
+ });
+
+ it('emits the selected policy and explains mock mode', () => {
+ const emitted: string[] = [];
+ fixture.componentInstance.policyChange.subscribe((policy) => emitted.push(policy));
+ fixture.componentInstance.policy = 'MOCK';
+ fixture.componentInstance.externalSideEffects = true;
+ fixture.detectChanges();
+ fixture.componentInstance.select('BLOCK');
+
+ expect(emitted).toEqual(['BLOCK']);
+ expect(fixture.nativeElement.textContent).toContain('will not be invoked for real');
+ });
+});
diff --git a/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.ts b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.ts
new file mode 100644
index 0000000..6d5ac58
--- /dev/null
+++ b/src/app/shared/side-effect-policy-selector/side-effect-policy-selector.ts
@@ -0,0 +1,22 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { ExternalSideEffectPolicy } from '@models/bias-impact';
+
+@Component({
+ selector: 'app-side-effect-policy-selector',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './side-effect-policy-selector.html',
+ styleUrl: './side-effect-policy-selector.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SideEffectPolicySelectorComponent {
+ @Input() policy: ExternalSideEffectPolicy = 'BLOCK';
+ @Input() externalSideEffects = false;
+ @Input() disabled = false;
+ @Output() policyChange = new EventEmitter();
+
+ select(policy: ExternalSideEffectPolicy) {
+ if (!this.disabled) this.policyChange.emit(policy);
+ }
+}
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 026b611..016c09b 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.css
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css
@@ -411,3 +411,13 @@
overflow-y: auto;
overscroll-behavior: contain;
}
+.execution-bias-variant-context {
+ align-items: center;
+ color: #5b21b6;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: .78rem;
+ gap: .35rem .75rem;
+ margin-top: .45rem;
+}
+.execution-graph-action-button-bias { background: #f3e8ff; color: #7e22ce; }
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 d636a13..0c8e14a 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.html
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html
@@ -11,6 +11,14 @@
@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 }}
+
+ }
}
@@ -92,6 +100,15 @@