@inerrata-corporation/errata 2.0.21 → 2.0.22-dev.4049

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ import type { ChainFacts, MapGraph, MapNode, Scope } from '../types'
2
+ import type { JudgmentKind } from './minimap'
3
+ import { displayName, shortName } from './minimap'
4
+
5
+ /**
6
+ * The graph view (`/errata-view graph`): the original minimap — the burst as a
7
+ * radial graph, the seed at the centre, one ring per hop, braille edges
8
+ * coloured by how far each prior travelled. The library (bookcase.ts) is the
9
+ * default; this stays for people who read structure better than shelves.
10
+ */
11
+
12
+ export type RadialNode = MapNode & { x: number; y: number }
13
+
14
+ export type RadialGraph = {
15
+ nodes: RadialNode[]
16
+ /** Index pairs into `nodes`; `true` = an edge of the walk, `false` = a cross edge. */
17
+ edges: [number, number, boolean][]
18
+ }
19
+
20
+ /**
21
+ * Radial layout of one catalogued burst: each node in its parent's angular
22
+ * sector, one ring per hop, and within a ring a higher score sits closer in.
23
+ * Positions are in the unit square. `layoutBurst` already lists every node
24
+ * right after its parent, so one pass places them all.
25
+ */
26
+ export function layoutRadial(map: MapGraph, cross: ReadonlyArray<readonly [string, string]> = []): RadialGraph {
27
+ const maxHops = Math.max(1, ...map.nodes.map(n => n.hops))
28
+ const scores = map.nodes.filter(n => !n.isSeed).map(n => n.score)
29
+ const [lo, hi] = [Math.min(...scores), Math.max(...scores)]
30
+ const norm = (s: number) => (hi > lo ? (s - lo) / (hi - lo) : 1)
31
+
32
+ const kids = new Map<number, number[]>()
33
+ map.nodes.forEach((n, i) => {
34
+ if (n.isSeed) return
35
+ const p = n.parent >= 0 && n.parent < map.nodes.length ? n.parent : 0
36
+ kids.set(p, [...(kids.get(p) ?? []), i])
37
+ })
38
+ const sector = new Map<number, [number, number]>([[0, [0, Math.PI * 2]]])
39
+ const nodes: RadialNode[] = map.nodes.map(n => ({ ...n, x: 0.5, y: 0.5 }))
40
+ const edges: [number, number, boolean][] = []
41
+ map.nodes.forEach((_, p) => {
42
+ const children = kids.get(p) ?? []
43
+ const [a0, a1] = sector.get(p) ?? [0, Math.PI * 2]
44
+ const span = (a1 - a0) / Math.max(1, children.length)
45
+ children.forEach((i, k) => {
46
+ const n = map.nodes[i]!
47
+ const angle = a0 + span * (k + 0.5) - Math.PI / 2
48
+ const r = 0.48 * (n.hops / maxHops) * (0.72 + 0.28 * (1 - norm(n.score)))
49
+ nodes[i] = { ...n, x: 0.5 + r * Math.cos(angle), y: 0.5 + r * Math.sin(angle) }
50
+ sector.set(i, [a0 + span * k, a0 + span * (k + 1)])
51
+ edges.push([p, i, true])
52
+ })
53
+ })
54
+
55
+ const index = new Map(map.nodes.map((n, i) => [n.id, i]))
56
+ const seen = new Set(edges.map(([a, b]) => key(a, b)))
57
+ for (const [s, t] of cross) {
58
+ const a = index.get(s), b = index.get(t)
59
+ if (a === undefined || b === undefined || a === b || seen.has(key(a, b))) continue
60
+ seen.add(key(a, b))
61
+ edges.push([a, b, false])
62
+ }
63
+ return { nodes, edges }
64
+ }
65
+
66
+ function key(a: number, b: number): string {
67
+ return a < b ? `${a},${b}` : `${b},${a}`
68
+ }
69
+
70
+ /**
71
+ * The real edges among a burst's nodes, from the `errata_neighbors` answers the
72
+ * library already fetched to complete its chains — the graph view's cross
73
+ * edges cost no call of their own.
74
+ */
75
+ export function crossEdges(map: MapGraph, facts: Readonly<Record<string, ChainFacts | null>>): [string, string][] {
76
+ const ids = new Set(map.nodes.map(n => n.id))
77
+ const out: [string, string][] = []
78
+ for (const f of Object.values(facts)) {
79
+ if (!f || !ids.has(f.id)) continue
80
+ for (const far of [...f.causes, ...f.fixes, ...f.problems, ...f.patterns]) {
81
+ if (ids.has(far.id) && far.id !== f.id) out.push([f.id, far.id])
82
+ }
83
+ }
84
+ return out
85
+ }
86
+
87
+ const DEFAULT = 0x01000000
88
+ // Bright enough to read on a dark terminal: the first cut's #3a3f4b vanished there.
89
+ const EDGE_WALK = 0x8a96b4
90
+ const EDGE_CROSS = 0x5c6680
91
+
92
+ /**
93
+ * An edge takes the colour of the membrane its far end lives in, so the map
94
+ * reads "how far did this prior travel to reach me": machine-only, the
95
+ * project / team / org membranes, or the public Errata Network.
96
+ */
97
+ export const SCOPE_COLOR: Record<Scope, number> = {
98
+ machine: 0x8a96b4, // slate — never left this machine
99
+ project: 0x5fa8ff, // blue
100
+ team: 0x3fc5b0, // teal
101
+ org: 0xf0b34a, // amber
102
+ public: 0xd27cf0, // magenta — The Errata Network
103
+ }
104
+ export const SCOPE_ORDER: Scope[] = ['machine', 'project', 'team', 'org', 'public']
105
+
106
+ /** The tier an edge is coloured by: its non-seed end, the farther-travelled when both say. */
107
+ export function edgeScope(a: MapNode, b: MapNode): Scope | undefined {
108
+ const ends = [a, b].filter(v => !v.isSeed && v.scope).map(v => v.scope!)
109
+ return ends.sort((p, q) => SCOPE_ORDER.indexOf(q) - SCOPE_ORDER.indexOf(p))[0]
110
+ }
111
+
112
+ function dim(color: number): number {
113
+ return (((color >> 16) & 255) * 0.6) << 16 | (((color >> 8) & 255) * 0.6) << 8 | ((color & 255) * 0.6)
114
+ }
115
+ const NAME = 0x9aa3b5
116
+
117
+ export const LABEL_COLOR: Record<string, number> = {
118
+ Problem: 0xe06c75,
119
+ Vulnerability: 0xff5f87,
120
+ Solution: 0x98c379,
121
+ RootCause: 0xd19a66,
122
+ Pattern: 0xc678dd,
123
+ AntiPattern: 0xbe5046,
124
+ Technique: 0x56b6c2,
125
+ Constraint: 0xe5c07b,
126
+ File: 0x61afef,
127
+ Function: 0x7fb8d8,
128
+ Method: 0x7fb8d8,
129
+ Module: 0xa9b1c4,
130
+ Package: 0xa9b1c4,
131
+ Const: 0x8fbfa0,
132
+ Variable: 0x8fbfa0,
133
+ Interface: 0xb4a7d6,
134
+ Class: 0xb4a7d6,
135
+ Type: 0xb4a7d6,
136
+ Comment: 0x7c8494,
137
+ }
138
+
139
+ export const LABEL_GLYPH: Record<string, number> = {
140
+ Problem: 0x25c6, // ◆
141
+ Vulnerability: 0x25c6,
142
+ Solution: 0x25cf, // ●
143
+ RootCause: 0x25b2, // ▲
144
+ Pattern: 0x2726, // ✦
145
+ AntiPattern: 0x2726,
146
+ Technique: 0x25c7, // ◇
147
+ Constraint: 0x25a0, // ■
148
+ File: 0x25aa, // ▪
149
+ // Code structure a burst walks through (CALLS / IMPORTS / DEFINES / CONTAINS).
150
+ Function: 0x0192, // ƒ
151
+ Method: 0x0192,
152
+ Module: 0x229e, // ⊞
153
+ Package: 0x229e,
154
+ Const: 0x2261, // ≡
155
+ Variable: 0x2261,
156
+ Interface: 0x25a1, // □
157
+ Class: 0x25a1,
158
+ Type: 0x25a1,
159
+ Comment: 0x00b6, // ¶
160
+ }
161
+
162
+ export function glyphOf(label: string): number {
163
+ return LABEL_GLYPH[label] ?? 0x2022 // •
164
+ }
165
+
166
+ export function colorOf(label: string): number {
167
+ return LABEL_COLOR[label] ?? 0x6b7280
168
+ }
169
+
170
+ /** Verdict colours on the graph's own (One Dark) palette. */
171
+ export const VERDICT_COLOR: Record<JudgmentKind, number> = { cite: 0x56b6c2, confirm: 0x98c379, fix: 0x98c379, refute: 0xe06c75 }
172
+
173
+ /** What "transparent" fades toward: the background of the terminal theme. */
174
+ export const DARK_BG = 0x1a1b1e
175
+ export const LIGHT_BG = 0xf7f7f5
176
+
177
+ /** Fraction of a colour kept at the weakest signal: faint, never gone. */
178
+ const FLOOR = 0.28
179
+
180
+ /** Signal strength per node, 0..1 over this burst's score range; the seed is full strength. */
181
+ export function strengths(graph: RadialGraph): number[] {
182
+ const scores = graph.nodes.filter(v => !v.isSeed).map(v => v.score)
183
+ const [lo, hi] = [Math.min(...scores), Math.max(...scores)]
184
+ return graph.nodes.map(v => (v.isSeed ? 1 : hi > lo ? (v.score - lo) / (hi - lo) : 1))
185
+ }
186
+
187
+ /** Blends `color` toward `bg` as signal weakens: strength 1 keeps it, 0 keeps FLOOR of it. */
188
+ export function fade(color: number, strength: number, bg: number): number {
189
+ const keep = FLOOR + (1 - FLOOR) * Math.max(0, Math.min(1, strength))
190
+ const ch = (c: number, shift: number) => Math.round(((c >> shift) & 255) * keep + ((bg >> shift) & 255) * (1 - keep))
191
+ return (ch(color, 16) << 16) | (ch(color, 8) << 8) | ch(color, 0)
192
+ }
193
+
194
+ /** How far the reveal has run: routes grow out ring by ring (`grow`), then names type out (`type`). Both 0..1. */
195
+ export type Frame = { grow: number; type: number }
196
+
197
+ export type RasterOptions = {
198
+ frame?: Frame
199
+ /** The theme background weak signal fades toward. */
200
+ bg?: number
201
+ /** A node the agent is bursting off: drawn inverted, its route to the seed lit. */
202
+ highlight?: string
203
+ isPulseOn?: boolean
204
+ /** Priors the agent just judged: each pulses in its verdict's colour. */
205
+ marks?: Record<string, JudgmentKind>
206
+ isMarkOn?: boolean
207
+ }
208
+
209
+ const HIGHLIGHT = 0xffd75f
210
+
211
+ /**
212
+ * The graph as Raster cells (`[codePoint, fg, bg]` u32 triplets, row-major,
213
+ * base64) at one frame of the reveal. Positions and label slots are planned on
214
+ * the finished map, so an animated frame only ever draws a prefix of it —
215
+ * nothing jumps as it fills in.
216
+ */
217
+ export function rasterizeGraph(graph: RadialGraph, columns: number, rows: number, options: RasterOptions = {}): string {
218
+ const { frame = { grow: 1, type: 1 }, bg = DARK_BG, highlight, isPulseOn = true, marks = {}, isMarkOn = true } = options
219
+ const markColor = (id: string): number | undefined => {
220
+ const kind = marks[id]
221
+ return kind ? VERDICT_COLOR[kind] : undefined
222
+ }
223
+ const words = new Uint32Array(columns * rows * 3)
224
+ for (let i = 0; i < columns * rows; i++) words.set([0x20, DEFAULT, DEFAULT], i * 3)
225
+ const occupied = new Uint8Array(columns * rows)
226
+ const cx = (x: number) => 1 + Math.round(x * Math.max(0, columns - 3))
227
+ const cy = (y: number) => Math.round(y * Math.max(0, rows - 1))
228
+ const put = (x: number, y: number, ch: number, fg: number, weight: number, cellBg = DEFAULT) => {
229
+ if (x < 0 || y < 0 || x >= columns || y >= rows) return false
230
+ const i = y * columns + x
231
+ if (occupied[i]! > weight) return false
232
+ occupied[i] = weight
233
+ words.set([ch, fg, cellBg], i * 3)
234
+ return true
235
+ }
236
+
237
+ const pos: [number, number][] = graph.nodes.map(v => [cx(v.x), cy(v.y)])
238
+ // Two nodes in one cell: walk the later one outward to the nearest free cell.
239
+ const taken = new Set<number>()
240
+ graph.nodes.forEach((_, i) => {
241
+ let [x, y] = pos[i]!
242
+ for (let r = 0; taken.has(y * columns + x) && r < 6; r++) {
243
+ const ring = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]].map(([dx, dy]) => [x + dx! * (r + 1), y + dy! * (r + 1)] as const)
244
+ const free = ring.find(([a, b]) => a >= 0 && b >= 0 && a < columns && b < rows && !taken.has(b * columns + a))
245
+ if (free) [x, y] = free
246
+ }
247
+ taken.add(y * columns + x)
248
+ pos[i] = [x, y]
249
+ })
250
+
251
+ const strength = strengths(graph)
252
+ // The highlighted node and the walk edges from it back to the seed.
253
+ const lit = graph.nodes.findIndex(v => v.id === highlight)
254
+ const route = new Set<number>()
255
+ for (let at = lit, guard = 0; at > 0 && guard < 16; guard++) {
256
+ const k = graph.edges.findIndex(([, c, isWalk]) => isWalk && c === at)
257
+ if (k < 0) break
258
+ route.add(k)
259
+ at = graph.edges[k]![0]
260
+ }
261
+ const depth = Math.max(1, ...graph.nodes.map(v => v.hops))
262
+ // How much of the walk edge INTO node i has grown: ring h fills during the h-th slice of `grow`.
263
+ const reach = (i: number) => (graph.nodes[i]!.isSeed ? 1 : Math.max(0, Math.min(1, frame.grow * depth - (graph.nodes[i]!.hops - 1))))
264
+
265
+ // Label slots, planned on the finished map, best first: nodes and earlier labels block, edges do not.
266
+ const blocked = new Set(pos.map(([x, y]) => y * columns + x))
267
+ const labels: { i: number; at: number; text: string }[] = []
268
+ const ranked = graph.nodes.map((v, i) => [v, i] as const).filter(([v]) => !v.isSeed).sort((p, q) => q[0].score - p[0].score)
269
+ for (const [v, i] of ranked) {
270
+ const [x, y] = pos[i]!
271
+ const free = (from: number, len: number) => {
272
+ if (from < 0 || from + len > columns) return false
273
+ for (let k = 0; k < len; k++) if (blocked.has(y * columns + from + k)) return false
274
+ return true
275
+ }
276
+ const fits = (len: number) => (free(x + 2, len) ? x + 2 : free(x - 1 - len, len) ? x - 1 - len : -1)
277
+ let text = shortName(displayName(v.label, v.name), 18)
278
+ while (text.length >= 4 && fits(text.length) < 0) text = shortName(text.replace(/~$/, ''), text.length - 2)
279
+ const at = text.length >= 4 ? fits(text.length) : -1
280
+ if (at < 0) continue
281
+ for (let k = 0; k < text.length; k++) blocked.add(y * columns + at + k)
282
+ labels.push({ i, at, text })
283
+ }
284
+
285
+ // Edges go down at braille resolution — 2×4 dots per cell — so a line of any
286
+ // slope is one continuous stroke. Per-cell box glyphs could not do that: a
287
+ // cell is ~2× taller than wide, so a visual diagonal steps two cells sideways
288
+ // per row and adjacent ╱╱ never join.
289
+ const dots = new Uint8Array(columns * rows)
290
+ const tone = new Uint8Array(columns * rows) // 2 = a walk edge crosses the cell, 1 = cross edges only
291
+ const hue = new Uint32Array(columns * rows) // the colour of the strongest edge through the cell
292
+ const plot = (px: number, py: number, weight: number, color: number) => {
293
+ const x = px >> 1, y = py >> 2
294
+ if (x < 0 || y < 0 || x >= columns || y >= rows) return
295
+ const i = y * columns + x
296
+ dots[i] = dots[i]! | BRAILLE_BIT[(py & 3) * 2 + (px & 1)]!
297
+ if (weight >= tone[i]!) hue[i] = color
298
+ tone[i] = Math.max(tone[i]!, weight)
299
+ }
300
+ for (const [k, [a, b, isWalk]] of graph.edges.entries()) {
301
+ // A walk edge grows parent → child; a cross edge fades in while names type.
302
+ const progress = isWalk ? reach(b) : frame.type
303
+ if (progress <= 0) continue
304
+ const weaker = graph.nodes[a]!.isSeed ? b : graph.nodes[b]!.isSeed ? a : strength[a]! < strength[b]! ? a : b
305
+ const scope = edgeScope(graph.nodes[a]!, graph.nodes[b]!)
306
+ const base = scope ? SCOPE_COLOR[scope] : EDGE_WALK
307
+ const color = route.has(k) ? HIGHLIGHT : fade(isWalk ? base : scope ? dim(base) : EDGE_CROSS, strength[weaker]!, bg)
308
+ let x0 = pos[a]![0] * 2 + 1, y0 = pos[a]![1] * 4 + 2
309
+ const x1 = pos[b]![0] * 2 + 1, y1 = pos[b]![1] * 4 + 2
310
+ const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0)
311
+ const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1
312
+ const limit = Math.round(progress * Math.max(dx, -dy))
313
+ let err = dx + dy
314
+ // Cross edges are dashed so a walk edge reads as the path taken.
315
+ for (let step = 0; step <= limit; step++) {
316
+ if (isWalk || step % 4 < 2) plot(x0, y0, route.has(k) ? 4 : isWalk ? 2 : 1, color)
317
+ if (x0 === x1 && y0 === y1) break
318
+ const e2 = 2 * err
319
+ if (e2 >= dy) { err += dy; x0 += sx }
320
+ if (e2 <= dx) { err += dx; y0 += sy }
321
+ }
322
+ }
323
+ for (let i = 0; i < columns * rows; i++) {
324
+ if (dots[i]) put(i % columns, Math.floor(i / columns), 0x2800 + dots[i]!, hue[i]!, tone[i]!)
325
+ }
326
+
327
+ graph.nodes.forEach((v, i) => {
328
+ if (reach(i) < 1) return
329
+ const [x, y] = pos[i]!
330
+ if (v.isSeed) put(x, y, 0x25c9, 0xffd75f, 9) // ◉
331
+ else if (i === lit) put(x, y, glyphOf(v.label), isPulseOn ? bg : HIGHLIGHT, 10, isPulseOn ? HIGHLIGHT : DEFAULT)
332
+ else if (markColor(v.id) !== undefined) {
333
+ const c = markColor(v.id)!
334
+ put(x, y, glyphOf(v.label), isMarkOn ? bg : c, 10, isMarkOn ? c : DEFAULT)
335
+ }
336
+ else put(x, y, glyphOf(v.label), fade(colorOf(v.label), strength[i]!, bg), 8)
337
+ })
338
+
339
+ for (const { i, at, text } of labels) {
340
+ if (reach(i) < 1) continue
341
+ const shown = Math.floor(text.length * frame.type)
342
+ const color = i === lit ? HIGHLIGHT : markColor(graph.nodes[i]!.id) ?? fade(NAME, strength[i]!, bg)
343
+ for (let k = 0; k < shown; k++) put(at + k, pos[i]![1], text.codePointAt(k)!, color, 3)
344
+ }
345
+ return new Uint8Array(words.buffer).toBase64()
346
+ }
347
+
348
+ /** Unicode braille dot bits, indexed by `row * 2 + column` within the 2×4 cell (U+2800 + bits). */
349
+ const BRAILLE_BIT = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]