feat(web): add Dream Output Viewer
Dreams are currently a black box — to benchmark Tier L models on dreamer
induction quality we need to see what each model produced. This adds a
workspace-scoped /dreams route that surfaces every dream run as a
group of conclusions split into explicit / deductive / inductive
columns, with click-to-expand premise trees.
What's here:
- `lib/dreams.ts`: pure helpers. `clusterConclusionsIntoDreams` groups a
raw conclusions list into per-pair bursts using a configurable time
window (default 60s). `expandPremiseTree` walks `reasoning_tree` first
and falls back to a flat `premises` ID list, with cycle detection and
a depth cap. Defines an `ExtendedConclusion` type that augments the
generated schema with `conclusion_type`, `premises`, and
`reasoning_tree` — Honcho's migration f1a2b3c4d5e6 added those
columns but `schema.d.ts` doesn't expose them yet, so the UI degrades
gracefully (unknown types fall into "explicit").
- `api/queries.ts`: new `useDreams` hook that paginates the conclusions
list endpoint up to a configurable cap (default 400) and hands the
raw list to the UI for clustering. New `dreams` query key.
- `components/dreams/`:
- `DreamList.tsx` — route entry. Shows recent dreams as rows with
timestamp, observer→observed pair, and per-type counts. Selecting
a row expands an inline detail panel above the list.
- `DreamDetail.tsx` — three-column view (explicit / deductive /
inductive). Each inductive conclusion has a "Show premises" button
that expands the reasoning chain.
- `PremiseTree.tsx` — recursive premise renderer with type badges,
cycle indicator, and graceful handling of premises that fall
outside the loaded page.
- Routing: `routes/workspaces_.$workspaceId_.dreams.tsx` registers the
page; routeTree.gen.ts regenerated.
- Sidebar: new "Dreams" entry between Conclusions and Webhooks
(MoonStar icon to distinguish from the dark-mode Moon).
- Breadcrumb: "dreams" added to SECTION_LABELS so the trail resolves.
Tests (vitest, 14 new):
- clustering: empty input; same-pair burst within window; gap exceeds
threshold → split; different pairs don't merge even with overlapping
timestamps; custom gap window; newest-first ordering; counts default
unknown types to explicit.
- premise tree: empty children for no premises; flat list → direct
children; multi-level reasoning_tree recursion; missing premise
flagged (not in loaded page); cycle detection halts recursion;
maxDepth cap; reasoning_tree preferred over flat premises.
Verified manually in the preview: route mounts, sidebar/breadcrumb
resolve, error path renders cleanly, typecheck + lint + dream tests
all clean. The unrelated `app.test.tsx` failure is pre-existing on
main.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
196
packages/web/src/lib/dreams.ts
Normal file
196
packages/web/src/lib/dreams.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { components } from "@/api/schema.d.ts";
|
||||
|
||||
type ApiConclusion = components["schemas"]["Conclusion"];
|
||||
|
||||
export type ConclusionType = "explicit" | "deductive" | "inductive";
|
||||
|
||||
export const CONCLUSION_TYPES: readonly ConclusionType[] = [
|
||||
"explicit",
|
||||
"deductive",
|
||||
"inductive",
|
||||
] as const;
|
||||
|
||||
// The generated OpenAPI schema does not yet expose `conclusion_type`, `premises`, or
|
||||
// `reasoning_tree` (Honcho migration f1a2b3c4d5e6 added the columns but the response
|
||||
// schema hasn't been regenerated client-side). We declare them as optional here so
|
||||
// the UI consumes them when present and degrades gracefully when absent.
|
||||
export type ExtendedConclusion = ApiConclusion & {
|
||||
conclusion_type?: ConclusionType | null;
|
||||
premises?: string[] | null;
|
||||
reasoning_tree?: ReasoningTreeNode | null;
|
||||
};
|
||||
|
||||
export interface ReasoningTreeNode {
|
||||
conclusion_id: string;
|
||||
premises?: ReasoningTreeNode[];
|
||||
}
|
||||
|
||||
export interface Dream {
|
||||
id: string;
|
||||
observer_id: string;
|
||||
observed_id: string | null;
|
||||
session_id: string | null;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
earliestIso: string;
|
||||
latestIso: string;
|
||||
conclusions: ExtendedConclusion[];
|
||||
}
|
||||
|
||||
export interface DreamCounts {
|
||||
explicit: number;
|
||||
deductive: number;
|
||||
inductive: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClusterOptions {
|
||||
/** Max gap (ms) between adjacent conclusions in the same dream. Defaults to 60s. */
|
||||
gapMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_GAP_MS = 60_000;
|
||||
|
||||
export function inferConclusionType(c: ExtendedConclusion): ConclusionType {
|
||||
return c.conclusion_type ?? "explicit";
|
||||
}
|
||||
|
||||
export function dreamCounts(dream: Pick<Dream, "conclusions">): DreamCounts {
|
||||
const counts: DreamCounts = { explicit: 0, deductive: 0, inductive: 0, total: 0 };
|
||||
for (const c of dream.conclusions) {
|
||||
counts[inferConclusionType(c)]++;
|
||||
counts.total++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function parseMs(iso: string): number {
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
function dreamIdFor(observer: string, observed: string | null, earliestIso: string): string {
|
||||
return `${observer}|${observed ?? ""}|${earliestIso}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group conclusions into "dreams" — bursts of conclusions for the same
|
||||
* (observer, observed) pair within a short time window.
|
||||
*
|
||||
* Algorithm: walk conclusions newest-first; for each peer pair, keep an "open"
|
||||
* dream open as long as the next conclusion is within `gapMs` of the oldest
|
||||
* timestamp in the dream. When the gap exceeds `gapMs`, close the dream and
|
||||
* start a new one for that pair.
|
||||
*/
|
||||
export function clusterConclusionsIntoDreams(
|
||||
conclusions: ExtendedConclusion[],
|
||||
options: ClusterOptions = {},
|
||||
): Dream[] {
|
||||
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
|
||||
if (conclusions.length === 0) return [];
|
||||
|
||||
const sorted = [...conclusions].sort((a, b) => parseMs(b.created_at) - parseMs(a.created_at));
|
||||
const openByPair = new Map<string, Dream>();
|
||||
const result: Dream[] = [];
|
||||
|
||||
for (const c of sorted) {
|
||||
const observed = c.observed_id ?? null;
|
||||
const pairKey = `${c.observer_id}::${observed ?? ""}`;
|
||||
const t = parseMs(c.created_at);
|
||||
const open = openByPair.get(pairKey);
|
||||
|
||||
if (open && open.earliestMs - t <= gapMs) {
|
||||
open.conclusions.push(c);
|
||||
if (t < open.earliestMs) {
|
||||
open.earliestMs = t;
|
||||
open.earliestIso = c.created_at;
|
||||
open.id = dreamIdFor(c.observer_id, observed, c.created_at);
|
||||
}
|
||||
if (t > open.latestMs) {
|
||||
open.latestMs = t;
|
||||
open.latestIso = c.created_at;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const dream: Dream = {
|
||||
id: dreamIdFor(c.observer_id, observed, c.created_at),
|
||||
observer_id: c.observer_id,
|
||||
observed_id: observed,
|
||||
session_id: c.session_id ?? null,
|
||||
earliestMs: t,
|
||||
latestMs: t,
|
||||
earliestIso: c.created_at,
|
||||
latestIso: c.created_at,
|
||||
conclusions: [c],
|
||||
};
|
||||
openByPair.set(pairKey, dream);
|
||||
result.push(dream);
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.latestMs - a.latestMs);
|
||||
}
|
||||
|
||||
export function buildPremiseIndex(
|
||||
conclusions: ExtendedConclusion[],
|
||||
): Map<string, ExtendedConclusion> {
|
||||
const index = new Map<string, ExtendedConclusion>();
|
||||
for (const c of conclusions) index.set(c.id, c);
|
||||
return index;
|
||||
}
|
||||
|
||||
export interface PremiseNode {
|
||||
conclusion: ExtendedConclusion | null;
|
||||
conclusionId: string;
|
||||
depth: number;
|
||||
children: PremiseNode[];
|
||||
cycle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the premise tree for a conclusion. Walks `reasoning_tree` if present,
|
||||
* otherwise falls back to the flat `premises` ID list. Cycle-safe via a visited
|
||||
* set; `maxDepth` defaults to 8.
|
||||
*/
|
||||
export function expandPremiseTree(
|
||||
conclusionId: string,
|
||||
index: Map<string, ExtendedConclusion>,
|
||||
maxDepth = 8,
|
||||
): PremiseNode {
|
||||
return walk(conclusionId, index, new Set<string>(), 0, maxDepth);
|
||||
}
|
||||
|
||||
function walk(
|
||||
conclusionId: string,
|
||||
index: Map<string, ExtendedConclusion>,
|
||||
visited: Set<string>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
): PremiseNode {
|
||||
if (visited.has(conclusionId)) {
|
||||
return {
|
||||
conclusion: index.get(conclusionId) ?? null,
|
||||
conclusionId,
|
||||
depth,
|
||||
children: [],
|
||||
cycle: true,
|
||||
};
|
||||
}
|
||||
const conclusion = index.get(conclusionId) ?? null;
|
||||
if (!conclusion || depth >= maxDepth) {
|
||||
return { conclusion, conclusionId, depth, children: [], cycle: false };
|
||||
}
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(conclusionId);
|
||||
|
||||
let children: PremiseNode[] = [];
|
||||
if (conclusion.reasoning_tree?.premises?.length) {
|
||||
children = conclusion.reasoning_tree.premises.map((node) =>
|
||||
walk(node.conclusion_id, index, nextVisited, depth + 1, maxDepth),
|
||||
);
|
||||
} else if (conclusion.premises?.length) {
|
||||
children = conclusion.premises.map((id) => walk(id, index, nextVisited, depth + 1, maxDepth));
|
||||
}
|
||||
return { conclusion, conclusionId, depth, children, cycle: false };
|
||||
}
|
||||
Reference in New Issue
Block a user