diff --git a/src/app/shared/bias-output-diff/bias-output-diff-fields.spec.ts b/src/app/shared/bias-output-diff/bias-output-diff-fields.spec.ts new file mode 100644 index 0000000..157b13e --- /dev/null +++ b/src/app/shared/bias-output-diff/bias-output-diff-fields.spec.ts @@ -0,0 +1,42 @@ +import { pairOutputFields } from './bias-output-diff-fields'; + +describe('pairOutputFields', () => { + it('files a bare value under a single named field', () => { + const fields = pairOutputFields('before', 'after'); + + expect(fields.length).toBe(1); + expect(fields[0].name).toBe('Output'); + expect(fields[0].state).toBe('changed'); + }); + + it('reads the output name off a node-prefixed key', () => { + const key = 'b9331000-0000-4000-8000-000000000002.res'; + const fields = pairOutputFields({ [key]: 'a' }, { [key]: 'b' }); + + expect(fields[0].name).toBe('res'); + expect(fields[0].nodeId).toBe('b9331000-0000-4000-8000-000000000002'); + }); + + it('marks fields only one side produced', () => { + const fields = pairOutputFields({ kept: 'x', dropped: 'y' }, { kept: 'x', extra: 'z' }); + const byName = new Map(fields.map((field) => [field.name, field])); + + expect(byName.get('kept')!.state).toBe('equal'); + expect(byName.get('dropped')!.state).toBe('only-baseline'); + expect(byName.get('extra')!.state).toBe('only-biased'); + }); + + it('sorts differences first', () => { + const fields = pairOutputFields({ a: 'same', z: 'before' }, { a: 'same', z: 'after' }); + + expect(fields.map((field) => field.name)).toEqual(['z', 'a']); + }); + + it('pretty-prints nested structure inside its field, and skips empty values', () => { + const fields = pairOutputFields({ nested: { score: 1 }, blank: ' ' }, { nested: { score: 2 } }); + + expect(fields.length).toBe(1); + expect(fields[0].baseline).toContain('"score": 1'); + expect(fields[0].biased).toContain('"score": 2'); + }); +}); diff --git a/src/app/shared/bias-output-diff/bias-output-diff-fields.ts b/src/app/shared/bias-output-diff/bias-output-diff-fields.ts new file mode 100644 index 0000000..28674d2 --- /dev/null +++ b/src/app/shared/bias-output-diff/bias-output-diff-fields.ts @@ -0,0 +1,96 @@ +import { stringifyOutputValue } from '@shared/task-execution-viewer/execution-viewer.utils'; + +/** Whether a field is the same on both sides, or produced by only one of them. */ +export type BiasFieldState = 'equal' | 'changed' | 'only-baseline' | 'only-biased'; + +export type BiasDiffField = { + /** The raw key both sides were joined on. */ + key: string; + /** What to call the field: the output name, without the node id the key usually carries. */ + name: string; + /** The node id read off the key, when the key carries one. */ + nodeId: string | null; + baseline: string | null; + biased: string | null; + state: BiasFieldState; +}; + +/** The key a bare (non-object) output is filed under; it has no name of its own. */ +const SINGLE_VALUE_KEY = '$'; + +/** + * Output keys arrive as `.`, which is the reason the old tree view was + * unreadable: forty characters of uuid before the one word that says what the value is. + */ +const NODE_PREFIXED_KEY = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(.+)$/i; + +function textOf(value: unknown): string | null { + const text = stringifyOutputValue(value); + return text.trim().length ? text : null; +} + +/** + * Flattens one side of the comparison into named text fields. + * + * Only the top level is spread out. Deeper structure is pretty-printed JSON inside its field: it + * still diffs line by line, and pairing arbitrarily nested keys buys nothing for outputs that are + * flat maps of node outputs in practice. + */ +function fieldsOf(value: unknown): Map { + const fields = new Map(); + if (value === null || value === undefined) return fields; + + if (Array.isArray(value)) { + value.forEach((item, index) => { + const text = textOf(item); + if (text !== null) fields.set(`[${index + 1}]`, text); + }); + return fields; + } + + if (typeof value === 'object') { + for (const [key, entry] of Object.entries(value as Record)) { + const text = textOf(entry); + if (text !== null) fields.set(key, text); + } + return fields; + } + + const text = textOf(value); + if (text !== null) fields.set(SINGLE_VALUE_KEY, text); + return fields; +} + +function labelOf(key: string): { name: string; nodeId: string | null } { + if (key === SINGLE_VALUE_KEY) return { name: 'Output', nodeId: null }; + const prefixed = NODE_PREFIXED_KEY.exec(key); + return prefixed ? { name: prefixed[2], nodeId: prefixed[1] } : { name: key, nodeId: null }; +} + +function stateOf(baseline: string | null, biased: string | null): BiasFieldState { + if (baseline !== null && biased === null) return 'only-baseline'; + if (baseline === null && biased !== null) return 'only-biased'; + return baseline === biased ? 'equal' : 'changed'; +} + +/** + * Joins a baseline output and one biased output field by field. + * + * Differences sort first: on a full-flow report most fields are untouched, and the ones that moved + * are the whole point of opening the comparison. + */ +export function pairOutputFields(baselineOutput: unknown, biasedOutput: unknown): BiasDiffField[] { + const baseline = fieldsOf(baselineOutput); + const biased = fieldsOf(biasedOutput); + + return [...new Set([...baseline.keys(), ...biased.keys()])] + .map((key) => ({ + key, + ...labelOf(key), + baseline: baseline.get(key) ?? null, + biased: biased.get(key) ?? null, + state: stateOf(baseline.get(key) ?? null, biased.get(key) ?? null) + })) + .sort((a, b) => + Number(a.state === 'equal') - Number(b.state === 'equal') || a.name.localeCompare(b.name)); +} diff --git a/src/app/shared/bias-output-diff/bias-output-diff.css b/src/app/shared/bias-output-diff/bias-output-diff.css index 6543e7b..2042d30 100644 --- a/src/app/shared/bias-output-diff/bias-output-diff.css +++ b/src/app/shared/bias-output-diff/bias-output-diff.css @@ -1,9 +1,37 @@ -.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; } } +.bias-diff { display: grid; gap: .55rem; } + +.bias-diff__toolbar { align-items: center; column-gap: .6rem; display: flex; flex-wrap: wrap; row-gap: .4rem; } +.bias-diff__count { color: #475569; font-size: .78rem; margin: 0; } +.bias-diff__filter, .bias-diff__variant { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 999px; color: #475569; cursor: pointer; font-size: .7rem; font-weight: 600; padding: .18rem .6rem; } +.bias-diff__filter { margin-left: auto; } +.bias-diff__filter.active, .bias-diff__variant.active { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; } +.bias-diff__variants { display: flex; flex-wrap: wrap; gap: .3rem; } + +.bias-diff__empty { color: #64748b; font-size: .8rem; margin: .2rem 0; } + +.bias-diff__field { background: #ffffff; border: 1px solid #e2e8f0; border-radius: .5rem; padding: .5rem .6rem; } +.bias-diff__field-head { align-items: center; display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .4rem; } +.bias-diff__name { color: #0f172a; font-size: .8rem; font-weight: 700; } + +/* The node id identifies the field but is never what a reader is looking for: kept, kept small. */ +.bias-diff__node { color: #64748b; font-size: .66rem; max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.bias-diff__state { border-radius: 999px; font-size: .62rem; font-weight: 700; letter-spacing: .03em; margin-left: auto; padding: .1rem .45rem; text-transform: uppercase; } +.bias-diff__state[data-state='changed'] { background: #fef3c7; color: #92400e; } +.bias-diff__state[data-state='equal'] { background: #f1f5f9; color: #64748b; } +.bias-diff__state[data-state='only-baseline'] { background: #fee2e2; color: #b91c1c; } +.bias-diff__state[data-state='only-biased'] { background: #dcfce7; color: #15803d; } + +/* Two columns, each scrolling its own long value rather than the dialog scrolling sideways. */ +.bias-diff__row { display: grid; gap: .5rem; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.bias-diff__cell { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .4rem; min-width: 0; padding: .4rem .5rem; } +.bias-diff__cell--baseline { border-left: 3px solid #2563eb; } +.bias-diff__cell--biased { border-left: 3px solid #a855f7; } +.bias-diff__cell-label { color: #64748b; display: block; font-size: .62rem; font-weight: 700; letter-spacing: .03em; margin-bottom: .2rem; text-transform: uppercase; } + +.bias-diff__text { color: #0f172a; font-size: .8rem; line-height: 1.5; max-height: 320px; overflow: auto; overflow-wrap: anywhere; white-space: pre-wrap; } +.bias-diff__text [data-kind='added'] { background: #dcfce7; border-radius: 2px; color: #14532d; } +.bias-diff__text [data-kind='removed'] { background: #fee2e2; border-radius: 2px; color: #7f1d1d; } +.bias-diff__missing { color: #94a3b8; font-style: italic; } + +@media (max-width: 700px) { .bias-diff__row { 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 index 55e22e0..7ce6d4e 100644 --- a/src/app/shared/bias-output-diff/bias-output-diff.html +++ b/src/app/shared/bias-output-diff/bias-output-diff.html @@ -1,26 +1,92 @@ -
-
-

{{ baselineLabel }}

- @if (isText(baselineOutput)) { -
{{ baselineOutput }}
- } @else { - - } -
+
+ @if (biasedOutputs().length === 0) { +

No biased output is available.

+ } @else { +
+ @if (variantIndexes().length > 1) { +
+ @for (index of variantIndexes(); track index) { + + } +
+ } -
- @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 }}
+

