241 lines
8.7 KiB
TypeScript
241 lines
8.7 KiB
TypeScript
|
|
import { describe, expect, it } from "vitest";
|
||
|
|
import {
|
||
|
|
buildPremiseIndex,
|
||
|
|
clusterConclusionsIntoDreams,
|
||
|
|
dreamCounts,
|
||
|
|
type ExtendedConclusion,
|
||
|
|
expandPremiseTree,
|
||
|
|
} from "@/lib/dreams";
|
||
|
|
|
||
|
|
// Helpers ─────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function mkConclusion(
|
||
|
|
id: string,
|
||
|
|
createdAt: string,
|
||
|
|
overrides: Partial<ExtendedConclusion> = {},
|
||
|
|
): ExtendedConclusion {
|
||
|
|
return {
|
||
|
|
id,
|
||
|
|
content: `conclusion ${id}`,
|
||
|
|
observer_id: "observer-a",
|
||
|
|
observed_id: "observed-b",
|
||
|
|
session_id: null,
|
||
|
|
created_at: createdAt,
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function iso(secondsFromZero: number): string {
|
||
|
|
const base = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00Z
|
||
|
|
return new Date(base + secondsFromZero * 1000).toISOString();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clustering ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe("clusterConclusionsIntoDreams", () => {
|
||
|
|
it("returns no dreams for empty input", () => {
|
||
|
|
expect(clusterConclusionsIntoDreams([])).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("groups conclusions within the default 60s window into a single dream", () => {
|
||
|
|
const burst = [
|
||
|
|
mkConclusion("c1", iso(0)),
|
||
|
|
mkConclusion("c2", iso(5)),
|
||
|
|
mkConclusion("c3", iso(15)),
|
||
|
|
mkConclusion("c4", iso(55)),
|
||
|
|
];
|
||
|
|
|
||
|
|
const dreams = clusterConclusionsIntoDreams(burst);
|
||
|
|
|
||
|
|
expect(dreams).toHaveLength(1);
|
||
|
|
expect(dreams[0].conclusions).toHaveLength(4);
|
||
|
|
expect(dreams[0].observer_id).toBe("observer-a");
|
||
|
|
expect(dreams[0].observed_id).toBe("observed-b");
|
||
|
|
expect(dreams[0].earliestIso).toBe(iso(0));
|
||
|
|
expect(dreams[0].latestIso).toBe(iso(55));
|
||
|
|
});
|
||
|
|
|
||
|
|
it("starts a new dream when the gap exceeds the threshold", () => {
|
||
|
|
const conclusions = [
|
||
|
|
mkConclusion("a", iso(0)),
|
||
|
|
mkConclusion("b", iso(20)),
|
||
|
|
// 5 minutes later → new dream
|
||
|
|
mkConclusion("c", iso(20 + 5 * 60)),
|
||
|
|
mkConclusion("d", iso(20 + 5 * 60 + 10)),
|
||
|
|
];
|
||
|
|
|
||
|
|
const dreams = clusterConclusionsIntoDreams(conclusions);
|
||
|
|
|
||
|
|
expect(dreams).toHaveLength(2);
|
||
|
|
// Sorted by latest descending — the newer dream comes first.
|
||
|
|
expect(dreams[0].conclusions.map((c) => c.id).sort()).toEqual(["c", "d"]);
|
||
|
|
expect(dreams[1].conclusions.map((c) => c.id).sort()).toEqual(["a", "b"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("separates dreams by (observer, observed) pair even when timestamps overlap", () => {
|
||
|
|
const conclusions = [
|
||
|
|
mkConclusion("a1", iso(0), { observer_id: "alice", observed_id: "bob" }),
|
||
|
|
mkConclusion("a2", iso(5), { observer_id: "alice", observed_id: "bob" }),
|
||
|
|
mkConclusion("c1", iso(2), { observer_id: "carol", observed_id: "dan" }),
|
||
|
|
mkConclusion("c2", iso(7), { observer_id: "carol", observed_id: "dan" }),
|
||
|
|
];
|
||
|
|
|
||
|
|
const dreams = clusterConclusionsIntoDreams(conclusions);
|
||
|
|
|
||
|
|
expect(dreams).toHaveLength(2);
|
||
|
|
const aliceDream = dreams.find((d) => d.observer_id === "alice");
|
||
|
|
const carolDream = dreams.find((d) => d.observer_id === "carol");
|
||
|
|
expect(aliceDream?.conclusions).toHaveLength(2);
|
||
|
|
expect(carolDream?.conclusions).toHaveLength(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("respects a custom gap window", () => {
|
||
|
|
const conclusions = [
|
||
|
|
mkConclusion("a", iso(0)),
|
||
|
|
mkConclusion("b", iso(120)), // 2 minutes apart
|
||
|
|
];
|
||
|
|
|
||
|
|
const tight = clusterConclusionsIntoDreams(conclusions, { gapMs: 60_000 });
|
||
|
|
expect(tight).toHaveLength(2);
|
||
|
|
|
||
|
|
const loose = clusterConclusionsIntoDreams(conclusions, { gapMs: 5 * 60_000 });
|
||
|
|
expect(loose).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("sorts dreams newest-first by latest timestamp", () => {
|
||
|
|
const olderTs = iso(0);
|
||
|
|
const newerTs = iso(10 * 60); // 10 minutes later
|
||
|
|
const dreams = clusterConclusionsIntoDreams([
|
||
|
|
mkConclusion("old", olderTs),
|
||
|
|
mkConclusion("new", newerTs),
|
||
|
|
]);
|
||
|
|
expect(dreams).toHaveLength(2);
|
||
|
|
expect(dreams[0].latestIso).toBe(newerTs);
|
||
|
|
expect(dreams[1].latestIso).toBe(olderTs);
|
||
|
|
expect(dreams[0].latestMs).toBeGreaterThan(dreams[1].latestMs);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("computes counts by inferred conclusion_type, 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" }),
|
||
|
|
];
|
||
|
|
const [dream] = clusterConclusionsIntoDreams(conclusions);
|
||
|
|
expect(dreamCounts(dream)).toEqual({
|
||
|
|
explicit: 1, // c1 has no type → defaults to explicit
|
||
|
|
deductive: 2,
|
||
|
|
inductive: 1,
|
||
|
|
total: 4,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Premise tree ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
describe("expandPremiseTree", () => {
|
||
|
|
it("returns an empty tree when the conclusion has no premises", () => {
|
||
|
|
const c = mkConclusion("solo", iso(0));
|
||
|
|
const index = buildPremiseIndex([c]);
|
||
|
|
const tree = expandPremiseTree("solo", index);
|
||
|
|
expect(tree.children).toEqual([]);
|
||
|
|
expect(tree.conclusion?.id).toBe("solo");
|
||
|
|
});
|
||
|
|
|
||
|
|
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 top = mkConclusion("top", iso(5), {
|
||
|
|
conclusion_type: "inductive",
|
||
|
|
premises: ["p1", "p2"],
|
||
|
|
});
|
||
|
|
const index = buildPremiseIndex([p1, p2, top]);
|
||
|
|
|
||
|
|
const tree = expandPremiseTree("top", index);
|
||
|
|
expect(tree.children.map((n) => n.conclusionId)).toEqual(["p1", "p2"]);
|
||
|
|
expect(tree.children.every((n) => n.conclusion !== null)).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
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 d1 = mkConclusion("d1", iso(2), {
|
||
|
|
conclusion_type: "deductive",
|
||
|
|
reasoning_tree: {
|
||
|
|
conclusion_id: "d1",
|
||
|
|
premises: [{ conclusion_id: "e1" }, { conclusion_id: "e2" }],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const ind = mkConclusion("ind", iso(3), {
|
||
|
|
conclusion_type: "inductive",
|
||
|
|
reasoning_tree: {
|
||
|
|
conclusion_id: "ind",
|
||
|
|
premises: [{ conclusion_id: "d1" }],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const index = buildPremiseIndex([e1, e2, d1, ind]);
|
||
|
|
|
||
|
|
const tree = expandPremiseTree("ind", index);
|
||
|
|
expect(tree.children).toHaveLength(1);
|
||
|
|
const deductive = tree.children[0];
|
||
|
|
expect(deductive.conclusionId).toBe("d1");
|
||
|
|
expect(deductive.children.map((n) => n.conclusionId).sort()).toEqual(["e1", "e2"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("flags missing premises (e.g., outside the loaded page) without throwing", () => {
|
||
|
|
const top = mkConclusion("top", iso(5), { premises: ["missing"] });
|
||
|
|
const index = buildPremiseIndex([top]);
|
||
|
|
const tree = expandPremiseTree("top", index);
|
||
|
|
expect(tree.children).toHaveLength(1);
|
||
|
|
expect(tree.children[0].conclusion).toBeNull();
|
||
|
|
expect(tree.children[0].conclusionId).toBe("missing");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("detects cycles and stops recursion", () => {
|
||
|
|
const a = mkConclusion("a", iso(0), { premises: ["b"] });
|
||
|
|
const b = mkConclusion("b", iso(1), { premises: ["a"] });
|
||
|
|
const index = buildPremiseIndex([a, b]);
|
||
|
|
const tree = expandPremiseTree("a", index);
|
||
|
|
|
||
|
|
// a → b → a(cycle)
|
||
|
|
expect(tree.children).toHaveLength(1);
|
||
|
|
const bNode = tree.children[0];
|
||
|
|
expect(bNode.conclusionId).toBe("b");
|
||
|
|
expect(bNode.children).toHaveLength(1);
|
||
|
|
const cycleNode = bNode.children[0];
|
||
|
|
expect(cycleNode.conclusionId).toBe("a");
|
||
|
|
expect(cycleNode.cycle).toBe(true);
|
||
|
|
expect(cycleNode.children).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("stops recursion at maxDepth", () => {
|
||
|
|
// a → b → c → d, expand with maxDepth=2: tree depth should not exceed 2
|
||
|
|
const d = mkConclusion("d", iso(0));
|
||
|
|
const c = mkConclusion("c", iso(1), { premises: ["d"] });
|
||
|
|
const b = mkConclusion("b", iso(2), { premises: ["c"] });
|
||
|
|
const a = mkConclusion("a", iso(3), { premises: ["b"] });
|
||
|
|
const index = buildPremiseIndex([a, b, c, d]);
|
||
|
|
|
||
|
|
const tree = expandPremiseTree("a", index, 2);
|
||
|
|
// depth 0: a, depth 1: b, depth 2: c (no further children)
|
||
|
|
expect(tree.conclusionId).toBe("a");
|
||
|
|
expect(tree.children[0].conclusionId).toBe("b");
|
||
|
|
expect(tree.children[0].children[0].conclusionId).toBe("c");
|
||
|
|
expect(tree.children[0].children[0].children).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("prefers reasoning_tree over flat premises when both are present", () => {
|
||
|
|
const e1 = mkConclusion("e1", iso(0));
|
||
|
|
const e2 = mkConclusion("e2", iso(0));
|
||
|
|
const top = mkConclusion("top", iso(5), {
|
||
|
|
premises: ["e1"], // flat says one
|
||
|
|
reasoning_tree: { conclusion_id: "top", premises: [{ conclusion_id: "e2" }] }, // tree says another
|
||
|
|
});
|
||
|
|
const index = buildPremiseIndex([e1, e2, top]);
|
||
|
|
|
||
|
|
const tree = expandPremiseTree("top", index);
|
||
|
|
expect(tree.children.map((n) => n.conclusionId)).toEqual(["e2"]);
|
||
|
|
});
|
||
|
|
});
|