diff --git a/CHANGELOG.d/2.12.6-lineage-dag-stories.md b/CHANGELOG.d/2.12.6-lineage-dag-stories.md new file mode 100644 index 000000000..fc15bc8c1 --- /dev/null +++ b/CHANGELOG.d/2.12.6-lineage-dag-stories.md @@ -0,0 +1,6 @@ +# Verify lineage DAG states before changing graph CSS + +Open `Lineage/LineageDag` in Storybook and compare the empty, grouped/forked, +ungrouped, and long-title states. The graph now omits a relationship from both +the SVG and its count unless both endpoints are visible in the same reconstruct +group. diff --git a/docs/adr/0078-focused-lineage-graph-on-post-open.md b/docs/adr/0078-focused-lineage-graph-on-post-open.md index 74a3f38b7..76ccee5b2 100644 --- a/docs/adr/0078-focused-lineage-graph-on-post-open.md +++ b/docs/adr/0078-focused-lineage-graph-on-post-open.md @@ -24,6 +24,19 @@ global graph only as the initial loading fallback. The graph is an interaction-time projection; it does not alter stored lineage edges or analysis-run snapshots. +The layout boundary independently keeps only edges whose source and target +nodes are both present in the same visible reconstruct group. A dangling edge +or an edge crossing visible groups is omitted from both the SVG and its edge +count. This preserves the backend's eligibility/ABAC decision when a partial +or stale client payload reaches the renderer; an invisible relationship must +not survive as buyer-facing aggregate evidence. + +Within one visible group, the layout may receive a converging DAG or a cyclic +import. It retains every visible edge, positions a shared child once on the +first deterministic walk, and excludes already-positioned or active-path +children from recursive re-entry. This prevents non-termination and repeated +placement without rewriting the stored graph. + ## Consequences - Opening a related post shows all visible nodes in its connected lineage @@ -33,6 +46,10 @@ analysis-run snapshots. lineage" state rather than an unrelated DAG. - A focused component can be larger than the landing limit, so the endpoint remains authenticated and ABAC-filtered. +- Captions count the same authorized, renderable edges that the buyer can see; + a relationship to an omitted node cannot leak through a count. +- A converging child has one stable SVG position while every authorized parent + edge remains visible. ## Alternatives rejected diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 28c59bd48..6ba03a951 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -8,6 +8,7 @@ buyer-facing control you can click before changing product CSS. | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | +| `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module diff --git a/frontend/src/LineageDag.stories.tsx b/frontend/src/LineageDag.stories.tsx new file mode 100644 index 000000000..a0f5101ac --- /dev/null +++ b/frontend/src/LineageDag.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LineageDag } from "./LineageDag"; +import type { LineageGraph } from "./api"; + +const meta = { + title: "Lineage/LineageDag", + component: LineageDag, + args: { + onSelectPost: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Edge case: no reconstructed lineage yet -- must not render an empty SVG. +export const Empty: Story = { + args: { + graph: { nodes: [], edges: [] }, + }, +}; + +export const SingleBranch: Story = { + args: { + graph: { + nodes: [ + { id: "a1", group: "Northwind Renewal", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "a2", group: "Northwind Renewal", label: "Follow-up call", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "a3", group: "Northwind Renewal", label: "Contract signed", occurred_at: "2026-01-10T00:00:00Z", is_root: false, is_branch_point: false }, + ], + edges: [ + { source: "a1", target: "a2", fused_score: 0.86 }, + { source: "a2", target: "a3", fused_score: 0.91 }, + ], + } satisfies LineageGraph, + }, +}; + +// The multi-branch, git-branch-style case the Ask Agent answer view relies on: +// several independent lineage threads rendered as separate figures, plus one +// thread with an actual fork (a branch point with two children). +export const MultipleGitStyleBranches: Story = { + args: { + graph: { + nodes: [ + { id: "a1", group: "Northwind Renewal", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: true }, + { id: "a2", group: "Northwind Renewal", label: "Legal review", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "a3", group: "Northwind Renewal", label: "Pricing review", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "b1", group: "Acme Onboarding", label: "Welcome call", occurred_at: "2026-02-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "b2", group: "Acme Onboarding", label: "Access provisioned", occurred_at: "2026-02-03T00:00:00Z", is_root: false, is_branch_point: false }, + ], + edges: [ + { source: "a1", target: "a2", fused_score: 0.78 }, + { source: "a1", target: "a3", fused_score: 0.64 }, + { source: "b1", target: "b2", fused_score: 0.9 }, + ], + } satisfies LineageGraph, + currentPostId: "a1", + }, +}; + +// Edge case: a node with no explicit group (or a raw UUID group id from a +// partially-processed corpus) must still render, bucketed under "Ungrouped". +export const UngroupedNode: Story = { + args: { + graph: { + nodes: [{ id: "u1", group: "", label: "Standalone note with no thread yet", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + } satisfies LineageGraph, + }, +}; + +// Edge case: a very long post title must truncate in the graph without +// breaking layout, while the full title stays available to screen readers. +export const LongNodeLabel: Story = { + args: { + graph: { + nodes: [ + { + id: "a1", + group: "Northwind Renewal", + label: "Quarterly vendor renewal kickoff call with legal, procurement, and finance stakeholders", + occurred_at: "2026-01-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + ], + edges: [], + } satisfies LineageGraph, + }, +}; diff --git a/frontend/src/LineageDag.test.tsx b/frontend/src/LineageDag.test.tsx new file mode 100644 index 000000000..8f1001a9a --- /dev/null +++ b/frontend/src/LineageDag.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LineageDag } from "./LineageDag"; +import type { LineageGraph } from "./api"; + +describe("LineageDag", () => { + it("shows an empty-state message instead of an empty graph", () => { + render(); + expect(screen.getByText("No reconstructed lineage yet. Rebuild after seeding posts.")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("renders one branch figure per lineage group, git-branch style", () => { + const graph: LineageGraph = { + nodes: [ + { id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: true }, + { id: "a2", group: "Project Alpha", label: "Follow-up note", occurred_at: "2026-01-02T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "b1", group: "Project Beta", label: "Beta kickoff", occurred_at: "2026-01-03T00:00:00Z", is_root: true, is_branch_point: false }, + ], + edges: [{ source: "a1", target: "a2", fused_score: 0.82 }], + }; + const { container } = render(); + + expect(screen.getByText("Project Alpha (2 records, 1 lineage edges)")).toBeInTheDocument(); + expect(screen.getByText("Project Beta (1 records, 0 lineage edges)")).toBeInTheDocument(); + expect(screen.getAllByRole("img")).toHaveLength(2); + expect(container.querySelectorAll(".lineage-dag-edge")).toHaveLength(1); + expect(container.querySelector(".lineage-dag-branch")).toBeInTheDocument(); + expect(container.querySelector(".lineage-dag-root")).toBeInTheDocument(); + expect(container.querySelector(".lineage-dag-node:not(.lineage-dag-root):not(.lineage-dag-branch)")).toBeInTheDocument(); + }); + + it("groups a missing/UUID group id under an Ungrouped heading, sorted last", () => { + const graph: LineageGraph = { + nodes: [ + { id: "u1", group: "3f9c8b1a-1111-2222-3333-444455556666", label: "Loose note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "z1", group: "Zeta Corp", label: "Zeta note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + ], + edges: [], + }; + render(); + + const headings = screen.getAllByRole("img").map((img) => img.getAttribute("aria-label")); + expect(headings).toEqual(["Zeta Corp lineage", "Ungrouped lineage"]); + }); + + it("reports the clicked post id", () => { + const onSelectPost = vi.fn(); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open post: Kickoff note" })); + expect(onSelectPost).toHaveBeenCalledWith("a1"); + }); + + it("also selects a node via Enter and Space for keyboard users", () => { + const onSelectPost = vi.fn(); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + const node = screen.getByRole("button", { name: "Open post: Kickoff note" }); + fireEvent.keyDown(node, { key: "Enter" }); + fireEvent.keyDown(node, { key: " " }); + expect(onSelectPost).toHaveBeenCalledTimes(2); + }); + + it("does not select on an unrelated key press", () => { + const onSelectPost = vi.fn(); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + fireEvent.keyDown(screen.getByRole("button", { name: "Open post: Kickoff note" }), { key: "Tab" }); + expect(onSelectPost).not.toHaveBeenCalled(); + }); + + it("marks the current post distinctly from the rest", () => { + const graph: LineageGraph = { + nodes: [ + { id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "a2", group: "Project Alpha", label: "Follow-up note", occurred_at: "2026-01-02T00:00:00Z", is_root: false, is_branch_point: false }, + ], + edges: [{ source: "a1", target: "a2", fused_score: 0.5 }], + }; + render(); + + expect(screen.getByRole("button", { name: "Open post: Follow-up note" })).toHaveAttribute("aria-current", "true"); + expect(screen.getByRole("button", { name: "Open post: Kickoff note" })).not.toHaveAttribute("aria-current"); + }); + + it("truncates a very long node label instead of overflowing the graph", () => { + const longLabel = "A".repeat(60); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: longLabel, occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + expect(screen.getByText(`${"A".repeat(33)}…`)).toBeInTheDocument(); + expect(screen.queryByText(longLabel)).not.toBeInTheDocument(); + // The full label is still reachable via the accessible name for screen readers. + expect(screen.getByRole("button", { name: `Open post: ${longLabel}` })).toBeInTheDocument(); + }); + + it("does not count or render a relationship whose other endpoint is not visible", () => { + const graph: LineageGraph = { + nodes: [ + { + id: "visible-note", + group: "Project Alpha", + label: "Visible note", + occurred_at: "2026-01-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + ], + edges: [{ source: "visible-note", target: "hidden-note", fused_score: 0.92 }], + }; + const { container } = render(); + + expect(screen.getByText("Project Alpha (1 records, 0 lineage edges)")).toBeInTheDocument(); + expect(container.querySelector(".lineage-dag-edge")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/LineageDag.tsx b/frontend/src/LineageDag.tsx index f082296f8..f26574d8e 100644 --- a/frontend/src/LineageDag.tsx +++ b/frontend/src/LineageDag.tsx @@ -6,6 +6,7 @@ function truncateLabel(label: string): string { return label.length > 34 ? `${label.slice(0, 33)}…` : label; } +/** Render the authorized lineage projection and let the buyer open a post. */ export function LineageDag({ graph, onSelectPost, @@ -43,7 +44,6 @@ export function LineageDag({ {group.edges.map((edge) => { const from = byId[edge.source]; const to = byId[edge.target]; - if (!from || !to) return null; const midX = (from.x + to.x) / 2; return ( { it("labels UUID reconstruct fallbacks as Ungrouped without merging named threads", () => { expect(groupHeading("A-100")).toBe("A-100"); + expect(groupHeading("")).toBe("Ungrouped"); expect(groupHeading("cccccccc-cccc-cccc-cccc-cccccccccccc")).toBe("Ungrouped"); + expect( + layoutLineageDag({ + nodes: [ + { + id: "named", + group: "Named thread", + label: "Named note", + occurred_at: "2026-01-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + { + id: "loose", + group: "", + label: "Loose note", + occurred_at: "2026-01-02T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + ], + edges: [], + }).map((group) => group.heading), + ).toEqual(["Named thread", "Ungrouped"]); + }); + + it("places every node in a cyclic visible component without inventing a root", () => { + const [group] = layoutLineageDag({ + nodes: [ + { + id: "cycle-a", + group: "Cyclic import", + label: "First imported note", + occurred_at: "2026-01-01T00:00:00Z", + is_root: false, + is_branch_point: false, + }, + { + id: "cycle-b", + group: "Cyclic import", + label: "Second imported note", + occurred_at: "2026-01-02T00:00:00Z", + is_root: false, + is_branch_point: false, + }, + ], + edges: [ + { source: "cycle-a", target: "cycle-b", fused_score: 0.8 }, + { source: "cycle-b", target: "cycle-a", fused_score: 0.79 }, + ], + }); + + expect(group.nodes.map(({ id, x, y }) => ({ id, x, y }))).toEqual([ + { id: "cycle-a", x: 28, y: 28 }, + { id: "cycle-b", x: 28, y: 80 }, + ]); + }); + + it("terminates when a visible root feeds a cycle", () => { + const [group] = layoutLineageDag({ + nodes: ["root", "cycle-a", "cycle-b"].map((id) => ({ + id, + group: "Cyclic import", + label: id, + occurred_at: "2026-01-01T00:00:00Z", + is_root: id === "root", + is_branch_point: false, + })), + edges: [ + { source: "root", target: "cycle-a", fused_score: 0.9 }, + { source: "cycle-a", target: "cycle-b", fused_score: 0.8 }, + { source: "cycle-b", target: "cycle-a", fused_score: 0.7 }, + ], + }); + + expect(group.nodes).toHaveLength(3); + expect(group.nodes.every(({ x, y }) => Number.isFinite(x) && Number.isFinite(y))).toBe(true); + }); + + it("positions a shared child once while retaining both visible parent edges", () => { + const [group] = layoutLineageDag({ + nodes: ["root", "branch-a", "branch-b", "shared-child"].map((id) => ({ + id, + group: "Converging import", + label: id, + occurred_at: "2026-01-01T00:00:00Z", + is_root: id === "root", + is_branch_point: id === "root", + })), + edges: [ + { source: "root", target: "branch-a", fused_score: 0.9 }, + { source: "root", target: "branch-b", fused_score: 0.88 }, + { source: "branch-a", target: "shared-child", fused_score: 0.82 }, + { source: "branch-b", target: "shared-child", fused_score: 0.8 }, + { source: "root", target: "shared-child", fused_score: 0.78 }, + ], + }); + + expect(group.edges).toHaveLength(5); + expect(group.nodes.map(({ id, x, y }) => ({ id, x, y }))).toEqual([ + { id: "root", x: 28, y: 54 }, + { id: "branch-a", x: 248, y: 28 }, + { id: "branch-b", x: 248, y: 80 }, + { id: "shared-child", x: 468, y: 28 }, + ]); + }); + + it("keeps a valid relationship between two ungrouped visible nodes", () => { + const [group] = layoutLineageDag({ + nodes: [ + { + id: "ungrouped-a", + group: "", + label: "First loose note", + occurred_at: "2026-01-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + { + id: "ungrouped-b", + group: "", + label: "Second loose note", + occurred_at: "2026-01-02T00:00:00Z", + is_root: false, + is_branch_point: false, + }, + ], + edges: [{ source: "ungrouped-a", target: "ungrouped-b", fused_score: 0.88 }], + }); + + expect(group.heading).toBe("Ungrouped"); + expect(group.edges).toHaveLength(1); + }); + + it("omits dangling and cross-group edges from visible layout evidence", () => { + const groups = layoutLineageDag({ + nodes: [ + { + id: "visible-a", + group: "Project Alpha", + label: "Visible Alpha note", + occurred_at: "2026-01-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + { + id: "visible-b", + group: "Project Beta", + label: "Visible Beta note", + occurred_at: "2026-01-02T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + ], + edges: [ + { source: "visible-a", target: "hidden-note", fused_score: 0.91 }, + { source: "visible-a", target: "visible-b", fused_score: 0.84 }, + { source: "hidden-note", target: "visible-a", fused_score: 0.77 }, + ], + }); + + expect(groups.map((group) => [group.heading, group.edges])).toEqual([ + ["Project Alpha", []], + ["Project Beta", []], + ]); }); }); diff --git a/frontend/src/lineageLayout.ts b/frontend/src/lineageLayout.ts index ceb1a54aa..17f7606d5 100644 --- a/frontend/src/lineageLayout.ts +++ b/frontend/src/lineageLayout.ts @@ -45,19 +45,26 @@ function layoutGroup(nodes: LineageGraphNode[], edges: LineageGraphEdge[]): { const hasParent = new Set(edges.map((edge) => edge.target)); const roots = nodes.filter((node) => !hasParent.has(node.id)); const positions = new Map(); + const visiting = new Set(); let nextRow = 0; const walk = (id: string, depth: number) => { - const kids = (children.get(id) ?? []).filter((childId) => byId.has(childId)); + if (positions.has(id)) return; + visiting.add(id); + const kids = (children.get(id) ?? []).filter( + (childId) => byId.has(childId) && !positions.has(childId) && !visiting.has(childId), + ); if (kids.length === 0) { positions.set(id, { x: PAD + depth * COL_W, y: PAD + nextRow * ROW_H }); nextRow += 1; + visiting.delete(id); return; } const startRow = nextRow; for (const childId of kids) walk(childId, depth + 1); const midRow = (startRow + nextRow - 1) / 2; positions.set(id, { x: PAD + depth * COL_W, y: PAD + midRow * ROW_H }); + visiting.delete(id); }; for (const root of roots) walk(root.id, 0); @@ -69,7 +76,7 @@ function layoutGroup(nodes: LineageGraphNode[], edges: LineageGraphEdge[]): { } const positioned = nodes.map((node) => { - const pos = positions.get(node.id) ?? { x: PAD, y: PAD }; + const pos = positions.get(node.id)!; return { ...node, ...pos }; }); const maxX = Math.max(PAD, ...positioned.map((node) => node.x)); @@ -95,8 +102,10 @@ export function subgraphForPost(graph: LineageGraph, postId: string): LineageGra }; } +/** Lay out only relationships whose two endpoints share one visible group. */ export function layoutLineageDag(graph: LineageGraph): LaidOutGroup[] { const buckets = new Map(); + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); for (const node of graph.nodes) { const group = node.group || "Ungrouped"; const bucket = buckets.get(group) ?? { nodes: [], edges: [] }; @@ -104,11 +113,13 @@ export function layoutLineageDag(graph: LineageGraph): LaidOutGroup[] { buckets.set(group, bucket); } for (const edge of graph.edges) { - const source = graph.nodes.find((node) => node.id === edge.source); - const group = source?.group || "Ungrouped"; - const bucket = buckets.get(group) ?? { nodes: [], edges: [] }; - bucket.edges.push(edge); - buckets.set(group, bucket); + const source = nodesById.get(edge.source); + const target = nodesById.get(edge.target); + if (!source || !target) continue; + const sourceGroup = source.group || "Ungrouped"; + const targetGroup = target.group || "Ungrouped"; + if (sourceGroup !== targetGroup) continue; + buckets.get(sourceGroup)!.edges.push(edge); } const groups = [...buckets.entries()].map(([group, { nodes, edges }]) => {