+ @if (fields().length === 0) { + Neither side produced an output. + } @else if (changedCount() === 0) { + Every field is identical on both sides. } @else { - + {{ changedCount() }} of {{ fields().length }} + {{ fields().length === 1 ? 'field differs' : 'fields differ' }}. } -

+

+ + @if (fields().length > changedCount() && changedCount() > 0) { + + } +
+ + @for (field of visibleFields(); track field.key) { +
+
+ {{ field.name }} + @if (field.nodeId) { + {{ field.nodeId }} + } + {{ stateLabel(field.state) }} +
+ + @if (field.state === 'equal') { +
+ Both sides +
+ @for (part of parts(field, 'left'); track $index) { + {{ part.text }} + } +
+
+ } @else { +
+
+ {{ baselineLabel() }} +
+ @if (field.baseline === null) { + Not produced + } + @for (part of parts(field, 'left'); track $index) { + {{ part.text }} + } +
+
+
+ {{ variantTitle(activeVariant()) }} +
+ @if (field.biased === null) { + Not produced + } + @for (part of parts(field, 'right'); track $index) { + {{ part.text }} + } +
+
+
+ } +
} - + + @if (fields().length > 0 && visibleFields().length === 0) { +

+ Baseline and biased output are identical on every field. +

+ } + }
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 index f0c3f31..1f3ac22 100644 --- a/src/app/shared/bias-output-diff/bias-output-diff.spec.ts +++ b/src/app/shared/bias-output-diff/bias-output-diff.spec.ts @@ -9,22 +9,59 @@ describe('BiasOutputDiffComponent', () => { 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']; + function text(): string { + return fixture.nativeElement.textContent as string; + } + + it('highlights the words that changed on each side', () => { + fixture.componentRef.setInput('baselineOutput', 'the candidate is strong'); + fixture.componentRef.setInput('biasedOutputs', ['the candidate is weak']); fixture.detectChanges(); - const text = fixture.nativeElement.textContent; - expect(text).toContain('neutral'); - expect(text).toContain('Biased output 1'); - expect(text).toContain('Biased output 2'); + const removed = fixture.nativeElement.querySelectorAll('[data-kind="removed"]'); + const added = fixture.nativeElement.querySelectorAll('[data-kind="added"]'); + expect(removed.length).toBe(1); + expect(added.length).toBe(1); + expect(removed[0].textContent).toContain('strong'); + expect(added[0].textContent).toContain('weak'); }); - it('uses the JSON viewer for complex outputs', () => { - fixture.componentInstance.baselineOutput = { result: true }; - fixture.componentInstance.biasedOutputs = [{ result: false }]; + it('pairs object outputs field by field and hides the identical ones', () => { + fixture.componentRef.setInput('baselineOutput', { ranking: 'A, B', notes: 'same' }); + fixture.componentRef.setInput('biasedOutputs', [{ ranking: 'B, A', notes: 'same' }]); fixture.detectChanges(); - expect(fixture.nativeElement.querySelectorAll('app-json-viewer').length).toBe(2); + expect(text()).toContain('1 of 2 fields differ'); + expect(text()).toContain('ranking'); + expect(text()).not.toContain('notes'); + + (fixture.nativeElement.querySelector('.bias-diff__filter') as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(text()).toContain('notes'); + }); + + it('compares one repetition at a time', () => { + fixture.componentRef.setInput('baselineOutput', 'neutral'); + fixture.componentRef.setInput('biasedOutputs', ['biased one', 'biased two']); + fixture.detectChanges(); + + expect(text()).toContain('biased one'); + expect(text()).not.toContain('biased two'); + + const variants = fixture.nativeElement.querySelectorAll('.bias-diff__variant'); + expect(variants.length).toBe(2); + (variants[1] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(text()).toContain('biased two'); + expect(text()).not.toContain('biased one'); + }); + + it('says so when there is no biased output at all', () => { + fixture.componentRef.setInput('baselineOutput', 'neutral'); + fixture.detectChanges(); + + expect(text()).toContain('No biased output is available.'); }); }); diff --git a/src/app/shared/bias-output-diff/bias-output-diff.ts b/src/app/shared/bias-output-diff/bias-output-diff.ts index e6182f6..71a3b62 100644 --- a/src/app/shared/bias-output-diff/bias-output-diff.ts +++ b/src/app/shared/bias-output-diff/bias-output-diff.ts @@ -1,22 +1,74 @@ -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { JsonViewerComponent } from '../json-viewer/json-viewer'; +import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core'; +import { DiffPart, diffSide, diffWords } from '@shared/execution-compare/execution-compare-diff'; +import { BiasDiffField, pairOutputFields } from './bias-output-diff-fields'; +/** + * Baseline vs. biased output, one named field at a time, with the words that moved highlighted. + * + * It used to render each side as a JSON tree. Side by side in a modal that made two forty-character + * uuid keys per row, the values themselves scrolled off to the right, and a reader had to compare + * two paragraphs of model output by eye. The word diff is the same one the run comparison uses. + */ @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'; + readonly baselineOutput = input(null); + readonly biasedOutputs = input([]); + readonly baselineLabel = input('Baseline'); + readonly variantLabel = input('Biased output'); - isText(value: unknown): value is string { - return typeof value === 'string'; + /** Which repetition is on screen; only one is compared at a time. */ + readonly selectedVariant = signal(0); + /** On by default: an unchanged field is context, a changed one is the finding. */ + readonly onlyDifferences = signal(true); + + readonly variantIndexes = computed(() => this.biasedOutputs().map((_, index) => index)); + + readonly activeVariant = computed(() => { + const count = this.biasedOutputs().length; + if (!count) return 0; + return Math.min(this.selectedVariant(), count - 1); + }); + + readonly fields = computed(() => { + const outputs = this.biasedOutputs(); + if (!outputs.length) return []; + return pairOutputFields(this.baselineOutput(), outputs[this.activeVariant()]); + }); + + readonly changedCount = computed(() => this.fields().filter((field) => field.state !== 'equal').length); + + readonly visibleFields = computed(() => + this.onlyDifferences() ? this.fields().filter((field) => field.state !== 'equal') : this.fields()); + + variantTitle(index: number): string { + return this.biasedOutputs().length > 1 ? `${this.variantLabel()} ${index + 1}` : this.variantLabel(); + } + + selectVariant(index: number) { + this.selectedVariant.set(index); + } + + toggleOnlyDifferences() { + this.onlyDifferences.update((only) => !only); + } + + /** Equal values are not diffed: the table's only possible answer there is "all the same". */ + parts(field: BiasDiffField, side: 'left' | 'right'): DiffPart[] { + const own = (side === 'left' ? field.baseline : field.biased) ?? ''; + if (field.state === 'equal') return own.length ? [{ kind: 'same', text: own }] : []; + return diffSide(diffWords(field.baseline ?? '', field.biased ?? ''), side); + } + + stateLabel(state: BiasDiffField['state']): string { + if (state === 'equal') return 'Identical'; + if (state === 'only-baseline') return `${this.baselineLabel()} only`; + if (state === 'only-biased') return 'Biased only'; + return 'Changed'; } } diff --git a/src/app/shared/json-viewer/json-viewer.css b/src/app/shared/json-viewer/json-viewer.css index 43ed85f..b544aab 100644 --- a/src/app/shared/json-viewer/json-viewer.css +++ b/src/app/shared/json-viewer/json-viewer.css @@ -1,8 +1,12 @@ .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; } +/* + * No min-width: max-content here. It sized every row to its longest value, so a paragraph of model + * output pushed itself out of the visible box and the row read as an empty key. + */ +.json-viewer__group { min-width: 0; } .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; } +.json-viewer__primitive { color: #0f172a; flex: 1 1 auto; font-family: inherit; min-width: 0; white-space: pre-wrap; word-break: break-word; }