Skip to content

Commit 089612b

Browse files
committed
feat(tui): add recursive session grouping tree
1 parent 0c1dfa9 commit 089612b

2 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
export type EntryNode<Entry> = {
2+
type: "entry"
3+
entry: Entry
4+
size: 1
5+
}
6+
7+
export type GroupNode<Kind extends PropertyKey, Entry> = {
8+
type: "group"
9+
kind: Kind
10+
children: TimelineNode<Kind, Entry>[]
11+
size: number
12+
}
13+
14+
export type TimelineNode<Kind extends PropertyKey, Entry> = EntryNode<Entry> | GroupNode<Kind, Entry>
15+
16+
export type GroupPath<Kind extends PropertyKey, Entry> = (entry: Entry) => readonly Kind[]
17+
18+
export function groupEntries<Kind extends PropertyKey, Entry>(entries: readonly Entry[], path: GroupPath<Kind, Entry>) {
19+
return entries.reduce<TimelineNode<Kind, Entry>[]>((nodes, entry) => {
20+
appendEntry(nodes, entry, path(entry))
21+
return nodes
22+
}, [])
23+
}
24+
25+
/** Merge ordered chunks at their shared seam without mutating either input. */
26+
export function mergeGroups<Kind extends PropertyKey, Entry>(
27+
left: readonly TimelineNode<Kind, Entry>[],
28+
right: readonly TimelineNode<Kind, Entry>[],
29+
): TimelineNode<Kind, Entry>[] {
30+
if (left.length === 0) return [...right]
31+
if (right.length === 0) return [...left]
32+
const before = left.at(-1)!
33+
const after = right[0]
34+
if (before.type !== "group" || after.type !== "group" || before.kind !== after.kind) {
35+
return [...left, ...right]
36+
}
37+
return [
38+
...left.slice(0, -1),
39+
{
40+
type: "group",
41+
kind: before.kind,
42+
children: mergeGroups(before.children, after.children),
43+
size: before.size + after.size,
44+
},
45+
...right.slice(1),
46+
]
47+
}
48+
49+
/** Split at a depth-first leaf offset, preserving the surrounding group paths. */
50+
export function splitGroups<Kind extends PropertyKey, Entry>(
51+
nodes: readonly TimelineNode<Kind, Entry>[],
52+
count: number,
53+
): [TimelineNode<Kind, Entry>[], TimelineNode<Kind, Entry>[]] {
54+
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split must be a non-negative integer")
55+
if (count === 0) return [[], [...nodes]]
56+
const total = leafCount(nodes)
57+
if (count > total) throw new RangeError("Group split exceeds leaf count")
58+
if (count === total) return [[...nodes], []]
59+
60+
let offset = 0
61+
for (const [index, node] of nodes.entries()) {
62+
const end = offset + node.size
63+
if (count === end) return [[...nodes.slice(0, index + 1)], [...nodes.slice(index + 1)]]
64+
if (count < end) {
65+
if (node.type !== "group") throw new RangeError("Cannot split inside a leaf")
66+
const size = count - offset
67+
const [left, right] = splitGroups(node.children, size)
68+
return [
69+
[...nodes.slice(0, index), { ...node, children: left, size }],
70+
[{ ...node, children: right, size: node.size - size }, ...nodes.slice(index + 1)],
71+
]
72+
}
73+
offset = end
74+
}
75+
throw new RangeError("Group split exceeds leaf count")
76+
}
77+
78+
export function flattenGroups<Kind extends PropertyKey, Entry>(nodes: readonly TimelineNode<Kind, Entry>[]): Entry[] {
79+
return nodes.flatMap((node) => (node.type === "entry" ? [node.entry] : flattenGroups(node.children)))
80+
}
81+
82+
export function leafCount<Kind extends PropertyKey, Entry>(nodes: readonly TimelineNode<Kind, Entry>[]) {
83+
return nodes.reduce((total, node) => total + node.size, 0)
84+
}
85+
86+
function appendEntry<Kind extends PropertyKey, Entry>(
87+
nodes: TimelineNode<Kind, Entry>[],
88+
entry: Entry,
89+
path: readonly Kind[],
90+
depth = 0,
91+
) {
92+
const kind = path[depth]
93+
if (kind === undefined) {
94+
nodes.push({ type: "entry", entry, size: 1 })
95+
return
96+
}
97+
const previous = nodes.at(-1)
98+
const group: GroupNode<Kind, Entry> =
99+
previous?.type === "group" && previous.kind === kind ? previous : { type: "group", kind, children: [], size: 0 }
100+
if (group !== previous) nodes.push(group)
101+
group.size++
102+
appendEntry(group.children, entry, path, depth + 1)
103+
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
flattenGroups,
4+
groupEntries,
5+
leafCount,
6+
mergeGroups,
7+
splitGroups,
8+
type TimelineNode,
9+
} from "../../../src/routes/session/grouping/tree"
10+
11+
type Kind = "activity" | "exploration" | "reasoning" | "instructions"
12+
type Entry = { id: string; path: Kind[] }
13+
14+
const entry = (id: string, ...path: Kind[]): Entry => ({ id, path })
15+
const group = (entries: readonly Entry[]) => groupEntries(entries, (item) => item.path)
16+
const ids = (nodes: readonly TimelineNode<Kind, Entry>[]) => flattenGroups(nodes).map((item) => item.id)
17+
18+
describe("session grouping tree", () => {
19+
test("leaves standalone entries unwrapped", () => {
20+
const entries = [entry("a"), entry("b")]
21+
expect(group(entries)).toEqual([
22+
{ type: "entry", entry: entries[0], size: 1 },
23+
{ type: "entry", entry: entries[1], size: 1 },
24+
])
25+
})
26+
27+
test("groups consecutive entries with the same path", () => {
28+
expect(group([entry("a", "exploration"), entry("b", "exploration")])).toMatchObject([
29+
{
30+
type: "group",
31+
kind: "exploration",
32+
size: 2,
33+
children: [
34+
{ type: "entry", entry: { id: "a" }, size: 1 },
35+
{ type: "entry", entry: { id: "b" }, size: 1 },
36+
],
37+
},
38+
])
39+
})
40+
41+
test("creates nested groups from configured paths", () => {
42+
const nodes = group([
43+
entry("read", "activity", "exploration"),
44+
entry("thought", "activity", "reasoning"),
45+
entry("notice", "activity", "instructions"),
46+
entry("shell", "activity"),
47+
])
48+
expect(nodes).toMatchObject([
49+
{
50+
type: "group",
51+
kind: "activity",
52+
size: 4,
53+
children: [
54+
{ type: "group", kind: "exploration", size: 1 },
55+
{ type: "group", kind: "reasoning", size: 1 },
56+
{ type: "group", kind: "instructions", size: 1 },
57+
{ type: "entry", entry: { id: "shell" }, size: 1 },
58+
],
59+
},
60+
])
61+
expect(ids(nodes)).toEqual(["read", "thought", "notice", "shell"])
62+
})
63+
64+
test("standalone entries delimit compatible groups", () => {
65+
const nodes = group([entry("a", "exploration"), entry("text"), entry("b", "exploration")])
66+
expect(nodes.map((node) => (node.type === "group" ? node.kind : node.entry.id))).toEqual([
67+
"exploration",
68+
"text",
69+
"exploration",
70+
])
71+
})
72+
73+
test("direct children delimit nested subgroups without ending their outer group", () => {
74+
const nodes = group([
75+
entry("read-a", "activity", "exploration"),
76+
entry("shell", "activity"),
77+
entry("read-b", "activity", "exploration"),
78+
])
79+
expect(nodes).toHaveLength(1)
80+
expect(nodes[0]).toMatchObject({
81+
type: "group",
82+
kind: "activity",
83+
size: 3,
84+
children: [
85+
{ type: "group", kind: "exploration", size: 1 },
86+
{ type: "entry", entry: { id: "shell" } },
87+
{ type: "group", kind: "exploration", size: 1 },
88+
],
89+
})
90+
})
91+
92+
test("counts depth-first leaves and never counts group wrappers", () => {
93+
const nodes = group([
94+
entry("a", "activity", "exploration"),
95+
entry("b", "activity", "exploration"),
96+
entry("c", "activity", "reasoning"),
97+
entry("d"),
98+
])
99+
expect(leafCount(nodes)).toBe(4)
100+
expect(nodes[0]?.size).toBe(3)
101+
if (nodes[0]?.type !== "group") throw new Error("Expected activity group")
102+
expect(nodes[0].children[0]?.size).toBe(2)
103+
})
104+
105+
test("merges both levels at a recursive seam", () => {
106+
const left = group([entry("shell", "activity"), entry("read-a", "activity", "exploration")])
107+
const right = group([entry("read-b", "activity", "exploration"), entry("thought", "activity", "reasoning")])
108+
const merged = mergeGroups(left, right)
109+
expect(merged).toMatchObject([
110+
{
111+
type: "group",
112+
kind: "activity",
113+
size: 4,
114+
children: [
115+
{ type: "entry", entry: { id: "shell" } },
116+
{ type: "group", kind: "exploration", size: 2 },
117+
{ type: "group", kind: "reasoning", size: 1 },
118+
],
119+
},
120+
])
121+
expect(ids(merged)).toEqual(["shell", "read-a", "read-b", "thought"])
122+
})
123+
124+
test("does not merge incompatible outer or inner seams", () => {
125+
expect(mergeGroups(group([entry("a", "exploration")]), group([entry("b", "reasoning")]))).toHaveLength(2)
126+
const merged = mergeGroups(
127+
group([entry("a", "activity", "exploration")]),
128+
group([entry("b", "activity", "reasoning")]),
129+
)
130+
expect(merged).toHaveLength(1)
131+
if (merged[0]?.type !== "group") throw new Error("Expected activity group")
132+
expect(merged[0].children).toHaveLength(2)
133+
})
134+
135+
test("retains identities outside the recursive seam", () => {
136+
const left = group([entry("before"), entry("shell", "activity"), entry("read-a", "activity", "exploration")])
137+
const right = group([
138+
entry("read-b", "activity", "exploration"),
139+
entry("thought", "activity", "reasoning"),
140+
entry("after"),
141+
])
142+
if (left[1]?.type !== "group" || right[0]?.type !== "group") throw new Error("Expected activity groups")
143+
const leftShell = left[1].children[0]
144+
const rightReasoning = right[0].children[1]
145+
const merged = mergeGroups(left, right)
146+
expect(merged[0]).toBe(left[0])
147+
expect(merged[2]).toBe(right[1])
148+
if (merged[1]?.type !== "group") throw new Error("Expected merged activity group")
149+
expect(merged[1].children[0]).toBe(leftShell)
150+
expect(merged[1].children[2]).toBe(rightReasoning)
151+
})
152+
153+
test("does not mutate chunks while recursively merging", () => {
154+
const left = group([entry("a", "activity", "exploration")])
155+
const right = group([entry("b", "activity", "exploration")])
156+
const saved = structuredClone([left, right])
157+
mergeGroups(left, right)
158+
expect([left, right]).toEqual(saved)
159+
})
160+
161+
test("merges empty chunks without sharing their root arrays", () => {
162+
const nodes = group([entry("a", "exploration")])
163+
expect(mergeGroups([], nodes)).toEqual(nodes)
164+
expect(mergeGroups([], nodes)).not.toBe(nodes)
165+
expect(mergeGroups(nodes, [])).toEqual(nodes)
166+
expect(mergeGroups(nodes, [])).not.toBe(nodes)
167+
})
168+
169+
test("splits at every depth-first leaf boundary and rejoins canonically", () => {
170+
const entries = [
171+
entry("before"),
172+
entry("read-a", "activity", "exploration"),
173+
entry("read-b", "activity", "exploration"),
174+
entry("thought", "activity", "reasoning"),
175+
entry("shell", "activity"),
176+
entry("after"),
177+
]
178+
const nodes = group(entries)
179+
for (let count = 0; count <= entries.length; count++) {
180+
const [left, right] = splitGroups(nodes, count)
181+
expect(ids(left)).toEqual(entries.slice(0, count).map((item) => item.id))
182+
expect(ids(right)).toEqual(entries.slice(count).map((item) => item.id))
183+
expect(leafCount(left)).toBe(count)
184+
expect(leafCount(right)).toBe(entries.length - count)
185+
expect(mergeGroups(left, right)).toEqual(nodes)
186+
}
187+
})
188+
189+
test("rejects invalid split offsets", () => {
190+
const nodes = group([entry("a")])
191+
for (const count of [-1, 0.5, 2, Number.NaN]) expect(() => splitGroups(nodes, count)).toThrow(RangeError)
192+
})
193+
194+
test("all two-way partitions reproduce a fresh projection", () => {
195+
const entries = [
196+
entry("a", "activity", "exploration"),
197+
entry("b", "activity", "exploration"),
198+
entry("c", "activity", "reasoning"),
199+
entry("d", "activity"),
200+
entry("e"),
201+
entry("f", "instructions"),
202+
entry("g", "instructions"),
203+
]
204+
const whole = group(entries)
205+
for (let index = 0; index <= entries.length; index++) {
206+
expect(mergeGroups(group(entries.slice(0, index)), group(entries.slice(index)))).toEqual(whole)
207+
}
208+
})
209+
210+
test("all three-way partitions merge associatively", () => {
211+
const entries = [
212+
entry("a", "activity", "exploration"),
213+
entry("b", "activity", "exploration"),
214+
entry("c", "activity", "reasoning"),
215+
entry("d", "activity"),
216+
entry("e"),
217+
entry("f", "instructions"),
218+
entry("g", "instructions"),
219+
]
220+
const whole = group(entries)
221+
for (let first = 0; first <= entries.length; first++) {
222+
for (let second = first; second <= entries.length; second++) {
223+
const a = group(entries.slice(0, first))
224+
const b = group(entries.slice(first, second))
225+
const c = group(entries.slice(second))
226+
expect(mergeGroups(mergeGroups(a, b), c)).toEqual(whole)
227+
expect(mergeGroups(a, mergeGroups(b, c))).toEqual(whole)
228+
}
229+
}
230+
})
231+
232+
test("all short path sequences preserve order, sizes, splits, and associative seams", () => {
233+
const paths: Kind[][] = [
234+
[],
235+
["exploration"],
236+
["reasoning"],
237+
["activity"],
238+
["activity", "exploration"],
239+
["activity", "reasoning"],
240+
]
241+
const sequences = paths.flatMap((first) =>
242+
paths.flatMap((second) => paths.flatMap((third) => paths.map((fourth) => [first, second, third, fourth]))),
243+
)
244+
sequences.forEach((sequence) => {
245+
const entries = sequence.map((path, index) => entry(String(index), ...path))
246+
const whole = group(entries)
247+
expect(ids(whole)).toEqual(["0", "1", "2", "3"])
248+
expect(leafCount(whole)).toBe(4)
249+
for (let first = 0; first <= entries.length; first++) {
250+
const [left, right] = splitGroups(whole, first)
251+
expect(mergeGroups(left, right)).toEqual(whole)
252+
for (let second = first; second <= entries.length; second++) {
253+
const a = group(entries.slice(0, first))
254+
const b = group(entries.slice(first, second))
255+
const c = group(entries.slice(second))
256+
expect(mergeGroups(mergeGroups(a, b), c)).toEqual(whole)
257+
expect(mergeGroups(a, mergeGroups(b, c))).toEqual(whole)
258+
}
259+
}
260+
})
261+
})
262+
263+
test("supports deeper paths without special-casing two phases", () => {
264+
type DeepKind = "outer" | "middle" | "inner"
265+
const entries = [
266+
{ id: "a", path: ["outer", "middle", "inner"] as DeepKind[] },
267+
{ id: "b", path: ["outer", "middle", "inner"] as DeepKind[] },
268+
]
269+
const nodes = groupEntries(entries, (item) => item.path)
270+
expect(nodes).toMatchObject([
271+
{
272+
type: "group",
273+
kind: "outer",
274+
size: 2,
275+
children: [
276+
{
277+
type: "group",
278+
kind: "middle",
279+
size: 2,
280+
children: [{ type: "group", kind: "inner", size: 2 }],
281+
},
282+
],
283+
},
284+
])
285+
})
286+
287+
test("preserves duplicate leaves because ingestion identity belongs to the projection layer", () => {
288+
const duplicate = entry("same", "exploration")
289+
expect(ids(group([duplicate, duplicate]))).toEqual(["same", "same"])
290+
})
291+
})

0 commit comments

Comments
 (0)