diff --git a/docs/workbench/wave-M-worthiness.md b/docs/workbench/wave-M-worthiness.md index b98a275a..21144689 100644 --- a/docs/workbench/wave-M-worthiness.md +++ b/docs/workbench/wave-M-worthiness.md @@ -160,6 +160,20 @@ Detailed brief pack: `docs/handoff/wave-M-phaseB-calibration-briefs-2026-06-13.m ratification boundary, and grounded-after scenes remain report-authored evidence. This is the first process trace; the fuller Calibration/Proposal integrated loop is still open. + **C2-b implementation note (2026-06-13):** the run detail is no longer a flat + JSON dump — each scene is now a typed loop stage. The reader + (`_contemplation_scenes`) projects every scene onto a canonical + `stage_role` (`cold_attempt`/`engine_enrichment`/`engine_proposal`/ + `operator_ratifies`/`grounded`/`other`) and pulls the loop's connective ids + (`proposal_id`, `candidate_id`, `proposal_state`, `grounding_source`) out of + the raw detail; the UI renders the arc "attempt → enrich → propose → ratify → + grounded" with named stages, cold→grounded bookends, and the ids as evidence. + **Honest wrinkle (surfaced, not faked):** the fixture proposals do NOT resolve + in the live proposal log (source_kinds `exemplar_corpus`/`operator`, none + `contemplation`), so the proposal id is shown as evidence but is intentionally + NOT a clickable cross-route link — a dead link would be theater. Live + Proposals/Calibration navigation is deferred until real contemplation + proposals reach the log (reader-verified linking is the follow-up). - **C3 — Field substrate (honest, read-only, hard):** real `FieldState` + `versor_condition` for a turn, rendered as **inspectable exact numbers and invariant status** — `versor_condition < 1e-6` as a live "field is valid" diff --git a/tests/test_workbench_contemplation_reader.py b/tests/test_workbench_contemplation_reader.py new file mode 100644 index 00000000..55dfab0a --- /dev/null +++ b/tests/test_workbench_contemplation_reader.py @@ -0,0 +1,76 @@ +"""C2-b — the contemplation loop as a typed staged projection. + +The reader turns each scene into a canonical loop stage (cold attempt → engine +enrichment → engine-authored proposal → operator ratifies → grounded) and pulls +the connective ids (proposal/candidate/state/grounding) out of the raw detail so +the UI renders a process, not a JSON dump. +""" + +from __future__ import annotations + +import pytest + +from workbench import readers + + +class TestStageRole: + @pytest.mark.parametrize( + "scene_id,role", + [ + ("S1_cold_session", "cold_attempt"), + ("S2_checkpoint_enrichment", "engine_enrichment"), + ("S3_engine_authored_proposal", "engine_proposal"), + ("S4_operator_ratifies", "operator_ratifies"), + ("S5_grounded_session", "grounded"), + ], + ) + def test_canonical_arc_ids_map_to_roles(self, scene_id: str, role: str) -> None: + assert readers._contemplation_stage_role(scene_id) == role + + def test_unknown_scene_id_falls_back_to_other(self) -> None: + # Honest closed set: anything outside the arc is "other", never guessed. + assert readers._contemplation_stage_role("S9_unmapped_thing") == "other" + assert readers._contemplation_stage_role("") == "other" + + +class TestSceneProjection: + def test_connective_ids_are_pulled_from_detail(self) -> None: + report = { + "scenes": [ + { + "scene": "S1_cold_session", + "claim": "cold", + "detail": {"grounding_source": "none"}, + }, + { + "scene": "S2_checkpoint_enrichment", + "claim": "enrich", + "detail": {"candidate_id": "cand-123"}, + }, + { + "scene": "S3_engine_authored_proposal", + "claim": "propose", + "detail": {"proposal_id": "prop-456", "state": "pending"}, + }, + ] + } + scenes = readers._contemplation_scenes(report) + s1, s2, s3 = scenes + assert (s1.stage_role, s1.grounding_source) == ("cold_attempt", "none") + assert (s2.stage_role, s2.candidate_id) == ("engine_enrichment", "cand-123") + assert s3.stage_role == "engine_proposal" + assert s3.proposal_id == "prop-456" + assert s3.proposal_state == "pending" + # Absent ids stay None, never empty strings. + assert s1.proposal_id is None and s1.candidate_id is None + + def test_detail_is_preserved_for_the_inspector(self) -> None: + report = {"scenes": [{"scene": "S1_cold_session", "claim": "c", "detail": {"x": 1}}]} + (scene,) = readers._contemplation_scenes(report) + assert scene.detail == {"x": 1} + + def test_non_dict_scene_is_skipped(self) -> None: + report = {"scenes": ["not a scene", {"scene": "S1_cold_session", "claim": "c"}]} + scenes = readers._contemplation_scenes(report) + assert len(scenes) == 1 + assert scenes[0].stage_role == "cold_attempt" diff --git a/workbench-ui/schema-snapshot.json b/workbench-ui/schema-snapshot.json index 6032af56..ecb17048 100644 --- a/workbench-ui/schema-snapshot.json +++ b/workbench-ui/schema-snapshot.json @@ -103,7 +103,12 @@ "ContemplationScene": [ "scene_id", "claim", - "detail" + "detail", + "stage_role", + "proposal_id", + "candidate_id", + "proposal_state", + "grounding_source" ], "DemoDagEdge": [ "from_node", diff --git a/workbench-ui/src/app/contemplation/ContemplationRoute.test.tsx b/workbench-ui/src/app/contemplation/ContemplationRoute.test.tsx index 47dca8df..92b28201 100644 --- a/workbench-ui/src/app/contemplation/ContemplationRoute.test.tsx +++ b/workbench-ui/src/app/contemplation/ContemplationRoute.test.tsx @@ -38,11 +38,21 @@ const detail: ContemplationRunDetail = { scene_id: "S1_cold_session", claim: "cold session refused", detail: { grounding_source: "none" }, + stage_role: "cold_attempt", + proposal_id: null, + candidate_id: null, + proposal_state: null, + grounding_source: "none", }, { scene_id: "S3_engine_authored_proposal", claim: "proposal remains pending", - detail: { state: "pending" }, + detail: { state: "pending", proposal_id: "05e167163c1f22c1" }, + stage_role: "engine_proposal", + proposal_id: "05e167163c1f22c1", + candidate_id: null, + proposal_state: "pending", + grounding_source: null, }, ], }; @@ -122,6 +132,28 @@ describe("ContemplationRoute", () => { expect(screen.getByText(/\"connective\":\"reveals\"/)).toBeInTheDocument(); }); + it("renders the loop as a staged process with connective evidence, no dead link", async () => { + stubFetch(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(await screen.findByText("Why does narrative exist?")); + await screen.findByText("Process Trace"); + + // Canonical stage roles are named, not raw scene ids alone. + expect(screen.getByText("Cold attempt")).toBeInTheDocument(); + expect(screen.getByText("Engine-authored proposal")).toBeInTheDocument(); + expect( + screen.getByText(/attempt → enrich → propose → ratify → grounded/), + ).toBeInTheDocument(); + + // The proposal id is surfaced as evidence with its state... + const evidence = screen.getByText(/05e167163c1f22c1 · pending/); + expect(evidence).toBeInTheDocument(); + // ...but NOT as a clickable link (it does not resolve in the live log). + expect(evidence.closest("a")).toBeNull(); + }); + it("renders the honest absence state when no reports exist", async () => { stubFetch([]); renderRoute(); diff --git a/workbench-ui/src/app/contemplation/ContemplationRoute.tsx b/workbench-ui/src/app/contemplation/ContemplationRoute.tsx index f10f8dd9..7b133d1a 100644 --- a/workbench-ui/src/app/contemplation/ContemplationRoute.tsx +++ b/workbench-ui/src/app/contemplation/ContemplationRoute.tsx @@ -15,9 +15,21 @@ import type { ContemplationRunDetail, ContemplationRunSummary, ContemplationScene, + ContemplationStageRole, } from "../../types/api"; import { pushRecentItem } from "../commandRegistry"; +// The contemplation loop made legible: attempt → enrich → propose → ratify → +// grounded. Labels for the canonical ADR-0172 stage roles. +const STAGE_LABEL: Record = { + cold_attempt: "Cold attempt", + engine_enrichment: "Engine enrichment", + engine_proposal: "Engine-authored proposal", + operator_ratifies: "Operator ratifies", + grounded: "Grounded", + other: "Stage", +}; + function errorMessage(error: unknown): string { return error instanceof WorkbenchApiError ? error.message @@ -74,7 +86,38 @@ function RunRow({ ); } -function SceneCard({ scene, index }: { scene: ContemplationScene; index: number }) { +function StageEvidence({ scene }: { scene: ContemplationScene }) { + // The loop's connective tissue, pulled out of the raw detail. Ids are + // surfaced as evidence (copyable); cross-route navigation is intentionally + // NOT a live link yet — these fixture proposals do not resolve in the live + // proposal log, and a dead link would be theater. + const rows: { label: string; value: string }[] = []; + if (scene.grounding_source) rows.push({ label: "grounding", value: scene.grounding_source }); + if (scene.candidate_id) rows.push({ label: "candidate", value: scene.candidate_id }); + if (scene.proposal_id) { + rows.push({ + label: "proposal", + value: scene.proposal_state + ? `${scene.proposal_id} · ${scene.proposal_state}` + : scene.proposal_id, + }); + } + if (rows.length === 0) return null; + return ( +
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+ ); +} + +function ProcessStageCard({ scene, index }: { scene: ContemplationScene; index: number }) { return (
  • @@ -83,20 +126,66 @@ function SceneCard({ scene, index }: { scene: ContemplationScene; index: number
    -
    - {scene.scene_id} +
    + + {STAGE_LABEL[scene.stage_role]} + + + {scene.scene_id} +
    -

    +

    {scene.claim}

    -
    - -
    + +
    + + raw detail + +
    + +
    +
  • ); } +function Bookend({ + label, + payload, +}: { + label: string; + payload: Record | null | undefined; +}) { + const surface = typeof payload?.surface === "string" ? payload.surface : null; + const grounding = + typeof payload?.grounding_source === "string" ? payload.grounding_source : null; + return ( +
    +
    + {label} + {grounding ? ( + {grounding} + ) : null} +
    + {surface ? ( +

    {surface}

    + ) : ( + not recorded + )} +
    + + raw + +
    + +
    +
    +
    + ); +} + function DetailPanel({ detail }: { detail: ContemplationRunDetail }) { const digest = digestPayload(detail.source_digest); return ( @@ -127,24 +216,19 @@ function DetailPanel({ detail }: { detail: ContemplationRunDetail }) { ]} />
    -
    -
    - before -
    - -
    -
    -
    - after -
    - -
    + + +
    +
    +
    + Learning loop · attempt → enrich → propose → ratify → grounded +
    +
      + {detail.scenes.map((scene, index) => ( + + ))} +
    -
      - {detail.scenes.map((scene, index) => ( - - ))} -
    ); diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index bf372395..cf15d6db 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -412,10 +412,23 @@ export interface DemoRunResult { scenarios: DemoScenarioRunResult[]; } +export type ContemplationStageRole = + | "cold_attempt" + | "engine_enrichment" + | "engine_proposal" + | "operator_ratifies" + | "grounded" + | "other"; + export interface ContemplationScene { scene_id: string; claim: string; detail: Record; + stage_role: ContemplationStageRole; + proposal_id: string | null; + candidate_id: string | null; + proposal_state: string | null; + grounding_source: string | null; } export interface ContemplationRunSummary { diff --git a/workbench/readers.py b/workbench/readers.py index f92c0655..e91c1c6a 100644 --- a/workbench/readers.py +++ b/workbench/readers.py @@ -1015,6 +1015,30 @@ def _contemplation_run_paths() -> list[Path]: ) +# scene-id token -> canonical loop stage role. Ordered: first match wins. +_CONTEMPLATION_STAGE_TOKENS: tuple[tuple[str, str], ...] = ( + ("cold", "cold_attempt"), + ("enrich", "engine_enrichment"), + ("checkpoint", "engine_enrichment"), + ("proposal", "engine_proposal"), + ("authored", "engine_proposal"), + ("ratif", "operator_ratifies"), + ("grounded", "grounded"), +) + + +def _contemplation_stage_role(scene_id: str) -> str: + low = scene_id.lower() + for token, role in _CONTEMPLATION_STAGE_TOKENS: + if token in low: + return role + return "other" + + +def _opt_str(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + def _contemplation_scenes(report: dict[str, Any]) -> list[ContemplationScene]: scenes = report.get("scenes") if not isinstance(scenes, list): @@ -1023,12 +1047,19 @@ def _contemplation_scenes(report: dict[str, Any]) -> list[ContemplationScene]: for index, scene in enumerate(scenes): if not isinstance(scene, dict): continue - detail = scene.get("detail") + raw_detail = scene.get("detail") + detail = raw_detail if isinstance(raw_detail, dict) else {} + scene_id = str(scene.get("scene") or f"scene_{index + 1}") out.append( ContemplationScene( - scene_id=str(scene.get("scene") or f"scene_{index + 1}"), + scene_id=scene_id, claim=str(scene.get("claim") or ""), - detail=detail if isinstance(detail, dict) else {}, + detail=detail, + stage_role=_contemplation_stage_role(scene_id), # type: ignore[arg-type] + proposal_id=_opt_str(detail.get("proposal_id")), + candidate_id=_opt_str(detail.get("candidate_id")), + proposal_state=_opt_str(detail.get("state")), + grounding_source=_opt_str(detail.get("grounding_source")), ) ) return out diff --git a/workbench/schemas.py b/workbench/schemas.py index a6abfbe7..b48f5dcb 100644 --- a/workbench/schemas.py +++ b/workbench/schemas.py @@ -377,11 +377,33 @@ class DemoRunResult: scenarios: list[DemoScenarioRunResult] = field(default_factory=list) +# Canonical ADR-0172 learning-arc stages (cold attempt → engine enrichment → +# engine-authored proposal → operator ratifies → grounded). "other" is the +# explicit fallback for any scene id outside the closed arc. +ContemplationStageRole = Literal[ + "cold_attempt", + "engine_enrichment", + "engine_proposal", + "operator_ratifies", + "grounded", + "other", +] + + @dataclass(frozen=True, slots=True) class ContemplationScene: scene_id: str claim: str detail: dict[str, Any] = field(default_factory=dict) + # Typed projection of the contemplation *loop* — the role this scene plays + # and the connective ids that thread it to the proposal / candidate + # surfaces. ``proposal_id`` etc. are surfaced as evidence; cross-route + # navigation lights up only when the id resolves in the live log. + stage_role: ContemplationStageRole = "other" + proposal_id: str | None = None + candidate_id: str | None = None + proposal_state: str | None = None + grounding_source: str | None = None @dataclass(frozen=True, slots=True)