diff --git a/src/components/CompletenessPanel.test.tsx b/src/components/CompletenessPanel.test.tsx index dd0a632..fccf3aa 100644 --- a/src/components/CompletenessPanel.test.tsx +++ b/src/components/CompletenessPanel.test.tsx @@ -1,8 +1,10 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; import { render, screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { CompletenessPanel } from "@/components/CompletenessPanel"; -import type { AnswerCompleteness } from "@/lib/contracts"; +import type { AnswerCompleteness, PlanRequirementOutcomeRow } from "@/lib/contracts"; /** * CHAOS-4413/CHAOS-4642: `completeness` is a REQUIRED field on the pinned @@ -86,3 +88,434 @@ describe("CompletenessPanel", () => { ); }); }); + +/** + * CHAOS-5640/CHAOS-5109: `state` is server-derived from the requirement + * outcomes, independent of `terminal_status` — its own badge, exhaustive + * over the four-member enum, with a tone that never reads `not_derived` as + * `complete` and never hides it. + */ +describe("CompletenessPanel — completeness.state badge", () => { + const EXPECTED_TONE: Record = { + not_derived: "badge--neutral", + complete: "badge--ok", + partial: "badge--warn", + degraded: "badge--bad", + }; + + for (const state of ["not_derived", "complete", "partial", "degraded"] as const) { + it(`renders the "${state}" state with its own title and tone`, () => { + const completeness: AnswerCompleteness = { + terminal_status: "no_match", + claimed_facts_count: 0, + rows_count: 0, + state, + }; + const { unmount } = render(); + + const badge = screen.getByTitle(`state: ${state}`); + expect(badge).toHaveTextContent(state.replaceAll("_", " ")); + expect(badge).toHaveClass(EXPECTED_TONE[state]); + + unmount(); + }); + } + + /** + * D25/B2 (chris 2026-09-13): `terminal_status` (disposition) and `state` + * (derived outcome) answer different questions and can legitimately + * disagree on the same result. This panel never reconciles them — both + * badges stay visible, unedited. + */ + it("shows BOTH badges, unreconciled, when terminal_status and state disagree", () => { + const completeness: AnswerCompleteness = { + terminal_status: "complete", + claimed_facts_count: 4, + rows_count: 2, + state: "partial", + }; + render(); + const row = screen.getByTestId("completeness-chip-row"); + expect(within(row).getByTitle("terminal_status: complete")).toHaveTextContent("complete"); + expect(within(row).getByTitle("state: partial")).toHaveTextContent("partial"); + }); +}); + +/** + * The outcome badge's tone switch is exhaustive over the closed enum, same + * discipline as the `state` badge above -- every member gets its own + * tone-asserted test, not just the two an earlier wire-order test happens + * to use. + */ +describe("CompletenessPanel — outcome badge tone", () => { + const EXPECTED_TONE: Record = { + satisfied: "badge--ok", + not_applicable: "badge--neutral", + narrowed: "badge--warn", + not_attempted: "badge--warn", + unavailable: "badge--bad", + }; + + for (const outcome of [ + "satisfied", + "narrowed", + "unavailable", + "not_applicable", + "not_attempted", + ] as const) { + it(`renders the "${outcome}" outcome with its own title and tone`, () => { + const completeness: AnswerCompleteness = { + terminal_status: "partial", + claimed_facts_count: 1, + rows_count: 1, + state: "partial", + outcomes: [{ ...baseOutcomeRow(), outcome }], + }; + const { unmount } = render(); + + const badge = screen.getByTitle(`outcome: ${outcome}`); + expect(badge).toHaveTextContent(outcome.replaceAll("_", " ")); + expect(badge).toHaveClass(EXPECTED_TONE[outcome]); + + unmount(); + }); + } +}); + +function baseOutcomeRow(): PlanRequirementOutcomeRow { + return { + stage: "assembled_result", + requirement: "identity.default_requirement", + obligation: "read", + outcome: "satisfied", + impact: "none", + cause_observed: true, + served: 1, + declared: 1, + }; +} + +function outcomeRow(overrides: Partial = {}): PlanRequirementOutcomeRow { + return { ...baseOutcomeRow(), ...overrides }; +} + +/** + * CHAOS-5109: the outcome table is the reader-facing half of the same + * derivation `state` above summarizes — one row per `outcomes[]` entry, + * in the server's OWN order, never re-sorted or de-duplicated. + */ +describe("CompletenessPanel — requirement outcomes", () => { + it("shows an explicit empty-state line when outcomes is absent, agreeing with not_derived", () => { + const completeness: AnswerCompleteness = { + terminal_status: "no_match", + claimed_facts_count: 0, + rows_count: 0, + state: "not_derived", + }; + render(); + expect(screen.getByTestId("completeness-outcomes-empty")).toHaveTextContent( + "No requirement outcomes were derived", + ); + expect(screen.queryByTestId("completeness-outcomes-table")).not.toBeInTheDocument(); + expect(screen.getByTitle("state: not_derived")).toBeInTheDocument(); + }); + + it("shows an explicit empty-state line when outcomes is an empty array, agreeing with not_derived", () => { + const completeness: AnswerCompleteness = { + terminal_status: "no_match", + claimed_facts_count: 0, + rows_count: 0, + state: "not_derived", + outcomes: [], + }; + render(); + expect(screen.getByTestId("completeness-outcomes-empty")).toBeInTheDocument(); + expect(screen.queryByTestId("completeness-outcomes-table")).not.toBeInTheDocument(); + }); + + it("renders one row per outcome, in wire order, with identity and outcome", () => { + const outcomes: PlanRequirementOutcomeRow[] = [ + outcomeRow({ requirement: "identity.first_requirement", outcome: "satisfied" }), + outcomeRow({ requirement: "identity.second_requirement", outcome: "narrowed" }), + outcomeRow({ requirement: "identity.third_requirement", outcome: "unavailable" }), + ]; + const completeness: AnswerCompleteness = { + terminal_status: "partial", + terminal_reason: "limitation_disclosed", + claimed_facts_count: 3, + rows_count: 3, + state: "partial", + outcomes, + }; + render(); + + const rows = screen.getAllByTestId("completeness-outcome-row"); + expect(rows).toHaveLength(3); + // Wire order, never re-sorted: row i shows outcome i's own fields. + outcomes.forEach((outcome, index) => { + const row = within(rows[index]!); + expect(row.getByTitle(outcome.requirement!)).toHaveTextContent( + outcome.requirement!.replaceAll("_", " "), + ); + expect(row.getByTitle(`outcome: ${outcome.outcome}`)).toHaveTextContent( + outcome.outcome.replaceAll("_", " "), + ); + }); + }); + + it("shows the reason field (declared cause) beside the outcome when the row carries one", () => { + const outcomes: PlanRequirementOutcomeRow[] = [ + outcomeRow({ + requirement: "identity.narrowed_requirement", + outcome: "narrowed", + impact: "scope", + cause_narrowing: "top_n_selection", + cause_observed: true, + }), + ]; + const completeness: AnswerCompleteness = { + terminal_status: "partial", + terminal_reason: "limitation_disclosed", + claimed_facts_count: 1, + rows_count: 1, + state: "partial", + outcomes, + }; + render(); + const row = screen.getByTestId("completeness-outcome-row"); + expect(row).toHaveTextContent("top n selection"); + }); + + it("marks a defaulted (not observed) cause distinctly from a reported one", () => { + const outcomes: PlanRequirementOutcomeRow[] = [ + outcomeRow({ + requirement: "identity.defaulted_requirement", + outcome: "narrowed", + impact: "scope", + cause_overrun: "items", + cause_observed: false, + }), + ]; + const completeness: AnswerCompleteness = { + terminal_status: "partial", + claimed_facts_count: 1, + rows_count: 1, + state: "partial", + outcomes, + }; + render(); + expect(screen.getByTestId("completeness-outcome-row")).toHaveTextContent( + "items (defaulted)", + ); + }); + + it("renders a row with no requirement identity as an honest gap, not a dropped row", () => { + const { + requirement: _requirement, + obligation: _obligation, + ...withoutIdentity + } = outcomeRow(); + const outcomes: PlanRequirementOutcomeRow[] = [withoutIdentity]; + const completeness: AnswerCompleteness = { + terminal_status: "complete", + claimed_facts_count: 1, + rows_count: 1, + state: "complete", + outcomes, + }; + render(); + const rows = screen.getAllByTestId("completeness-outcome-row"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent("—"); + }); + + /** + * `obligation` is present exactly when `requirement` is, per the + * schema -- both must render, not just the identity. + */ + it("renders the row's obligation beside its requirement identity", () => { + const outcomes: PlanRequirementOutcomeRow[] = [ + outcomeRow({ requirement: "readiness.release", obligation: "read" }), + ]; + const completeness: AnswerCompleteness = { + terminal_status: "partial", + claimed_facts_count: 1, + rows_count: 1, + state: "partial", + outcomes, + }; + render(); + expect(screen.getByTestId("completeness-outcome-obligation")).toHaveTextContent( + "obligation: read", + ); + }); + + it("omits the obligation line when the row carries no obligation", () => { + const { obligation: _obligation, ...withoutObligation } = outcomeRow(); + render( + , + ); + expect(screen.queryByTestId("completeness-outcome-obligation")).not.toBeInTheDocument(); + }); + + /** + * `impact` -- "what the reader loses" -- is a different fact than the + * outcome that caused it, so it is its own column. + */ + it("shows the row's impact as its own visible column", () => { + const outcomes: PlanRequirementOutcomeRow[] = [ + outcomeRow({ outcome: "narrowed", impact: "scope" }), + ]; + render( + , + ); + expect(screen.getByTestId("completeness-outcome-row")).toHaveTextContent("scope"); + }); + + /** `served`/`declared` are their own column, alongside outcome and impact. */ + it("shows the row's served and declared counts", () => { + const outcomes: PlanRequirementOutcomeRow[] = [outcomeRow({ served: 2, declared: 5 })]; + render( + , + ); + expect(screen.getByTestId("completeness-outcome-row")).toHaveTextContent("2 / 5"); + }); + + /** + * The `refinements` step-by-step trail renders behind a per-row + * disclosure, every step's own fields visible once opened, never + * summarized away. + */ + it("discloses every refinement step's own fields when the row carries a chain", () => { + const outcomes: PlanRequirementOutcomeRow[] = [ + outcomeRow({ + outcome: "narrowed", + impact: "scope", + refinements: [ + { stage: "planning", basis: "attention_rank", before: 10, after: 5 }, + { stage: "projection", overrun: "items", before: 5, after: 3 }, + ], + }), + ]; + render( + , + ); + const row = screen.getByTestId("completeness-outcome-row"); + expect(within(row).getByText("2 refinement steps")).toBeInTheDocument(); + const steps = within(row).getAllByTestId("completeness-outcome-refinement-step"); + expect(steps).toHaveLength(2); + expect(steps[0]).toHaveTextContent("planning"); + expect(steps[0]).toHaveTextContent("10"); + expect(steps[0]).toHaveTextContent("5"); + expect(steps[0]).toHaveTextContent("attention rank"); + expect(steps[1]).toHaveTextContent("projection"); + expect(steps[1]).toHaveTextContent("items"); + }); + + it("shows no refinement disclosure when the row carries no refinements", () => { + const outcomes: PlanRequirementOutcomeRow[] = [outcomeRow()]; + render( + , + ); + expect( + screen.queryByTestId("completeness-outcome-refinement-step"), + ).not.toBeInTheDocument(); + }); + + /** + * `outcomes` carries `@maxItems 200` on the wire — this panel renders + * what the contract sends, never a client-side cap of its own. + */ + it("renders all 200 rows at the contract's own bound, without truncation", () => { + const outcomes: PlanRequirementOutcomeRow[] = Array.from({ length: 200 }, (_unused, i) => + outcomeRow({ requirement: `identity.requirement_${String(i)}` }), + ); + const completeness: AnswerCompleteness = { + terminal_status: "complete", + claimed_facts_count: 200, + rows_count: 200, + state: "complete", + outcomes, + }; + render(); + expect(screen.getAllByTestId("completeness-outcome-row")).toHaveLength(200); + }); +}); + +/** + * Every pinned example under `src/contracts/examples` that carries a + * `completeness` block must render through this panel without throwing — + * enumerated from the files themselves, never a hand-picked subset, so a + * new example added later is covered automatically. + */ +describe("CompletenessPanel — every example fixture carrying completeness", () => { + const EXAMPLES_DIR = path.resolve(import.meta.dirname, "../contracts/examples"); + const exampleFiles = readdirSync(EXAMPLES_DIR).filter((entry) => entry.endsWith(".json")); + + const completenessExamples = exampleFiles + .map((file) => { + const value = JSON.parse(readFileSync(path.join(EXAMPLES_DIR, file), "utf8")) as { + completeness?: AnswerCompleteness; + }; + return { file, completeness: value.completeness }; + }) + .filter( + (entry): entry is { file: string; completeness: AnswerCompleteness } => + entry.completeness !== undefined, + ); + + // A regression here means the fixture set changed shape and this sweep + // silently stopped covering anything — fail loudly instead of passing + // vacuously on zero fixtures. + it("found at least one example carrying completeness", () => { + expect(completenessExamples.length).toBeGreaterThan(0); + }); + + for (const { file, completeness } of completenessExamples) { + it(`renders ${file} without throwing`, () => { + const { unmount } = render(); + expect(screen.getByTestId("completeness-panel")).toBeInTheDocument(); + unmount(); + }); + } +}); diff --git a/src/components/CompletenessPanel.tsx b/src/components/CompletenessPanel.tsx index 608e490..ba066e3 100644 --- a/src/components/CompletenessPanel.tsx +++ b/src/components/CompletenessPanel.tsx @@ -1,16 +1,66 @@ import { useId } from "react"; import { Badge } from "@/components/Badge"; -import type { AnswerCompleteness } from "@/lib/contracts"; -import { humanizeTerm, statusTone } from "@/lib/presentation"; +import type { + AnswerCompleteness, + PlanRequirementOutcomeRow, + RequirementRefinement, +} from "@/lib/contracts"; +import { + completenessStateTone, + humanizeTerm, + planRequirementOutcomeTone, + statusTone, +} from "@/lib/presentation"; export type CompletenessPanelProps = { readonly completeness: AnswerCompleteness; }; /** - * CHAOS-4413/CHAOS-4642: shows how much of an answer is here and why it - * stopped where it did. + * The declared cause behind one row's outcome, when the server named one -- + * `cause_coverage`/`cause_narrowing`/`cause_overrun` are not mutually + * exclusive on the wire, so every one the row carries is shown, never just + * the first. `cause_observed` is per-row, not per-cause: it says whether + * THIS row's named cause was reported by a mechanism or defaulted to, and is + * appended so a defaulted cause is never mistaken for an observed one. + */ +function outcomeCauses(row: PlanRequirementOutcomeRow): readonly string[] { + const causes = [row.cause_coverage, row.cause_narrowing, row.cause_overrun].filter( + (value): value is string => value !== undefined, + ); + if (causes.length === 0) return []; + return row.cause_observed ? causes : causes.map((cause) => `${cause} (defaulted)`); +} + +/** + * One `RequirementRefinement` step, as its own field=value pairs -- a + * closed-vocabulary trail, never prose. `basis`/`overrun`/`coverage` are + * not mutually exclusive on the wire (the schema requires at least one), + * so every one the step carries is shown. + */ +function RefinementStep({ + step, + index, +}: { + readonly step: RequirementRefinement; + readonly index: number; +}) { + const causes = [ + step.basis === undefined ? null : `basis: ${humanizeTerm(step.basis)}`, + step.overrun === undefined ? null : `overrun: ${humanizeTerm(step.overrun)}`, + step.coverage === undefined ? null : `coverage: ${humanizeTerm(step.coverage)}`, + ].filter((entry): entry is string => entry !== null); + return ( +
  • + {`${String(index + 1)}. ${humanizeTerm(step.stage)} — ${String(step.before)} → ${String(step.after)}${causes.length === 0 ? "" : ` (${causes.join(", ")})`}`} +
  • + ); +} + +/** + * CHAOS-4413/CHAOS-4642/CHAOS-5640/CHAOS-5109: shows how much of an answer is + * here and why it stopped where it did. * * `terminal_status` reuses the exact same closed vocabulary as the result's * own `status` (`statusTone` is exhaustive over both), so its badge always @@ -23,9 +73,48 @@ export type CompletenessPanelProps = { * `terminal_reason` is a closed vocabulary naming WHY, never the engine's or * a model's own raw text (CHAOS-4413's own schema doc comment) — shown * verbatim, exactly like `CoveragePanel`'s degraded reasons. + * + * `state` (CHAOS-5640) is a SECOND, independent completeness signal, server- + * derived from the requirement outcomes below it rather than authored — + * never model text and never reconciled against `terminal_status`. The two + * vocabularies answer different questions (disposition vs derived outcome) + * and can legitimately disagree on the same result (e.g. a `complete` + * disposition with a `partial` derived state); when they do, BOTH badges + * stay visible side by side — this panel never picks a winner or collapses + * one into the other. `not_derived` gets its own badge and tone + * (`completenessStateTone`), never the `complete` tone and never omitted: + * an answer with no derived outcomes is an honest gap, not a vacuous + * success (the exact failure `AnswerCompleteness.state`'s schema doc + * comment names). + * + * `outcomes` (CHAOS-5109) is the one authority for what this answer was + * supposed to contain and what became of it — rendered one row per entry, + * in the SERVER'S OWN ORDER, never re-sorted or de-duplicated (every stage + * only appends to it, so wire order is itself part of what it discloses). + * Absent or empty renders an explicit empty-state line instead of a table; + * the contract guarantees that shape agrees with `state === "not_derived"` + * (an empty outcome set is exactly what makes `state` `not_derived`), so + * this view need not re-derive or assert that invariant itself. + * + * Every field on a row is either a visible column or a disclosed one, never + * silently absent (same "nothing is silently discarded" discipline as + * `CohortRankingPanel`/`CoveragePanel`): `obligation` renders beside the + * `requirement` identity (present exactly when `requirement` is, per the + * schema); `impact` is its own column, since "what the reader loses" is a + * different fact than the outcome that caused it; `served`/`declared` are + * their own column; the `refinements` step-by-step trail, when the row + * carries one, sits behind a per-row `
    ` (same pattern as + * `CoveragePanel`'s "Source details") rather than crowding the scannable + * row, since it is optional narrative depth on top of the row's own facts. + * + * `refusal_basis` is a machine field this panel does not render — same rule + * `DeterministicAnswerView` already applies to the top-level field of the + * same name: what must reach the reader is the service's own limitation + * sentence, not the closed-vocabulary token. */ export function CompletenessPanel({ completeness }: CompletenessPanelProps) { const idPrefix = useId(); + const outcomes = completeness.outcomes ?? []; return (
    {humanizeTerm(completeness.terminal_status)} + + {humanizeTerm(completeness.state)} +

    {`${String(completeness.claimed_facts_count)} claimed fact${completeness.claimed_facts_count === 1 ? "" : "s"} · ${String(completeness.rows_count)} row${completeness.rows_count === 1 ? "" : "s"}`} @@ -51,6 +146,94 @@ export function CompletenessPanel({ completeness }: CompletenessPanelProps) { {humanizeTerm(completeness.terminal_reason)}

    ) : null} +

    + Requirement outcomes +

    + {outcomes.length === 0 ? ( +

    + No requirement outcomes were derived for this answer. +

    + ) : ( +
    + + + + + + + + + + + + {outcomes.map((row, index) => { + const causes = outcomeCauses(row); + const refinements = row.refinements ?? []; + return ( + // Index key: rows carry no unique id on the + // wire and this list is never reordered or + // filtered, only ever rendered in the + // server's own append-only order. + + + + + + + + ); + })} + +
    RequirementOutcomeImpactServed / declaredReason
    + {row.requirement === undefined + ? "—" + : humanizeTerm(row.requirement)} + {row.obligation === undefined ? null : ( + <> +
    + + {`obligation: ${humanizeTerm(row.obligation)}`} + + + )} +
    + + {humanizeTerm(row.outcome)} + + {humanizeTerm(row.impact)}{`${String(row.served)} / ${String(row.declared)}`} + {causes.length === 0 + ? "—" + : causes.map(humanizeTerm).join(" · ")} + {refinements.length === 0 ? null : ( +
    + + {refinements.length === 1 + ? "1 refinement step" + : `${String(refinements.length)} refinement steps`} + +
      + {refinements.map((step, stepIndex) => ( + // Index key: steps carry no unique + // id on the wire and this chain is + // never reordered or filtered. + + ))} +
    +
    + )} +
    +
    + )}
    ); } diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts index b0eaf76..2bc5f39 100644 --- a/src/lib/contracts.ts +++ b/src/lib/contracts.ts @@ -54,8 +54,13 @@ export type { // CHAOS-4636/CHAOS-4668: one recorded narrowing step ("showing 2 of 3 // teams" and why). PlanNarrowing, + // CHAOS-5640/CHAOS-5109: one requirement's derived outcome inside + // `AnswerCompleteness.outcomes` -- what the answer was supposed to + // contain and what became of it. + PlanRequirementOutcomeRow, PriorSubjectReceiptDispositionEntry, RelationshipPath, + RequirementRefinement, // CHAOS-4415: the service's own conditional render shapes and their // parts. Exported by name because the generated module is rewritten // wholesale on every pin bump (see this file's own header). diff --git a/src/lib/presentation.ts b/src/lib/presentation.ts index cb117c4..cda9329 100644 --- a/src/lib/presentation.ts +++ b/src/lib/presentation.ts @@ -7,10 +7,12 @@ * alongside the tone, so a reader can see the vocabulary, not just the color. */ import type { + AnswerCompleteness, CohortMemberDataCompleteness, CohortMemberOutcome, CoverageState, InvestigationStatus, + PlanRequirementOutcomeRow, PriorSubjectReceiptDisposition, StructureDisposition, SubjectCandidateState, @@ -141,6 +143,52 @@ export function cohortDataCompletenessTone(completeness: CohortMemberDataComplet } } +/** + * Tone for `completeness.state` (CHAOS-5640/CHAOS-5109) -- what the derived + * requirement outcomes add up to, DISTINCT from `terminal_status`'s own tone + * (`statusTone` above): the two vocabularies answer different questions and + * can legitimately disagree on the same result, so this switch never reads + * `terminal_status`. Exhaustive over the closed enum. + * + * `not_derived` is `neutral`, never `ok`: it is a real, honest state (no + * outcome rows exist to derive from) and must never read as "complete" — + * the exact vacuous-success failure `AnswerCompleteness.state`'s own schema + * doc comment exists to name. + */ +export function completenessStateTone(state: AnswerCompleteness["state"]): Tone { + switch (state) { + case "complete": + return "ok"; + case "partial": + return "warn"; + case "degraded": + return "bad"; + case "not_derived": + return "neutral"; + } +} + +/** + * Tone for one `PlanRequirementOutcomeRow.outcome` (CHAOS-5640/CHAOS-5109). + * Exhaustive over the closed enum, same discipline as `cohortOutcomeTone`: + * `not_applicable` is `neutral` (nothing was owed, so nothing was lost), the + * two impact-bearing outcomes are `warn`, and `unavailable` -- the one + * outcome the derived `state` above can turn `degraded` on -- is `bad`. + */ +export function planRequirementOutcomeTone(outcome: PlanRequirementOutcomeRow["outcome"]): Tone { + switch (outcome) { + case "satisfied": + return "ok"; + case "not_applicable": + return "neutral"; + case "narrowed": + case "not_attempted": + return "warn"; + case "unavailable": + return "bad"; + } +} + /** * Renders a `snake_case` contract term for reading, without losing it: callers * show this next to (or as the title of) the raw term.