Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 70 additions & 70 deletions packages/web/e2e/dreams.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,76 +21,76 @@ test.describe("Dreams route", () => {
await context.route(
(url) => url.pathname.endsWith("/conclusions/list"),
async (route) => {
const now = Date.now();
const iso = (offsetMs: number) => new Date(now - offsetMs).toISOString();
const items = [
// Dream A — burst
{
id: "ind-1",
content: "Alice prefers asynchronous communication",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(1000),
conclusion_type: "inductive",
reasoning_tree: {
conclusion_id: "ind-1",
premises: [{ conclusion_id: "ded-1" }],
const now = Date.now();
const iso = (offsetMs: number) => new Date(now - offsetMs).toISOString();
const items = [
// Dream A — burst
{
id: "ind-1",
content: "Alice prefers asynchronous communication",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(1000),
level: "inductive",
reasoning_tree: {
conclusion_id: "ind-1",
premises: [{ conclusion_id: "ded-1" }],
},
},
},
{
id: "ded-1",
content: "Alice mentioned email twice and declined two meetings",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(2000),
conclusion_type: "deductive",
reasoning_tree: {
conclusion_id: "ded-1",
premises: [{ conclusion_id: "exp-1" }, { conclusion_id: "exp-2" }],
{
id: "ded-1",
content: "Alice mentioned email twice and declined two meetings",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(2000),
level: "deductive",
reasoning_tree: {
conclusion_id: "ded-1",
premises: [{ conclusion_id: "exp-1" }, { conclusion_id: "exp-2" }],
},
},
},
{
id: "exp-1",
content: "Alice said 'just email me'",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(3000),
conclusion_type: "explicit",
},
{
id: "exp-2",
content: "Alice declined the Tuesday standup",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(4000),
conclusion_type: "explicit",
},
// Dream B — 30 minutes ago, different pair → clusters separately
{
id: "ded-2",
content: "Carol responds in the evenings",
observer_id: "carol",
observed_id: "dan",
session_id: "sess-2",
created_at: iso(30 * 60_000),
conclusion_type: "deductive",
},
];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
total: items.length,
pages: 1,
page: 1,
size: items.length,
}),
});
{
id: "exp-1",
content: "Alice said 'just email me'",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(3000),
level: "explicit",
},
{
id: "exp-2",
content: "Alice declined the Tuesday standup",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(4000),
level: "explicit",
},
// Dream B — 30 minutes ago, different pair → clusters separately
{
id: "ded-2",
content: "Carol responds in the evenings",
observer_id: "carol",
observed_id: "dan",
session_id: "sess-2",
created_at: iso(30 * 60_000),
level: "deductive",
},
];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
total: items.length,
pages: 1,
page: 1,
size: items.length,
}),
});
},
);
});
Expand All @@ -115,7 +115,7 @@ test.describe("Dreams route", () => {
await page.goto("/workspaces/ws-test/dreams");

// Two dreams: alice→bob burst, and the older carol→dan
const rows = page.locator('button[aria-pressed]');
const rows = page.locator("button[aria-pressed]");
await expect(rows).toHaveCount(2);

// Alice→bob row should show count chips
Expand All @@ -135,7 +135,7 @@ test.describe("Dreams route", () => {

test("expands premise tree for an inductive conclusion", async ({ page }) => {
await page.goto("/workspaces/ws-test/dreams");
await page.locator('button[aria-pressed]').first().click();
await page.locator("button[aria-pressed]").first().click();

const showPremises = page.getByRole("button", { name: /^Show premises$/i });
await expect(showPremises).toBeVisible();
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/components/dreams/DreamDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const COLUMNS: Array<{ type: ConclusionType; label: string; description: string
label: "Inductive",
description: "Generalized patterns inferred from deductives",
},
{
type: "contradiction",
label: "Contradiction",
description: "Conflicts found between existing observations",
},
];

interface DreamDetailProps {
Expand All @@ -51,6 +56,7 @@ export function DreamDetail({ dream, onClose }: DreamDetailProps) {
explicit: [],
deductive: [],
inductive: [],
contradiction: [],
};
for (const c of dream.conclusions) {
buckets[inferConclusionType(c)].push(c);
Expand Down
10 changes: 9 additions & 1 deletion packages/web/src/components/dreams/DreamList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ function DreamRow({ dream, active, onSelect }: DreamRowProps) {
<CountChip label="explicit" value={counts.explicit} kind="neutral" />
<CountChip label="deductive" value={counts.deductive} kind="accent" />
<CountChip label="inductive" value={counts.inductive} kind="warning" />
{counts.contradiction > 0 && (
<CountChip label="contradiction" value={counts.contradiction} kind="destructive" />
)}
<ChevronRight
className="w-4 h-4 ml-1 transition-transform"
style={{
Expand All @@ -177,7 +180,7 @@ function DreamRow({ dream, active, onSelect }: DreamRowProps) {
);
}

type ChipKind = "neutral" | "accent" | "warning";
type ChipKind = "neutral" | "accent" | "warning" | "destructive";

function CountChip({ label, value, kind }: { label: string; value: number; kind: ChipKind }) {
const palette: Record<ChipKind, { bg: string; fg: string; border: string }> = {
Expand All @@ -188,6 +191,11 @@ function CountChip({ label, value, kind }: { label: string; value: number; kind:
},
accent: { bg: COLOR.accentSubtle, fg: COLOR.accentText, border: COLOR.accentBorder },
warning: { bg: "rgba(245,158,11,0.10)", fg: COLOR.warning, border: COLOR.warningBorder },
destructive: {
bg: COLOR.destructiveDim,
fg: COLOR.destructive,
border: COLOR.destructiveBorder,
},
};
const cfg = palette[kind];
const dim = value === 0;
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/components/dreams/PremiseTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ const TYPE_BADGE: Record<
fg: COLOR.warning,
border: COLOR.warningBorder,
},
contradiction: {
label: "contradiction",
bg: COLOR.destructiveDim,
fg: COLOR.destructive,
border: COLOR.destructiveBorder,
},
};

export function ConclusionTypeBadge({ type }: { type: ConclusionType }) {
Expand Down
26 changes: 18 additions & 8 deletions packages/web/src/lib/dreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ import type { components } from "@/api/schema.d.ts";

type ApiConclusion = components["schemas"]["Conclusion"];

export type ConclusionType = "explicit" | "deductive" | "inductive";
export type ConclusionType = "explicit" | "deductive" | "inductive" | "contradiction";

export const CONCLUSION_TYPES: readonly ConclusionType[] = [
"explicit",
"deductive",
"inductive",
"contradiction",
] 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.
// The generated OpenAPI schema (Honcho 3.0.5) does not expose `level`, `premises`, or
// `reasoning_tree`, but live Honcho 3.0.11 returns `level` on every conclusion.
// Declared optional here so the UI consumes them when present and degrades gracefully
// when absent. `premises`/`reasoning_tree` are still unserved — the premise tree stays
// empty until Honcho ships them.
export type ExtendedConclusion = ApiConclusion & {
conclusion_type?: ConclusionType | null;
/** Widened to `string`: Honcho may add levels this client doesn't know yet. */
level?: string | null;
premises?: string[] | null;
reasoning_tree?: ReasoningTreeNode | null;
};
Expand All @@ -41,6 +44,7 @@ export interface DreamCounts {
explicit: number;
deductive: number;
inductive: number;
contradiction: number;
total: number;
}

Expand All @@ -52,11 +56,17 @@ export interface ClusterOptions {
const DEFAULT_GAP_MS = 60_000;

export function inferConclusionType(c: ExtendedConclusion): ConclusionType {
return c.conclusion_type ?? "explicit";
return CONCLUSION_TYPES.find((t) => t === c.level) ?? "explicit";
}

export function dreamCounts(dream: Pick<Dream, "conclusions">): DreamCounts {
const counts: DreamCounts = { explicit: 0, deductive: 0, inductive: 0, total: 0 };
const counts: DreamCounts = {
explicit: 0,
deductive: 0,
inductive: 0,
contradiction: 0,
total: 0,
};
for (const c of dream.conclusions) {
counts[inferConclusionType(c)]++;
counts.total++;
Expand Down
31 changes: 19 additions & 12 deletions packages/web/src/test/dreams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,28 @@ describe("clusterConclusionsIntoDreams", () => {
expect(dreams[0].latestMs).toBeGreaterThan(dreams[1].latestMs);
});

it("computes counts by inferred conclusion_type, defaulting unknown to explicit", () => {
it("computes counts by inferred level, defaulting unknown to explicit", () => {
const conclusions = [
mkConclusion("c1", iso(0)),
mkConclusion("c2", iso(2), { conclusion_type: "deductive" }),
mkConclusion("c3", iso(4), { conclusion_type: "deductive" }),
mkConclusion("c4", iso(6), { conclusion_type: "inductive" }),
mkConclusion("c2", iso(2), { level: "deductive" }),
mkConclusion("c3", iso(4), { level: "deductive" }),
mkConclusion("c4", iso(6), { level: "inductive" }),
];
const [dream] = clusterConclusionsIntoDreams(conclusions);
expect(dreamCounts(dream)).toEqual({
explicit: 1, // c1 has no type → defaults to explicit
explicit: 1, // c1 has no level → defaults to explicit
deductive: 2,
inductive: 1,
contradiction: 0,
total: 4,
});
});

it("counts a level this client does not know as explicit rather than dropping it", () => {
const conclusions = [mkConclusion("c1", iso(0), { level: "abductive" })];
const [dream] = clusterConclusionsIntoDreams(conclusions);
expect(dreamCounts(dream).explicit).toBe(1);
});
});

// Premise tree ────────────────────────────────────────────────────────────────
Expand All @@ -144,10 +151,10 @@ describe("expandPremiseTree", () => {
});

it("expands a flat premises list to direct children", () => {
const p1 = mkConclusion("p1", iso(0), { conclusion_type: "explicit" });
const p2 = mkConclusion("p2", iso(1), { conclusion_type: "explicit" });
const p1 = mkConclusion("p1", iso(0), { level: "explicit" });
const p2 = mkConclusion("p2", iso(1), { level: "explicit" });
const top = mkConclusion("top", iso(5), {
conclusion_type: "inductive",
level: "inductive",
premises: ["p1", "p2"],
});
const index = buildPremiseIndex([p1, p2, top]);
Expand All @@ -158,17 +165,17 @@ describe("expandPremiseTree", () => {
});

it("walks a multi-level reasoning_tree recursively", () => {
const e1 = mkConclusion("e1", iso(0), { conclusion_type: "explicit" });
const e2 = mkConclusion("e2", iso(1), { conclusion_type: "explicit" });
const e1 = mkConclusion("e1", iso(0), { level: "explicit" });
const e2 = mkConclusion("e2", iso(1), { level: "explicit" });
const d1 = mkConclusion("d1", iso(2), {
conclusion_type: "deductive",
level: "deductive",
reasoning_tree: {
conclusion_id: "d1",
premises: [{ conclusion_id: "e1" }, { conclusion_id: "e2" }],
},
});
const ind = mkConclusion("ind", iso(3), {
conclusion_type: "inductive",
level: "inductive",
reasoning_tree: {
conclusion_id: "ind",
premises: [{ conclusion_id: "d1" }],
Expand Down