Merge pull request #733 from AssetOverflow/feat/wb-c2b-contemplation-process

Wave M C2-b: contemplation as a legible staged process
This commit is contained in:
Shay 2026-06-13 17:07:51 -07:00 committed by GitHub
commit dedaa0b7dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 306 additions and 29 deletions

View file

@ -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"

View 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"

View file

@ -103,7 +103,12 @@
"ContemplationScene": [
"scene_id",
"claim",
"detail"
"detail",
"stage_role",
"proposal_id",
"candidate_id",
"proposal_state",
"grounding_source"
],
"DemoDagEdge": [
"from_node",

View file

@ -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();

View file

@ -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)]">
{scene.scene_id}
<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">
<StableJsonViewer source={JSON.stringify(scene.detail, null, 2)} />
</div>
<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,24 +216,19 @@ 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)} />
</div>
<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) => (
<ProcessStageCard key={`${scene.scene_id}-${index}`} scene={scene} index={index} />
))}
</ol>
</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} />
))}
</ol>
</div>
</Panel>
);

View file

@ -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 {

View file

@ -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

View file

@ -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)