feat(workbench): Wave M C2-b — contemplation as a legible staged process
C2-a surfaced contemplation runs but rendered each scene as a flat JSON dump —
outputs, not process. C2-b makes the loop legible: every scene is now a typed
stage in the canonical ADR-0172 arc.
Reader-first (no theater):
- schemas.py: ContemplationScene gains a typed loop projection — stage_role
(cold_attempt | engine_enrichment | engine_proposal | operator_ratifies |
grounded | other) plus the connective ids (proposal_id, candidate_id,
proposal_state, grounding_source). New ContemplationStageRole Literal.
- readers.py: _contemplation_scenes derives stage_role from the scene id
(closed set, "other" fallback) and pulls the connective ids out of the raw
detail. schema-snapshot.json regenerated.
UI:
- ContemplationRoute: the run detail now renders the arc as a staged process —
"attempt → enrich → propose → ratify → grounded" with named stage roles,
cold→grounded bookends (surfaces pulled out of before/after), the connective
ids as evidence, and the raw detail tucked into a collapsible (not lost).
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 to reader-verified linking once
real contemplation proposals reach the log.
Validation: 128 workbench Python tests + new reader-projection tests (canonical
arc → roles, unknown → "other", id extraction, detail preserved); 466/466
frontend incl. schemaDrift + the staged-process / no-dead-link test; pnpm build
clean; git diff --check clean. No serving-path imports.
This commit is contained in:
parent
47990cdd4a
commit
f68b395620
8 changed files with 306 additions and 29 deletions
|
|
@ -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"
|
||||
|
|
|
|||
76
tests/test_workbench_contemplation_reader.py
Normal file
76
tests/test_workbench_contemplation_reader.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -103,7 +103,12 @@
|
|||
"ContemplationScene": [
|
||||
"scene_id",
|
||||
"claim",
|
||||
"detail"
|
||||
"detail",
|
||||
"stage_role",
|
||||
"proposal_id",
|
||||
"candidate_id",
|
||||
"proposal_state",
|
||||
"grounding_source"
|
||||
],
|
||||
"DemoDagEdge": [
|
||||
"from_node",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ContemplationStageRole, string> = {
|
||||
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 (
|
||||
<dl className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="contents">
|
||||
<dt className="text-[var(--color-text-secondary)]">{row.label}</dt>
|
||||
<dd className="m-0 truncate font-mono text-[var(--color-text-primary)]" title={row.value}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessStageCard({ scene, index }: { scene: ContemplationScene; index: number }) {
|
||||
return (
|
||||
<li className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-3">
|
||||
<div className="flex justify-center">
|
||||
|
|
@ -83,20 +126,66 @@ function SceneCard({ scene, index }: { scene: ContemplationScene; index: number
|
|||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 border-l border-[var(--color-border-subtle)] pl-3">
|
||||
<div className="font-mono text-xs text-[var(--color-text-muted)]">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{STAGE_LABEL[scene.stage_role]}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[var(--color-text-muted)]">
|
||||
{scene.scene_id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="m-0 mt-1 text-sm text-[var(--color-text-primary)]">
|
||||
<p className="m-0 mt-1 text-sm text-[var(--color-text-primary)] [text-wrap:balance]">
|
||||
{scene.claim}
|
||||
</p>
|
||||
<div className="mt-2 max-h-80 overflow-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2">
|
||||
<StageEvidence scene={scene} />
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--color-text-secondary)]">
|
||||
raw detail
|
||||
</summary>
|
||||
<div className="mt-1 max-h-80 overflow-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2">
|
||||
<StableJsonViewer source={JSON.stringify(scene.detail, null, 2)} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Bookend({
|
||||
label,
|
||||
payload,
|
||||
}: {
|
||||
label: string;
|
||||
payload: Record<string, unknown> | null | undefined;
|
||||
}) {
|
||||
const surface = typeof payload?.surface === "string" ? payload.surface : null;
|
||||
const grounding =
|
||||
typeof payload?.grounding_source === "string" ? payload.grounding_source : null;
|
||||
return (
|
||||
<div className="min-w-0 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2">
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-[var(--color-text-secondary)]">{label}</span>
|
||||
{grounding ? (
|
||||
<span className="font-mono text-xs text-[var(--color-text-muted)]">{grounding}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{surface ? (
|
||||
<p className="m-0 text-sm text-[var(--color-text-primary)] [text-wrap:balance]">{surface}</p>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">not recorded</span>
|
||||
)}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--color-text-secondary)]">
|
||||
raw
|
||||
</summary>
|
||||
<div className="mt-1 overflow-auto">
|
||||
<StableJsonViewer source={JSON.stringify(payload ?? {}, null, 2)} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({ detail }: { detail: ContemplationRunDetail }) {
|
||||
const digest = digestPayload(detail.source_digest);
|
||||
return (
|
||||
|
|
@ -127,25 +216,20 @@ function DetailPanel({ detail }: { detail: ContemplationRunDetail }) {
|
|||
]}
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2">
|
||||
<div className="mb-1 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
before
|
||||
</div>
|
||||
<StableJsonViewer source={JSON.stringify(detail.before ?? {}, null, 2)} />
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2">
|
||||
<div className="mb-1 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
after
|
||||
</div>
|
||||
<StableJsonViewer source={JSON.stringify(detail.after ?? {}, null, 2)} />
|
||||
<Bookend label="Cold session (before)" payload={detail.before} />
|
||||
<Bookend label="Grounded session (after)" payload={detail.after} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
|
||||
Learning loop · attempt → enrich → propose → ratify → grounded
|
||||
</div>
|
||||
<ol className="m-0 grid list-none gap-4 p-0">
|
||||
{detail.scenes.map((scene, index) => (
|
||||
<SceneCard key={`${scene.scene_id}-${index}`} scene={scene} index={index} />
|
||||
<ProcessStageCard key={`${scene.scene_id}-${index}`} scene={scene} index={index} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
stage_role: ContemplationStageRole;
|
||||
proposal_id: string | null;
|
||||
candidate_id: string | null;
|
||||
proposal_state: string | null;
|
||||
grounding_source: string | null;
|
||||
}
|
||||
|
||||
export interface ContemplationRunSummary {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue