Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions garak-report/src/components/TechniqueIntent/TaxonomyAxisList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import { formatRate } from "../../utils/formatPercentage";
import useSeverityColor from "../../hooks/useSeverityColor";
import DefconBadge from "../DefconBadge";
import TaxonomyCellChart from "./TaxonomyCellChart";
import TaxonomyPathBadges from "./TaxonomyPathBadges";
import {
buildAxisGroups,
type AxisGroup,
type MatrixCell,
type MatrixView,
type TaxonomyAxis,
} from "../../utils/techniqueIntentRollup";
import { isTechniqueKey } from "../../utils/taxonomyLabels";
import type { SortOption } from "../../hooks/useModuleFilters";

/**
Expand Down Expand Up @@ -122,7 +124,16 @@ const Stat = ({ label, value }: { label: string; value: string }) => (
* digest provides a detector count here, so we report how many judges scored
* the pairing rather than inventing a per-detector breakdown.
*/
const CellDetail = ({ cell, title }: { cell: MatrixCell; title?: string }) => {
const CellDetail = ({
cell,
title,
taxonomyKey,
}: {
cell: MatrixCell;
title?: string;
/** Underlying key for this pairing's leaf, when it's a technique (drives the breadcrumb). */
taxonomyKey?: string;
}) => {
const { getSeverityLabelByLevel, getDefconBadgeColor } = useSeverityColor();
const defcon = scoreToDefcon(cell.score);
const hasFailures = cell.score < 1;
Expand All @@ -139,6 +150,14 @@ const CellDetail = ({ cell, title }: { cell: MatrixCell; title?: string }) => {
<Text kind="label/bold/md">{getSeverityLabelByLevel(defcon)}</Text>
</Badge>
</Flex>
{taxonomyKey && isTechniqueKey(taxonomyKey) && (
<Stack gap="density-xxs">
<Text kind="label/regular/md" className="opacity-60">
Taxonomy path
</Text>
<TaxonomyPathBadges techniqueKey={taxonomyKey} />
</Stack>
)}
<Flex gap="density-2xl" wrap="wrap">
<Stat label="Pass rate" value={formatRate(cell.score)} />
<Stat
Expand Down Expand Up @@ -220,6 +239,14 @@ const GroupChildrenChart = ({
}, [initialSelected, selected, focusNonce]);
return (
<Stack gap="density-md" paddingY="density-sm">
{isTechniqueKey(group.key) && (
<Stack gap="density-xxs">
<Text kind="label/regular/md" className="opacity-60">
Taxonomy path
</Text>
<TaxonomyPathBadges techniqueKey={group.key} />
</Stack>
)}
<Text kind="label/regular/md" className="opacity-60">
Pass rate by {childNoun}. Click a bar for the pass/fail breakdown.
</Text>
Expand All @@ -232,7 +259,11 @@ const GroupChildrenChart = ({
/>
{selectedEntry && (
<div ref={boxRef} style={{ scrollMarginTop: "1rem" }}>
<CellDetail cell={selectedEntry.cell} title={selectedEntry.otherLabel} />
<CellDetail
cell={selectedEntry.cell}
title={selectedEntry.otherLabel}
taxonomyKey={selectedEntry.otherKey}
/>
</div>
)}
</Grid>
Expand Down
55 changes: 55 additions & 0 deletions garak-report/src/components/TechniqueIntent/TaxonomyPathBadges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @file TaxonomyPathBadges.tsx
* @description Breadcrumb-style rendering of a technique's full taxonomy path
* (e.g. Fictionalizing › Roleplaying › User_persona), reusing the
* gray outline tag styling from {@link ProbeTagsList} so the
* taxonomy context reads consistently with the Probes view.
* @module components/TechniqueIntent
*
* @copyright NVIDIA Corporation 2023-2026
* @license Apache-2.0
*/

import { Badge, Flex, Text } from "@kui/react";
import { ChevronRight } from "lucide-react";
import { techniquePathSegments } from "../../utils/taxonomyLabels";

/** Props for TaxonomyPathBadges component */
interface TaxonomyPathBadgesProps {
/** Full `demon:`-prefixed technique key to render as a taxonomy path. */
techniqueKey: string;
}

/**
* Renders a technique's full taxonomy path as a chain of gray outline badges
* (matching {@link ProbeTagsList}'s tag style) separated by chevrons, e.g.
* `Fictionalizing › Roleplaying › User_persona`. Gives leaf-level entries the
* branch context that shortened labels alone drop. Renders nothing for a
* single-segment path, since there is no hierarchy to show.
*
* @param props - Component props
* @param props.techniqueKey - Full `demon:` technique key
* @returns Breadcrumb badge chain, or null when the key has no hierarchy
*/
const TaxonomyPathBadges = ({ techniqueKey }: TaxonomyPathBadgesProps) => {
const segments = techniquePathSegments(techniqueKey);
if (segments.length <= 1) return null;

return (
<Flex align="center" gap="density-xxs" wrap="wrap">
{segments.map((segment, index) => (
// eslint-disable-next-line react/no-array-index-key -- segments are positional and stable per key
<Flex key={index} align="center" gap="density-xxs">
<Badge color="gray" kind="outline">
<Text kind="label/regular/xs">{segment}</Text>
</Badge>
{index < segments.length - 1 && (
<ChevronRight size={12} className="opacity-50" aria-hidden="true" />
)}
</Flex>
))}
</Flex>
);
};

export default TaxonomyPathBadges;
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @license Apache-2.0
*/

import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import type { ComponentProps } from "react";
import TaxonomyAxisList from "../TaxonomyAxisList";
Expand Down Expand Up @@ -75,7 +75,9 @@ const cell = (over: Partial<MatrixCell>): MatrixCell => ({
});

// techA: 3 intents (chart path, worst-first). techB: 1 failing intent
// (single-child detail). techC: 1 clean intent.
// (single-child detail). techC: 1 clean intent. techD is a real hierarchical
// demon: key, used to exercise the taxonomy-path breadcrumb (Issue #1972).
const techD = "demon:Fictionalizing:Roleplaying:User_persona";
const cellMap: Record<string, MatrixCell> = {
"techA|i1": cell({
col: "i1",
Expand All @@ -86,10 +88,11 @@ const cellMap: Record<string, MatrixCell> = {
"techA|i3": cell({ col: "i3", score: 1, passed: 100 }),
"techB|i1": cell({ row: "techB", col: "i1", score: 0.4, passed: 40, nAttempts: 20 }),
"techC|i1": cell({ row: "techC", col: "i1", score: 1, passed: 100 }),
[`${techD}|i1`]: cell({ row: techD, col: "i1", score: 0.2, passed: 20 }),
};

const view: MatrixView = {
rows: ["techA", "techB", "techC"],
rows: ["techA", "techB", "techC", techD],
cols: ["i1", "i2", "i3"],
rowLabel: key => key,
colLabel: key => key,
Expand All @@ -116,7 +119,7 @@ const renderList = (props: Partial<ComponentProps<typeof TaxonomyAxisList>> = {}
describe("TaxonomyAxisList", () => {
it("renders one accordion entry per visible primary group", () => {
renderList();
expect(screen.getAllByTestId("accordion-item"), "a row per technique").toHaveLength(3);
expect(screen.getAllByTestId("accordion-item"), "a row per technique").toHaveLength(4);
});

it("renders a bar chart for every group and auto-shows detail for single-intent groups", () => {
Expand Down Expand Up @@ -159,6 +162,43 @@ describe("TaxonomyAxisList", () => {
).toBeInTheDocument();
});

it("shows a labelled taxonomy path breadcrumb in a technique group's expanded content", () => {
renderList();
expect(screen.getAllByText("Taxonomy path").length, "labelled, not a bare tag chain").toBeGreaterThan(
0
);
expect(screen.getByText("Fictionalizing"), "broadest branch segment").toBeInTheDocument();
expect(screen.getByText("Roleplaying"), "middle branch segment").toBeInTheDocument();
});

it("keeps the breadcrumb out of the always-visible trigger — it only shows once expanded", () => {
renderList();
for (const trigger of screen.getAllByTestId("accordion-trigger")) {
expect(
within(trigger).queryByText("Fictionalizing"),
"breadcrumb segments must not render in the collapsed trigger"
).toBeNull();
}
expect(screen.getByText("Fictionalizing")).toBeInTheDocument();
});

it("does not show a breadcrumb for flat (non-technique) group keys", () => {
renderList();
// techA/techB/techC are flat mock keys with no demon: hierarchy, so their
// groups should contribute no breadcrumb segments — only techD's do.
expect(screen.getAllByText("Fictionalizing"), "only techD's group has a breadcrumb").toHaveLength(
1
);
});

it("does not show a breadcrumb on the intent axis, since intent codes are flat", () => {
renderList({ axis: "intent" });
expect(
screen.queryByText("Fictionalizing"),
"intent-axis groups (flat codes) never render technique breadcrumbs"
).toBeNull();
});

it("supports the intent axis and alphabetical sort", () => {
renderList({ axis: "intent", sortBy: "alphabetical" });
expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @file TaxonomyPathBadges.test.tsx
* @description Verifies the breadcrumb rendering for a technique's full
* taxonomy path (Issue #1972): every branch segment renders as a
* tag, in order, and single-segment keys render nothing.
*
* @copyright NVIDIA Corporation 2023-2026
* @license Apache-2.0
*/

import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import TaxonomyPathBadges from "../TaxonomyPathBadges";
import type { MockBadgeProps, MockFlexProps, MockTextProps } from "../../../test-utils/mockTypes";

// Reuse the same gray-outline Badge the Probes tag list renders with, so this
// test only asserts on content/order, not on KUI's internals.
vi.mock("@kui/react", () => ({
Badge: ({ children, color, kind }: MockBadgeProps) => (
<span data-testid="badge" data-color={color} data-kind={kind}>
{children}
</span>
),
Flex: ({ children }: MockFlexProps) => <div>{children}</div>,
Text: ({ children }: MockTextProps) => <span>{children}</span>,
}));

describe("TaxonomyPathBadges", () => {
it("renders every branch segment, broadest first, down to the leaf", () => {
render(<TaxonomyPathBadges techniqueKey="demon:Fictionalizing:Roleplaying:User_persona" />);
const badges = screen.getAllByTestId("badge");
expect(badges.map(b => b.textContent)).toEqual(["Fictionalizing", "Roleplaying", "User_persona"]);
});

it("uses the same gray outline tag styling as the Probes tag list", () => {
render(<TaxonomyPathBadges techniqueKey="demon:Encoding:Base64" />);
for (const badge of screen.getAllByTestId("badge")) {
expect(badge.dataset.color).toBe("gray");
expect(badge.dataset.kind).toBe("outline");
}
});

it("renders nothing for a single-segment key (no hierarchy to show)", () => {
const { container } = render(<TaxonomyPathBadges techniqueKey="demon:Base64" />);
expect(container).toBeEmptyDOMElement();
});
});
1 change: 1 addition & 0 deletions garak-report/src/components/TechniqueIntent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
export { default as TechniqueIntentPanel } from "./TechniqueIntentPanel";
export type { TechniqueIntentPanelProps } from "./TechniqueIntentPanel";
export { default as TaxonomyAxisList } from "./TaxonomyAxisList";
export { default as TaxonomyPathBadges } from "./TaxonomyPathBadges";
2 changes: 1 addition & 1 deletion garak-report/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export { default as ProbesChart } from "./ProbesChart";
export { default as DetectorsView } from "./DetectorsView";

// Technique/Intent taxonomy components
export { TechniqueIntentPanel, TaxonomyAxisList } from "./TechniqueIntent";
export { TechniqueIntentPanel, TaxonomyAxisList, TaxonomyPathBadges } from "./TechniqueIntent";
export type { TechniqueIntentPanelProps } from "./TechniqueIntent";

// Subcomponent exports
Expand Down
47 changes: 47 additions & 0 deletions garak-report/src/utils/__tests__/taxonomyLabels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @file taxonomyLabels.test.ts
* @description Verifies the taxonomy label/path helpers, including the
* breadcrumb segments used to show a technique's full taxonomy
* path (Issue #1972).
*
* @copyright NVIDIA Corporation 2023-2026
* @license Apache-2.0
*/

import { describe, expect, it } from "vitest";
import { isTechniqueKey, shortenTechnique, techniquePathSegments } from "../taxonomyLabels";

describe("isTechniqueKey", () => {
it("recognizes a demon:-prefixed technique key", () => {
expect(isTechniqueKey("demon:Fictionalizing:Roleplaying:User_persona")).toBe(true);
});

it("rejects a flat intent code", () => {
expect(isTechniqueKey("T009ignore")).toBe(false);
});
});

describe("techniquePathSegments", () => {
it("returns every branch from broadest to leaf, stripping the demon: prefix", () => {
expect(
techniquePathSegments("demon:Fictionalizing:Roleplaying:User_persona"),
"full path is preserved, unlike shortenTechnique's last-two truncation"
).toEqual(["Fictionalizing", "Roleplaying", "User_persona"]);
});

it("handles a single-segment key", () => {
expect(techniquePathSegments("demon:Base64")).toEqual(["Base64"]);
});

it("handles a key with no demon: prefix by treating it as a bare path", () => {
expect(techniquePathSegments("Encoding:Base64")).toEqual(["Encoding", "Base64"]);
});
});

describe("shortenTechnique (existing behavior, unchanged)", () => {
it("keeps only the two most specific segments", () => {
expect(shortenTechnique("demon:Fictionalizing:Roleplaying:User_persona")).toBe(
"Roleplaying:User_persona"
);
});
});
24 changes: 24 additions & 0 deletions garak-report/src/utils/taxonomyLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,27 @@ export function shortenTechnique(key: string): string {
const segments = stripped.split(":");
return segments.slice(-2).join(":");
}
/**
* Whether a taxonomy key is a hierarchical `demon:` technique key (as opposed
* to a flat intent code). Technique keys are the only axis that currently
* carries an explicit taxonomy path, so this gates the breadcrumb display.
*/
export function isTechniqueKey(key: string): boolean {
return key.startsWith("demon:");
}

/**
* Full taxonomy path for a hierarchical `demon:` technique key, from broadest
* branch to the specific leaf — the same segments {@link shortenTechnique}
* truncates to the last two, kept in full for breadcrumb display.
*
* @example
* techniquePathSegments("demon:Fictionalizing:Roleplaying:User_persona")
* // ["Fictionalizing", "Roleplaying", "User_persona"]
*/
export function techniquePathSegments(key: string): string[] {
return key
.replace(/^demon:/, "")
.split(":")
.filter(Boolean);
}
66 changes: 33 additions & 33 deletions garak/analyze/ui/index.html

Large diffs are not rendered by default.

Loading