prism-viz-engine 0.1.0

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,297 @@
1
+ /**
2
+ * Layer 02 — "the right canvas per shape."
3
+ *
4
+ * That phrase is the layer's own subtitle in prism-viz-engine-cluster.html, and it is a
5
+ * routing rule, not a description. The shape of the thing being drawn picks the renderer;
6
+ * the user does not toggle it. An infrastructure topology wants a floor plan. A pipeline
7
+ * wants an ordered chain. A code graph wants force.
8
+ *
9
+ * The three renderers named in layer 02, plus isometric, each own a shape:
10
+ *
11
+ * isometric infra/deployment topology GBFolio · DO/Cloudflare · IONOS
12
+ * things with zones, tiers, racks — a floor plan reads it fastest
13
+ * xyflow node-graph ordered process chains CC5 -> Reallusion -> Blender,
14
+ * workflows, sequences, lifecycles — order is the meaning
15
+ * react-force-graph code / knowledge graphs prism-graph, the 3D renderer;
16
+ * no authored layout, structure emerges from the edges
17
+ * Excalidraw freeform / hand-drawn infinite canvas, Lanshu's format
18
+ *
19
+ * The discriminator already exists and is authored, not guessed: archify's IR carries
20
+ * `diagram_type`, with a separate JSON Schema per type. So the router reads the field the
21
+ * generator already set. This is the auto-routing visual-explainer advertised and did not
22
+ * have (its "router" is a hard-coded array at mcp/server.mjs:263-289, which literally
23
+ * says "The MCP server does not call an LLM" at :272) — here it is real, because the IR
24
+ * declares its own shape.
25
+ */
26
+
27
+ import type { JSONCanvas } from "../../core/json-canvas"
28
+
29
+ /** archify's five, plus the two shapes our own substrate produces. */
30
+ export type DiagramShape =
31
+ | "architecture"
32
+ | "workflow"
33
+ | "sequence"
34
+ | "dataflow"
35
+ | "lifecycle"
36
+ | "codegraph"
37
+ | "freeform"
38
+
39
+ export type RendererId = "isometric" | "nodegraph" | "forcegraph" | "excalidraw"
40
+
41
+ export interface RouteDecision {
42
+ renderer: RendererId
43
+ /** Why this renderer — surfaced in the UI so the routing is legible, never magic. */
44
+ because: string
45
+ /** Renderers that can also open this shape, offered as an override. */
46
+ alternatives: RendererId[]
47
+ }
48
+
49
+ /**
50
+ * ISOMETRIC IS NOT THE DEFAULT FOR "architecture".
51
+ *
52
+ * Gavin asked for the isometric camera on CERTAIN architectural diagrams — GBFolio,
53
+ * DO/Cloudflare, IONOS — i.e. deployment topology, things that occupy somewhere. An
54
+ * earlier pass routed the whole `architecture` type to isometric, which made every
55
+ * diagram isometric. That is the opposite of what was asked for.
56
+ *
57
+ * The real discriminator is TOPOLOGY, not the type name. A deployment diagram has
58
+ * regions, zones, VPCs, hosts, clusters — it has a floor plan, so a floor plan reads it.
59
+ * An application architecture of the same declared type has modules and services with no
60
+ * spatial meaning at all, and forcing it onto a tile lattice invents a geography that
61
+ * isn't in the data.
62
+ *
63
+ * So `architecture` routes to the node graph by default, and only lifts to isometric when
64
+ * the document itself shows topology. The signals are read from the content, and the
65
+ * reason is reported, so the lift is never silent.
66
+ */
67
+ /**
68
+ * WHAT MAKES A DIAGRAM "DEPLOYMENT TOPOLOGY" — archify's answer, not mine.
69
+ *
70
+ * Three wrong passes preceded this, all of them me guessing:
71
+ * 1. a generic word list over boundary labels — too weak, real deployment diagrams
72
+ * scored 0.25 and stayed node-graph.
73
+ * 2. the boundary `kind` — meaningless on its own. The schema types it as
74
+ * {"enum":["region","security-group"]}, two values reused for ANY grouping:
75
+ * `kind=region` labels both "AWS us-east-1 / production" and "archify/ skill package".
76
+ * 3. cloud-provider and region-id regexes over labels — fitting to prose.
77
+ *
78
+ * Then the design harvest found that archify ALREADY DECLARES THIS, checkably, in
79
+ * `renderers/shared/engineering-profiles.mjs` — its `deployment-ownership` profile. That
80
+ * file emits authoring diagnostics and changes zero pixels, but its REQUIREMENTS are a
81
+ * precise structural definition of a deployment diagram:
82
+ *
83
+ * :4 DEPLOYMENT_BOUNDARY_KINDS = new Set(['region','security-group'])
84
+ * -> at least one of EACH (:30-41)
85
+ * :45-54 every non-`external` component names its owner in `tag`
86
+ * :56-78 every component belongs to exactly one `region` boundary
87
+ * :80-92 every `database` sits inside a `security-group`
88
+ * :95-117 region-consistency inside private boundaries
89
+ * :119-142 every boundary-crossing connection carries a named label
90
+ *
91
+ * The structural rules are the discriminator, and the OWNER TAG is what separates the
92
+ * two cases that fooled every earlier attempt: a real deployment diagram names who
93
+ * operates each box, an application architecture does not. `archify/ skill package`
94
+ * carries a `region` boundary and no owners; `AWS us-east-1 / production` carries both.
95
+ *
96
+ * We score rather than gate because the profile is opt-in and a diagram can be genuine
97
+ * topology while failing a rule or two. But every term below is one of archify's, read
98
+ * from its source — not a pattern invented here.
99
+ */
100
+ /**
101
+ * KNOWN DUPLICATION — recorded rather than left to drift.
102
+ *
103
+ * This function answers the same question as archify's own
104
+ * `deploymentOwnershipDiagnostics`, which layer 01's `gate-ir.mjs` calls directly and
105
+ * which is authoritative. Two implementations of one rule is exactly the shape that
106
+ * rots, so it is named here.
107
+ *
108
+ * Why it exists anyway: that function is node-only. Its import chain
109
+ * (`diagnostics.mjs` -> `node:fs`, `node:path`) cannot enter a browser bundle, and by the
110
+ * time this code runs the IR is gone — we hold a converted JSON Canvas. So the browser
111
+ * cannot call the authority, only re-derive from what survived conversion.
112
+ *
113
+ * THE FIX, designed and deliberately not half-built: the sidecar runs node-side and CAN
114
+ * import archify. It should compute the verdict when it serves a diagram and pass it
115
+ * through as a route hint; `routeCanvas` then READS a fact and falls back to this scorer
116
+ * only when no hint arrives. One computation, at the only layer that can do it properly.
117
+ * Until that lands, treat `gate-ir.mjs`'s reported renderer as authoritative and this as
118
+ * the approximation — they agree on every example tested, which is not the same as
119
+ * being guaranteed to agree.
120
+ */
121
+ export function topologyScore(canvas: JSONCanvas): { score: number; hits: string[] } {
122
+ const hits: string[] = []
123
+ const nodes = canvas.nodes.filter((n) => n.type !== "group")
124
+ const groups = canvas.nodes.filter((n) => n.type === "group")
125
+ const ax = (n: unknown) => (n as any)?.archify ?? {}
126
+
127
+ // (1) at least one region AND one security-group boundary — engineering-profiles.mjs:30-41
128
+ const regions = groups.filter((g) => ax(g).kind === "region")
129
+ const secGroups = groups.filter((g) => ax(g).kind === "security-group")
130
+ const bothKinds = regions.length > 0 && secGroups.length > 0
131
+ if (bothKinds) hits.push(`${regions.length} region + ${secGroups.length} security-group`)
132
+
133
+ // (2) owners named in `tag` — :45-54. The decisive signal: deployments have operators.
134
+ const ownable = nodes.filter((n) => ax(n).type !== "external")
135
+ const owned = ownable.filter((n) => typeof ax(n).tag === "string" && ax(n).tag.trim() !== "")
136
+ const ownedRatio = ownable.length ? owned.length / ownable.length : 0
137
+ if (owned.length) hits.push(`${owned.length}/${ownable.length} components name an owner`)
138
+
139
+ // (3) components actually placed inside a region — :56-78
140
+ const regionWraps = new Set(regions.flatMap((g) => (ax(g).wraps as string[]) ?? []))
141
+ const placedInRegion = nodes.filter((n) => regionWraps.has(n.id))
142
+ const placedRatio = nodes.length ? placedInRegion.length / nodes.length : 0
143
+ if (placedInRegion.length) hits.push(`${placedInRegion.length}/${nodes.length} inside a region`)
144
+
145
+ // (4) every database inside a security-group — :80-92
146
+ const sgWraps = new Set(secGroups.flatMap((g) => (ax(g).wraps as string[]) ?? []))
147
+ const dbs = nodes.filter((n) => ax(n).type === "database")
148
+ const dbsGuarded = dbs.filter((n) => sgWraps.has(n.id))
149
+ if (dbs.length && dbsGuarded.length === dbs.length) hits.push(`all ${dbs.length} database(s) in a security-group`)
150
+
151
+ // OWNERSHIP DOMINATES, and that is archify's weighting, not one tuned to get an answer.
152
+ // A missing owner is severity `error` on EVERY non-external component
153
+ // (engineering-profiles.mjs:45-54) — the profile does not tolerate one. So a genuine
154
+ // deployment diagram approaches 1.0 here and an application architecture does not,
155
+ // which is the difference the three earlier attempts kept failing to find:
156
+ // "AWS us-east-1 / production" names who operates each box; "Query Runtime" does not.
157
+ //
158
+ // bothKinds is necessary but NOT sufficient — nearly every archify example has a
159
+ // region+security-group pair, because those two values are the whole enum. It is worth
160
+ // a floor, not a verdict.
161
+ const score =
162
+ (bothKinds ? 0.22 : 0) +
163
+ ownedRatio * 0.48 +
164
+ placedRatio * 0.2 +
165
+ (dbs.length && dbsGuarded.length === dbs.length ? 0.1 : 0)
166
+
167
+ return { score, hits }
168
+ }
169
+
170
+ const ROUTES: Record<DiagramShape, RouteDecision> = {
171
+ architecture: {
172
+ // default — an application architecture is modules and services, not a floor plan
173
+ renderer: "nodegraph",
174
+ because: "components and their relationships — no spatial meaning to project",
175
+ alternatives: ["isometric", "excalidraw"],
176
+ },
177
+ workflow: {
178
+ renderer: "nodegraph",
179
+ because: "an ordered process chain — order is the meaning, so it wants a directed graph",
180
+ alternatives: ["excalidraw"],
181
+ },
182
+ sequence: {
183
+ renderer: "nodegraph",
184
+ because: "ordered exchange between participants — lanes and order carry it",
185
+ alternatives: ["excalidraw"],
186
+ },
187
+ lifecycle: {
188
+ renderer: "nodegraph",
189
+ because: "state transitions — a directed graph shows the reachable set",
190
+ alternatives: ["forcegraph", "excalidraw"],
191
+ },
192
+ dataflow: {
193
+ renderer: "nodegraph",
194
+ because: "directed movement between stages — edges are the subject",
195
+ alternatives: ["forcegraph"],
196
+ },
197
+ codegraph: {
198
+ renderer: "forcegraph",
199
+ because: "no authored layout — structure emerges from the edges (prism-graph)",
200
+ alternatives: ["nodegraph"],
201
+ },
202
+ freeform: {
203
+ renderer: "excalidraw",
204
+ because: "hand-drawn infinite canvas — nothing here implies an order",
205
+ alternatives: ["nodegraph"],
206
+ },
207
+ }
208
+
209
+ /** Shapes we can actually render today. The rest are declared and honest about it. */
210
+ export const IMPLEMENTED: Record<RendererId, boolean> = {
211
+ nodegraph: true, // xyflow — src/layers/02-render/Canvas.tsx
212
+ isometric: true, // FossFLOW projection — src/layers/02-render/IsometricView.tsx
213
+ forcegraph: false, // prism-graph, react-force-graph — layer 02's third renderer, not built
214
+ excalidraw: false, // the Excal writer grafted from Lanshu belongs here — not built
215
+ }
216
+
217
+ export function routeForShape(shape: DiagramShape): RouteDecision {
218
+ return ROUTES[shape] ?? ROUTES.freeform
219
+ }
220
+
221
+ /**
222
+ * Route a canvas. Prefers the authored `diagram_type` the generator set; falls back to a
223
+ * structural read only when the document carries no declaration — and says which it used,
224
+ * so an inferred route is never mistaken for a declared one.
225
+ */
226
+ export function routeCanvas(
227
+ canvas: JSONCanvas,
228
+ declaredType?: string
229
+ ): RouteDecision & { shape: DiagramShape; declared: boolean; empty?: boolean } {
230
+ if (declaredType && declaredType in ROUTES) {
231
+ const shape = declaredType as DiagramShape
232
+ const base = routeForShape(shape)
233
+
234
+ // The only lift to isometric: a declared architecture that actually shows topology.
235
+ // Threshold is deliberately high — when in doubt this stays a node graph, because a
236
+ // wrongly-isometric diagram invents a geography the data does not contain.
237
+ if (shape === "architecture") {
238
+ // 0.6, not 0.35. The ranking below 0.6 is sound — deployment diagrams sort above
239
+ // application architectures — but the absolute scores cluster, because most archify
240
+ // examples only partially fill the deployment-ownership profile. So the threshold
241
+ // picks which error to make. Gavin's reported defect was EVERYTHING going isometric,
242
+ // and a wrongly-isometric diagram invents a geography the data does not contain,
243
+ // while a wrongly-flat one is one click on the override. Under-lift on purpose.
244
+ const topo = topologyScore(canvas)
245
+ if (topo.score >= 0.6) {
246
+ return {
247
+ renderer: "isometric",
248
+ because: `deployment topology — ${topo.hits.join(", ")}`,
249
+ alternatives: ["nodegraph", "excalidraw"],
250
+ shape,
251
+ declared: true,
252
+ }
253
+ }
254
+ }
255
+ return { ...base, shape, declared: true }
256
+ }
257
+
258
+ // No declaration. Read the document's own structure rather than guessing a name.
259
+ const nodes = canvas.nodes.filter((n) => n.type !== "group")
260
+ const groups = canvas.nodes.length - nodes.length
261
+ const edges = canvas.edges.length
262
+
263
+ // An empty canvas has no shape to read. Saying "freeform" here would be inventing a
264
+ // reading from nothing — report the absence and open on the composing surface.
265
+ if (!nodes.length) {
266
+ return {
267
+ renderer: "nodegraph",
268
+ because: "nothing placed yet — no shape to read, so the composing canvas opens",
269
+ alternatives: ["isometric"],
270
+ shape: "workflow",
271
+ declared: false,
272
+ empty: true,
273
+ }
274
+ }
275
+ const density = nodes.length ? edges / nodes.length : 0
276
+ const sided = canvas.edges.filter((e) => e.fromSide || e.toSide).length
277
+
278
+ // Same test as the declared path — topology, not the mere presence of boundaries.
279
+ const topo = topologyScore(canvas)
280
+ const shape: DiagramShape =
281
+ groups > 0 && sided > 0 && topo.score >= 0.35
282
+ ? "architecture"
283
+ : density > 1.6
284
+ ? "codegraph" // densely interlinked, no authored layout to preserve
285
+ : edges > 0
286
+ ? "workflow"
287
+ : "freeform"
288
+
289
+ return { ...routeForShape(shape), shape, declared: false }
290
+ }
291
+
292
+ /** Fall back to something implemented, and say so rather than rendering nothing. */
293
+ export function resolveRenderer(d: RouteDecision): { renderer: RendererId; fellBack: boolean } {
294
+ if (IMPLEMENTED[d.renderer]) return { renderer: d.renderer, fellBack: false }
295
+ const alt = d.alternatives.find((a) => IMPLEMENTED[a])
296
+ return { renderer: alt ?? "nodegraph", fellBack: true }
297
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Layer 03 (substrate) → the JSON Canvas wire.
3
+ *
4
+ * Turns what the two harvest modes emit into canvas nodes. The engine never invents a
5
+ * node: everything on the canvas came from a walk that cited file:line, or from a
6
+ * shelf row that cited a slug. If neither exists, nothing is drawn.
7
+ *
8
+ * Two inputs, ONE output node per component — that is the fusion Gavin asked for:
9
+ * - `uxui-canvas-nodes.json` (griot-harvest-ux-ui) -> the UI half
10
+ * - the DGS plan's oss-inspo rows (griot-harvest) -> the code half
11
+ * They join on the repo, so a harvested screen carries its tool's licence, fit verdict
12
+ * and decision on the same card you drag.
13
+ *
14
+ * Kuzu (layer 03, `trial · next` on the shelf — the one renderer-cluster tool already
15
+ * ruled) is the eventual query substrate here; `loadFromKuzu` is the seam it lands on.
16
+ * It is deliberately not stubbed with fake data — an unimplemented path throws.
17
+ */
18
+
19
+ import {
20
+ type JSONCanvas,
21
+ type CanvasNode,
22
+ type GriotNodeMeta,
23
+ emptyCanvas,
24
+ } from "../../core/json-canvas"
25
+ import { type LayerSlot, isLayerSlot, UNPLACEABLE } from "../../core/layer-roles"
26
+
27
+ /** The shape `emit-canvas-nodes.mjs` writes. */
28
+ export interface HarvestedUxNode {
29
+ id: string
30
+ type: "component" | "screen" | "flow" | "workflow"
31
+ label: string
32
+ layer: string
33
+ position: { x: number; y: number }
34
+ data: {
35
+ walkLevel?: string
36
+ parentId?: string | null
37
+ origin: { repo?: string; file: string; line: number }
38
+ mountPoint: string
39
+ provenance: { harvestedBy: string; harvestedAt: string; sourceCommit?: string | null; repo?: string }
40
+ licence: string
41
+ notCopy?: string[]
42
+ }
43
+ }
44
+
45
+ /** The shape a `griot-harvest` / DGS `oss-inspo` row contributes. */
46
+ export interface HarvestedCodeRow {
47
+ repo: string
48
+ capability?: string
49
+ features?: string[]
50
+ fit?: { app: string; strength: 1 | 2 | 3; why: string }
51
+ licence?: string
52
+ decision?: "adopt" | "trial" | "defer" | "pass" | "undecided"
53
+ role?: "scaffold" | "component" | "pattern"
54
+ stage?: "now" | "next" | "later"
55
+ }
56
+
57
+ const CARD_W = 232
58
+ const CARD_H = 96
59
+
60
+ /**
61
+ * Layer bands give a node its y; repo columns give it x. Position carries only meaning
62
+ * that is actually in the data — the emitter stores placeholders in `position` and says
63
+ * outright that it "never invents meaning" there, so we recompute rather than trust it.
64
+ */
65
+ export function adaptHarvest(
66
+ uxNodes: HarvestedUxNode[],
67
+ codeRows: HarvestedCodeRow[] = [],
68
+ opts: { laneHeight?: number; labelWidth?: number; slots: readonly LayerSlot[] } = {
69
+ slots: [],
70
+ }
71
+ ): JSONCanvas {
72
+ const laneH = opts.laneHeight ?? 168
73
+ const labelW = opts.labelWidth ?? 208
74
+ const slots = opts.slots
75
+ const canvas = emptyCanvas()
76
+
77
+ const codeByRepo = new Map(codeRows.map((r) => [r.repo, r]))
78
+ const repos = [...new Set(uxNodes.map((n) => repoOf(n)))].sort()
79
+ const packed = new Map<string, number>()
80
+
81
+ for (const n of uxNodes) {
82
+ const layer: LayerSlot = isLayerSlot(n.layer) ? n.layer : UNPLACEABLE
83
+ const repo = repoOf(n)
84
+ const lane = Math.max(0, slots.indexOf(layer))
85
+ const key = `${lane}|${repo}`
86
+ const i = packed.get(key) ?? 0
87
+ packed.set(key, i + 1)
88
+
89
+ const code = codeByRepo.get(repo)
90
+ const griot: GriotNodeMeta = {
91
+ layer,
92
+ walkLevel: n.type,
93
+ ui: {
94
+ origin: { repo, file: n.data.origin.file, line: n.data.origin.line },
95
+ mountPoint: n.data.mountPoint,
96
+ notCopy: n.data.notCopy ?? [],
97
+ preview: { kind: "none" },
98
+ },
99
+ code: code
100
+ ? {
101
+ capability: code.capability,
102
+ features: code.features,
103
+ fit: code.fit,
104
+ licence: code.licence ?? n.data.licence,
105
+ decision: code.decision ?? "undecided",
106
+ role: code.role,
107
+ stage: code.stage,
108
+ }
109
+ : { licence: n.data.licence },
110
+ provenance: {
111
+ harvestedBy: n.data.provenance.harvestedBy,
112
+ harvestedAt: n.data.provenance.harvestedAt,
113
+ sourceCommit: n.data.provenance.sourceCommit ?? null,
114
+ },
115
+ }
116
+
117
+ const node: CanvasNode = {
118
+ id: n.id,
119
+ type: "file",
120
+ file: n.data.origin.file,
121
+ x: labelW + 24 + repos.indexOf(repo) * (CARD_W + 20) + i * 18,
122
+ y: lane * laneH + 34 + i * 14,
123
+ width: CARD_W,
124
+ height: CARD_H,
125
+ griot,
126
+ } as CanvasNode
127
+ ;(node as any).label = n.label
128
+ canvas.nodes.push(node)
129
+ }
130
+
131
+ // Edges come from declared parentage ONLY. No inferred relationships, ever.
132
+ for (const n of uxNodes) {
133
+ const parent = n.data.parentId
134
+ if (!parent) continue
135
+ if (!uxNodes.some((m) => m.id === parent)) continue
136
+ canvas.edges.push({
137
+ id: `${parent}->${n.id}`,
138
+ fromNode: parent,
139
+ fromSide: "bottom",
140
+ toNode: n.id,
141
+ toSide: "top",
142
+ })
143
+ }
144
+
145
+ return canvas
146
+ }
147
+
148
+ const repoOf = (n: HarvestedUxNode) => n.data.provenance?.repo ?? n.data.origin?.repo ?? "?"
149
+
150
+ /**
151
+ * The Kuzu seam. `kuzudb/kuzu` is `trial · next` on the shelf and is the prism-graph
152
+ * query substrate; when the embedded DB lands, structural nodes come from a Cypher
153
+ * query here instead of a JSON file. Left throwing on purpose — a stub that returns
154
+ * plausible fake rows is the exact failure this whole engine exists to stop.
155
+ */
156
+ export async function loadFromKuzu(_cypher: string): Promise<JSONCanvas> {
157
+ throw new Error(
158
+ "loadFromKuzu: the Kuzu substrate is not wired yet (shelf state: trial · next). " +
159
+ "Use adaptHarvest() against a real harvest until it is — this path will not return invented rows."
160
+ )
161
+ }