@dxos/diagram 0.11.1 → 0.14.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.
Files changed (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9406 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +108 -74
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +216 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +186 -0
  150. package/src/nudge.ts +387 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +66 -5
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +891 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
package/src/appeal.ts ADDED
@@ -0,0 +1,586 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // How a drawing reads to a person, measured: one evaluator per `code` or `both` rule in
7
+ // `rules/DIAGRAM.mdl`, each a score in [0, 1] (1 is good) computed from the scene's geometry. The
8
+ // `vision` half of the library is asked of a decision model shown the rendered page (`judge`), so a
9
+ // geometric measure can be checked against what a reader sees.
10
+ //
11
+
12
+ import type * as DecisionModel from 'effect/ai/DecisionModel';
13
+ import * as Effect from 'effect/Effect';
14
+
15
+ import * as Architecture from './architecture.ts';
16
+ import * as Diagnostics from './diagnostics.ts';
17
+ import * as Layout from './layout.ts';
18
+ import * as Objective from './objective.ts';
19
+ import * as Rules from './rules.ts';
20
+ import type * as Scene from './scene.ts';
21
+ import type * as Score from './score.ts';
22
+ import * as View from './view.ts';
23
+
24
+ type Point = Scene.Point;
25
+ type Rect = View.Box['rect'];
26
+
27
+ /** Everything an evaluator reads: the scene, its geometry and its defect report, computed once. */
28
+ export type Subject = {
29
+ readonly objects: readonly Scene.WorldObject[];
30
+ readonly drawing: View.Drawing;
31
+ readonly report: Diagnostics.Report;
32
+ /** Connectors as a reader traces them (a bus once per spoke), each with the boxes at its ends. */
33
+ readonly edges: readonly Edge[];
34
+ };
35
+
36
+ export type Edge = {
37
+ readonly points: readonly Point[];
38
+ readonly bends: number;
39
+ readonly from?: View.Box;
40
+ readonly to?: View.Box;
41
+ readonly style?: View.Path['style'];
42
+ };
43
+
44
+ export type Measure = { readonly score: number; readonly detail: string };
45
+
46
+ export type Evaluator = (subject: Subject) => Measure;
47
+
48
+ /** Coordinates closer than this are "the same line" for alignment. */
49
+ const ALIGNED = 1;
50
+
51
+ /** Line height over font size as the renderers draw text: `s` is 13 units on a 26-unit line. */
52
+ const LINE_PER_EM = 2;
53
+
54
+ /** The gap below which two boxes count as adjacent, so a short arrow between them is never a detour. */
55
+ const DIRECT_GAP = 64;
56
+
57
+ /** Coordinates differing by more than `ALIGNED` but less than this are a near miss. */
58
+ const NEAR_MISS = 12;
59
+
60
+ const mean = (values: readonly number[]) => values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
61
+
62
+ /** Coefficient of variation; 0 for fewer than two values or a zero mean. */
63
+ const variation = (values: readonly number[]) => {
64
+ const average = mean(values);
65
+ if (values.length < 2 || average === 0) {
66
+ return 0;
67
+ }
68
+ return Math.sqrt(mean(values.map((value) => (value - average) ** 2))) / average;
69
+ };
70
+
71
+ /** 1 at no penalty, 0.5 at `half`, falling off hyperbolically. */
72
+ const soften = (penalty: number, half = 1) => 1 / (1 + Math.max(0, penalty) / half);
73
+
74
+ const share = (outcomes: readonly boolean[], empty = 1) =>
75
+ outcomes.length ? outcomes.filter(Boolean).length / outcomes.length : empty;
76
+
77
+ const percent = (value: number) => `${Math.round(value * 100)}%`;
78
+
79
+ const center = ({ x, y, w, h }: Rect): Point => ({ x: x + w / 2, y: y + h / 2 });
80
+
81
+ const contains = (outer: Rect, inner: Rect) =>
82
+ outer.x <= inner.x + ALIGNED &&
83
+ outer.y <= inner.y + ALIGNED &&
84
+ outer.x + outer.w >= inner.x + inner.w - ALIGNED &&
85
+ outer.y + outer.h >= inner.y + inner.h - ALIGNED;
86
+
87
+ const intersects = (a: Rect, b: Rect) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
88
+
89
+ /** Border-to-border gap between two rects; 0 when they touch or overlap. */
90
+ const gap = (a: Rect, b: Rect) =>
91
+ Math.hypot(
92
+ Math.max(0, Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)),
93
+ Math.max(0, Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h)),
94
+ );
95
+
96
+ const pathLength = (points: readonly Point[]) =>
97
+ points
98
+ .slice(1)
99
+ .reduce((total, point, index) => total + Math.hypot(point.x - points[index].x, point.y - points[index].y), 0);
100
+
101
+ /** Overlap of two intervals' projections, positive when they share some extent. */
102
+ const overlap = (lo1: number, hi1: number, lo2: number, hi2: number) => Math.min(hi1, hi2) - Math.max(lo1, lo2);
103
+
104
+ /** Which side of a rect a point lies on, for a point on or just outside its border. */
105
+ const sideOf = (point: Point, { x, y, w, h }: Rect): 'top' | 'bottom' | 'left' | 'right' => {
106
+ const distances = {
107
+ top: Math.abs(point.y - y),
108
+ bottom: Math.abs(point.y - y - h),
109
+ left: Math.abs(point.x - x),
110
+ right: Math.abs(point.x - x - w),
111
+ };
112
+ return (Object.entries(distances) as [keyof typeof distances, number][]).reduce((best, entry) =>
113
+ entry[1] < best[1] ? entry : best,
114
+ )[0];
115
+ };
116
+
117
+ const OPPOSITE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const;
118
+
119
+ const nodesOf = ({ drawing }: Subject) => drawing.boxes.filter(({ frame }) => !frame);
120
+ const framesOf = ({ drawing }: Subject) => drawing.boxes.filter(({ frame }) => frame);
121
+
122
+ const distance = ({ x, y }: Point, rect: Rect) =>
123
+ Math.hypot(Math.max(rect.x - x, 0, x - rect.x - rect.w), Math.max(rect.y - y, 0, y - rect.y - rect.h));
124
+
125
+ /** The routes of a drawing, each resolved to the boxes nearest its ends and styled as its far-end piece. */
126
+ const edgesFor = (objects: readonly Scene.WorldObject[], drawing: View.Drawing): Edge[] => {
127
+ const nodes = drawing.boxes.filter(({ frame }) => !frame);
128
+ const styles = new Map(drawing.paths.map(({ ref, style }) => [ref, style]));
129
+ const nearest = (point: Point) =>
130
+ nodes.reduce<View.Box | undefined>(
131
+ (best, box) => (!best || distance(point, box.rect) < distance(point, best.rect) ? box : best),
132
+ undefined,
133
+ );
134
+ return Diagnostics.routes(objects).map(({ ref, points, bends }) => ({
135
+ points,
136
+ bends,
137
+ from: nearest(points[0]),
138
+ to: nearest(points[points.length - 1]),
139
+ style: styles.get(ref),
140
+ }));
141
+ };
142
+
143
+ /** Connectors that join two distinct boxes, which are the ones rules about arrows are about. */
144
+ const edgesOf = ({ edges }: Subject) =>
145
+ edges.filter(
146
+ (edge): edge is Edge & { from: View.Box; to: View.Box } =>
147
+ edge.from !== undefined && edge.to !== undefined && edge.from !== edge.to && edge.points.length >= 2,
148
+ );
149
+
150
+ /** The smallest frame enclosing a box, its innermost group. */
151
+ const groupOf = (box: View.Box, frames: readonly View.Box[]) =>
152
+ frames
153
+ .filter((frame) => frame !== box && contains(frame.rect, box.rect))
154
+ .reduce<View.Box | undefined>(
155
+ (best, frame) => (!best || frame.rect.w * frame.rect.h < best.rect.w * best.rect.h ? frame : best),
156
+ undefined,
157
+ );
158
+
159
+ /** Every frame enclosing a box, outermost first. */
160
+ const ancestorsOf = (box: View.Box, frames: readonly View.Box[]) =>
161
+ frames
162
+ .filter((frame) => frame !== box && contains(frame.rect, box.rect))
163
+ .sort((a, b) => b.rect.w * b.rect.h - a.rect.w * a.rect.h);
164
+
165
+ /** Times a polyline crosses a rect's border: entries plus exits, counted at segment ends inside vs. outside. */
166
+ const borderCrossings = (points: readonly Point[], rect: Rect) => {
167
+ const inside = (point: Point) =>
168
+ point.x > rect.x + ALIGNED &&
169
+ point.x < rect.x + rect.w - ALIGNED &&
170
+ point.y > rect.y + ALIGNED &&
171
+ point.y < rect.y + rect.h - ALIGNED;
172
+ let crossings = 0;
173
+ for (let index = 1; index < points.length; index++) {
174
+ if (inside(points[index]) !== inside(points[index - 1])) {
175
+ crossings++;
176
+ }
177
+ }
178
+ return crossings;
179
+ };
180
+
181
+ /** The main direction of the drawing: the axis and sign most connectors' net displacement runs along. */
182
+ const dominantFlow = (edges: readonly { from: View.Box; to: View.Box }[]) => {
183
+ const totals = { down: 0, up: 0, right: 0, left: 0 };
184
+ for (const { from, to } of edges) {
185
+ const [start, end] = [center(from.rect), center(to.rect)];
186
+ const [dx, dy] = [end.x - start.x, end.y - start.y];
187
+ totals[Math.abs(dy) >= Math.abs(dx) ? (dy > 0 ? 'down' : 'up') : dx > 0 ? 'right' : 'left']++;
188
+ }
189
+ return (Object.entries(totals) as [keyof typeof totals, number][]).reduce((best, entry) =>
190
+ entry[1] > best[1] ? entry : best,
191
+ )[0];
192
+ };
193
+
194
+ /** Code evaluators, keyed by rule id; every `code` and `both` rule in the library has one. */
195
+ export const EVALUATORS: Readonly<Record<string, Evaluator>> = {
196
+ 'no-edge-crossings': ({ report }) => ({
197
+ score: soften(report.metrics.crossings, 2),
198
+ detail: `${report.metrics.crossings} crossings`,
199
+ }),
200
+
201
+ 'no-edge-through-node': ({ report }) => ({
202
+ score: report.metrics.routesThroughNodes === 0 ? 1 : 0,
203
+ detail: `${report.metrics.routesThroughNodes} arrows through boxes`,
204
+ }),
205
+
206
+ 'no-shared-trunks': ({ report }) => ({
207
+ score: soften(report.metrics.edgeOverlaps),
208
+ detail: `${report.metrics.edgeOverlaps} arrow pairs share a line`,
209
+ }),
210
+
211
+ 'edges-head-toward-target': (subject) => {
212
+ // Against the gap between the boxes rather than between the arrow's own ends, so a route that loops
213
+ // round the page, or joins boxes placed far apart, reads as the detour it looks like.
214
+ const ratios = edgesOf(subject).map(({ points, from, to }) => {
215
+ const across =
216
+ Math.max(0, Math.max(from.rect.x, to.rect.x) - Math.min(from.rect.x + from.rect.w, to.rect.x + to.rect.w)) +
217
+ Math.max(0, Math.max(from.rect.y, to.rect.y) - Math.min(from.rect.y + from.rect.h, to.rect.y + to.rect.h));
218
+ return pathLength(points) / Math.max(across, DIRECT_GAP);
219
+ });
220
+ const direct = share(ratios.map((ratio) => ratio <= 2));
221
+ const excess = mean(ratios.map((ratio) => Math.max(0, ratio - 2)));
222
+ return {
223
+ score: direct * soften(excess, 2),
224
+ detail: `${percent(direct)} direct, mean excess ×${excess.toFixed(2)}`,
225
+ };
226
+ },
227
+
228
+ 'few-bends': (subject) => {
229
+ const excess = edgesOf(subject).map(({ bends, from, to }) => {
230
+ const aligned =
231
+ overlap(from.rect.x, from.rect.x + from.rect.w, to.rect.x, to.rect.x + to.rect.w) > 0 ||
232
+ overlap(from.rect.y, from.rect.y + from.rect.h, to.rect.y, to.rect.y + to.rect.h) > 0;
233
+ return Math.max(0, bends - (aligned ? 0 : 1));
234
+ });
235
+ const average = mean(excess);
236
+ return { score: soften(average), detail: `${average.toFixed(2)} excess bends per arrow` };
237
+ },
238
+
239
+ 'straight-when-aligned': (subject) => {
240
+ const facing = edgesOf(subject).filter(
241
+ ({ from, to }) =>
242
+ overlap(from.rect.x, from.rect.x + from.rect.w, to.rect.x, to.rect.x + to.rect.w) > 0 ||
243
+ overlap(from.rect.y, from.rect.y + from.rect.h, to.rect.y, to.rect.y + to.rect.h) > 0,
244
+ );
245
+ const straight = share(facing.map(({ bends }) => bends === 0));
246
+ return { score: straight, detail: `${percent(straight)} of ${facing.length} facing pairs straight` };
247
+ },
248
+
249
+ 'uniform-edge-length': (subject) => {
250
+ const cv = variation(edgesOf(subject).map(({ points }) => pathLength(points)));
251
+ return { score: 1 - Math.min(1, cv), detail: `length CV ${cv.toFixed(2)}` };
252
+ },
253
+
254
+ 'consistent-flow': (subject) => {
255
+ const edges = edgesOf(subject).filter(({ style }) => style?.head !== 'triangle');
256
+ const flow = dominantFlow(edges);
257
+ const withFlow = share(
258
+ edges.map(({ from, to }) => {
259
+ const [start, end] = [center(from.rect), center(to.rect)];
260
+ return flow === 'down'
261
+ ? end.y > start.y
262
+ : flow === 'up'
263
+ ? end.y < start.y
264
+ : flow === 'right'
265
+ ? end.x > start.x
266
+ : end.x < start.x;
267
+ }),
268
+ );
269
+ return { score: withFlow, detail: `${percent(withFlow)} of arrows run ${flow}` };
270
+ },
271
+
272
+ 'continuous-paths': (subject) => {
273
+ const edges = edgesOf(subject);
274
+ const scores = nodesOf(subject).flatMap((node) => {
275
+ const incoming = edges.filter(({ to }) => to === node);
276
+ const outgoing = edges.filter(({ from }) => from === node);
277
+ if (incoming.length !== 1 || outgoing.length !== 1) {
278
+ return [];
279
+ }
280
+ const entry = sideOf(incoming[0].points[incoming[0].points.length - 1], node.rect);
281
+ const exit = sideOf(outgoing[0].points[0], node.rect);
282
+ return [exit === OPPOSITE[entry] ? 1 : exit === entry ? 0 : 0.5];
283
+ });
284
+ return { score: scores.length ? mean(scores) : 1, detail: `${scores.length} pass-through boxes` };
285
+ },
286
+
287
+ 'grid-alignment': (subject) => {
288
+ const centers = nodesOf(subject).map(({ rect }) => center(rect));
289
+ const aligned = centers.map(
290
+ (point) =>
291
+ centers.filter((other) => other !== point && Math.abs(other.x - point.x) < ALIGNED).length >= 2 ||
292
+ centers.filter((other) => other !== point && Math.abs(other.y - point.y) < ALIGNED).length >= 2,
293
+ );
294
+ return { score: share(aligned), detail: `${percent(share(aligned))} of boxes in a line of three` };
295
+ },
296
+
297
+ 'no-near-misses': (subject) => {
298
+ const nodes = nodesOf(subject);
299
+ let misses = 0;
300
+ for (let index = 0; index < nodes.length; index++) {
301
+ for (const other of nodes.slice(index + 1)) {
302
+ const [a, b] = [nodes[index].rect, other.rect];
303
+ const offsets = [
304
+ center(a).x - center(b).x,
305
+ center(a).y - center(b).y,
306
+ a.x - b.x,
307
+ a.y - b.y,
308
+ a.x + a.w - b.x - b.w,
309
+ a.y + a.h - b.y - b.h,
310
+ ].map(Math.abs);
311
+ if (offsets.some((offset) => offset > ALIGNED && offset < NEAR_MISS)) {
312
+ misses++;
313
+ }
314
+ }
315
+ }
316
+ return { score: soften(misses, 2), detail: `${misses} near-aligned pairs` };
317
+ },
318
+
319
+ 'frames-nested': (subject) => {
320
+ const frames = framesOf(subject);
321
+ let partial = 0;
322
+ for (let index = 0; index < frames.length; index++) {
323
+ for (const other of frames.slice(index + 1)) {
324
+ const [a, b] = [frames[index].rect, other.rect];
325
+ if (intersects(a, b) && !contains(a, b) && !contains(b, a)) {
326
+ partial++;
327
+ }
328
+ }
329
+ }
330
+ return { score: partial === 0 ? 1 : 0, detail: `${partial} partly overlapping frames` };
331
+ },
332
+
333
+ 'frames-aligned': (subject) => {
334
+ const frames = framesOf(subject);
335
+ const aligned = frames.map(({ rect }) =>
336
+ frames.some(
337
+ (other) =>
338
+ other.rect !== rect &&
339
+ [
340
+ [rect.y, other.rect.y],
341
+ [rect.y + rect.h, other.rect.y + other.rect.h],
342
+ [rect.x, other.rect.x],
343
+ [rect.x + rect.w, other.rect.x + other.rect.w],
344
+ ].some(([first, second]) => Math.abs(first - second) < ALIGNED),
345
+ ),
346
+ );
347
+ return {
348
+ score: frames.length < 2 ? 1 : share(aligned),
349
+ detail: `${percent(share(aligned))} of ${frames.length} frames share an edge`,
350
+ };
351
+ },
352
+
353
+ 'frames-hug-members': (subject) => {
354
+ const frames = framesOf(subject);
355
+ const nodes = nodesOf(subject);
356
+ const fills = frames.flatMap((frame) => {
357
+ const members = nodes.filter((node) => contains(frame.rect, node.rect));
358
+ if (!members.length) {
359
+ return [];
360
+ }
361
+ const [x0, y0] = [Math.min(...members.map(({ rect }) => rect.x)), Math.min(...members.map(({ rect }) => rect.y))];
362
+ const [x1, y1] = [
363
+ Math.max(...members.map(({ rect }) => rect.x + rect.w)),
364
+ Math.max(...members.map(({ rect }) => rect.y + rect.h)),
365
+ ];
366
+ return [((x1 - x0) * (y1 - y0)) / (frame.rect.w * frame.rect.h)];
367
+ });
368
+ const fill = fills.length ? mean(fills) : 1;
369
+ return { score: Math.min(1, fill / 0.5), detail: `members fill ${percent(fill)} of their frames` };
370
+ },
371
+
372
+ 'few-frame-crossings': (subject) => {
373
+ const frames = framesOf(subject);
374
+ const extra = edgesOf(subject).reduce((total, { points, from, to }) => {
375
+ const [outer, inner] = [ancestorsOf(from, frames), ancestorsOf(to, frames)];
376
+ const required =
377
+ outer.filter((frame) => !inner.includes(frame)).length + inner.filter((frame) => !outer.includes(frame)).length;
378
+ const actual = frames.reduce((sum, frame) => sum + borderCrossings(points, frame.rect), 0);
379
+ return total + Math.max(0, actual - required);
380
+ }, 0);
381
+ return { score: soften(extra, 2), detail: `${extra} unneeded border crossings` };
382
+ },
383
+
384
+ 'gestalt-cues-agree': (subject) => {
385
+ const nodes = nodesOf(subject);
386
+ const frames = framesOf(subject);
387
+ if (!frames.length || nodes.length < 2) {
388
+ return { score: 1, detail: 'no groups' };
389
+ }
390
+ const agree = nodes.map((node) => {
391
+ const nearest = nodes
392
+ .filter((other) => other !== node)
393
+ .reduce((best, other) => (gap(node.rect, other.rect) < gap(node.rect, best.rect) ? other : best));
394
+ return groupOf(node, frames) === groupOf(nearest, frames);
395
+ });
396
+ return { score: share(agree), detail: `${percent(share(agree))} of boxes nearest a group-mate` };
397
+ },
398
+
399
+ 'compact-drawing': (subject) => {
400
+ const { bounds } = subject.drawing;
401
+ const area = nodesOf(subject).reduce((total, { rect }) => total + rect.w * rect.h, 0);
402
+ const density = bounds.w * bounds.h > 0 ? area / (bounds.w * bounds.h) : 1;
403
+ return { score: Math.min(1, density / 0.25), detail: `boxes cover ${percent(density)} of the drawing` };
404
+ },
405
+
406
+ 'balanced-whitespace': (subject) => {
407
+ const { bounds } = subject.drawing;
408
+ const cells = Array.from({ length: 9 }, (_, index): Rect => ({
409
+ x: bounds.x + ((index % 3) * bounds.w) / 3,
410
+ y: bounds.y + (Math.floor(index / 3) * bounds.h) / 3,
411
+ w: bounds.w / 3,
412
+ h: bounds.h / 3,
413
+ }));
414
+ const ink = cells.map((cell) =>
415
+ nodesOf(subject).reduce((total, { rect }) => {
416
+ const [w, h] = [
417
+ overlap(cell.x, cell.x + cell.w, rect.x, rect.x + rect.w),
418
+ overlap(cell.y, cell.y + cell.h, rect.y, rect.y + rect.h),
419
+ ];
420
+ return total + (w > 0 && h > 0 ? w * h : 0);
421
+ }, 0),
422
+ );
423
+ const cv = variation(ink);
424
+ return { score: 1 - Math.min(1, cv / 1.5), detail: `ink CV ${cv.toFixed(2)} across a 3 × 3 grid` };
425
+ },
426
+
427
+ 'uniform-spacing': (subject) => {
428
+ const nodes = nodesOf(subject);
429
+ const gaps = nodes.map(({ rect }) =>
430
+ Math.min(...nodes.filter((other) => other.rect !== rect).map((other) => gap(rect, other.rect))),
431
+ );
432
+ const cv = variation(gaps.filter(Number.isFinite));
433
+ return { score: 1 - Math.min(1, cv), detail: `nearest-gap CV ${cv.toFixed(2)}` };
434
+ },
435
+
436
+ 'landscape-aspect': ({ drawing: { bounds } }) => {
437
+ const ratio = bounds.h > 0 ? bounds.w / bounds.h : 1;
438
+ return { score: soften(Math.abs(Math.log(ratio / 1.6))), detail: `aspect ${ratio.toFixed(2)}` };
439
+ },
440
+
441
+ 'readable-at-page-width': ({ objects, drawing: { bounds } }) => {
442
+ const sizes = objects.flatMap((object) =>
443
+ object.elements.flatMap((element) =>
444
+ element.kind === 'text' || (element.kind === 'rect' && element.text)
445
+ ? [
446
+ (Layout.FONT_METRICS[element.weight ?? (element.kind === 'text' ? 's' : 'm')].lineH / LINE_PER_EM) *
447
+ (object.scale ?? 1),
448
+ ]
449
+ : [],
450
+ ),
451
+ );
452
+ const smallest = sizes.length ? Math.min(...sizes) : 0;
453
+ const scaled = smallest * Math.min(1, 1200 / Math.max(bounds.w, 1));
454
+ return {
455
+ score: sizes.length ? Math.min(1, scaled / 11) : 1,
456
+ detail: `smallest text ${scaled.toFixed(1)}px at 1200 wide`,
457
+ };
458
+ },
459
+
460
+ 'labels-clear': ({ report }) => {
461
+ const count = report.metrics.textOverlaps + report.metrics.labelOverflows;
462
+ return { score: soften(count), detail: `${count} overlapping or overflowing labels` };
463
+ },
464
+
465
+ 'labels-attached': (subject) => {
466
+ const labelRects = new Map(Diagnostics.labels(subject.objects).map(({ ref, rect }) => [ref, rect]));
467
+ const paths = subject.drawing.paths;
468
+ const distanceTo = (point: Point, points: readonly Point[]) =>
469
+ Math.min(
470
+ ...points.slice(1).map((end, index) => {
471
+ const start = points[index];
472
+ const [dx, dy] = [end.x - start.x, end.y - start.y];
473
+ const t =
474
+ dx || dy
475
+ ? Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)))
476
+ : 0;
477
+ return Math.hypot(point.x - start.x - t * dx, point.y - start.y - t * dy);
478
+ }),
479
+ );
480
+ const attached = paths.flatMap((path) => {
481
+ const rect = path.label ? labelRects.get(`${path.ref}-label`) : undefined;
482
+ if (!rect) {
483
+ return [];
484
+ }
485
+ const point = center(rect);
486
+ const own = distanceTo(point, path.points);
487
+ return [paths.every((other) => other === path || distanceTo(point, other.points) > own)];
488
+ });
489
+ return {
490
+ score: share(attached),
491
+ detail: `${percent(share(attached))} of ${attached.length} labels nearest their arrow`,
492
+ };
493
+ },
494
+ };
495
+
496
+ /** Measures every code-evaluated rule of a scene. */
497
+ export const measure = (
498
+ objects: readonly Scene.WorldObject[],
499
+ rules: readonly Rules.DiagramRule[] = Rules.RULES,
500
+ ): (Rules.DiagramRule & Measure)[] => {
501
+ const drawing = View.extract(objects);
502
+ const subject: Subject = {
503
+ objects,
504
+ drawing,
505
+ report: Diagnostics.analyze(objects),
506
+ edges: edgesFor(objects, drawing),
507
+ };
508
+ return rules.flatMap((rule) => {
509
+ const evaluator = EVALUATORS[rule.id];
510
+ return rule.evaluator !== 'vision' && evaluator ? [{ ...rule, ...evaluator(subject) }] : [];
511
+ });
512
+ };
513
+
514
+ /** The weighted mean of rule scores: one number for comparing drawings of the same diagram. */
515
+ export const overall = (scores: readonly { weight: number; score: number }[]): number => {
516
+ const total = scores.reduce((sum, { weight }) => sum + weight, 0);
517
+ return total ? scores.reduce((sum, { weight, score }) => sum + weight * score, 0) / total : 0;
518
+ };
519
+
520
+ const HOW_TO_SEE =
521
+ 'The attached image is an architecture diagram as drawn: boxes are components, dashed frames are groups, and ' +
522
+ 'arrows run from a component to what it depends on. Judge how the drawing reads to a person looking at it, ' +
523
+ 'not whether the architecture it shows is good.';
524
+
525
+ /** The library's `vision` and `both` rules as questions for a judge shown the rendered page. */
526
+ export const visionRules = (rules: readonly Rules.DiagramRule[] = Rules.RULES): readonly Architecture.Rule[] =>
527
+ rules.flatMap(({ id, title, evaluator, question, criteria }) =>
528
+ evaluator === 'code' || !question
529
+ ? []
530
+ : [
531
+ {
532
+ id,
533
+ key: Rules.keyOf(id),
534
+ description: title,
535
+ instructions: `${HOW_TO_SEE} ${question}`,
536
+ criteria: criteria ?? { true: 'Yes.', false: 'No.' },
537
+ },
538
+ ],
539
+ );
540
+
541
+ /** The vision rules as one batched judge over the rendered page; a failed call scores every rule as an error. */
542
+ export const judge = (
543
+ rules: readonly Rules.DiagramRule[] = Rules.RULES,
544
+ ): Score.Batch<Architecture.Subject, DecisionModel.DecisionModel> => Architecture.judge(visionRules(rules), 'appeal');
545
+
546
+ /** The code-evaluated rules as scorers over a scene, for `Score.evaluate` beside the judge. */
547
+ export const scorers = (
548
+ rules: readonly Rules.DiagramRule[] = Rules.RULES,
549
+ ): Score.Scorer<readonly Scene.WorldObject[]>[] =>
550
+ rules.flatMap((rule) =>
551
+ rule.evaluator === 'vision' || !EVALUATORS[rule.id]
552
+ ? []
553
+ : [
554
+ {
555
+ id: rule.id,
556
+ kind: 'appeal',
557
+ description: rule.title,
558
+ evaluate: (objects: readonly Scene.WorldObject[]) =>
559
+ Effect.sync(() => {
560
+ const [measured] = measure(objects, [rule]);
561
+ return { score: measured.score, detail: measured.detail };
562
+ }),
563
+ },
564
+ ],
565
+ );
566
+
567
+ /**
568
+ * A layout objective that adds the library's appeal to `base`: its hard constraints and costs stay, and
569
+ * every point of appeal a candidate lacks costs `weight` (a crossing costs 3), so the engine prefers the
570
+ * candidate a reader would. Appeal is measured over the code-evaluated rules only.
571
+ */
572
+ export const objective = (
573
+ base: Objective.Objective = Objective.DEFAULT,
574
+ { weight = 30, rules = Rules.RULES }: { weight?: number; rules?: readonly Rules.DiagramRule[] } = {},
575
+ ): Objective.Objective => ({
576
+ constraints: base.constraints,
577
+ costs: [
578
+ ...base.costs,
579
+ {
580
+ id: 'appeal',
581
+ description: 'How far the drawing falls short of the rule library, weighted by evidence.',
582
+ weight,
583
+ measure: ({ objects }) => 1 - overall(measure(objects, rules)),
584
+ },
585
+ ],
586
+ });
@@ -0,0 +1,94 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as AiError from 'effect/ai/AiError';
6
+ import * as DecisionModel from 'effect/ai/DecisionModel';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Layer from 'effect/Layer';
9
+ import { describe, test } from 'vitest';
10
+
11
+ import { RULES, contentOf, judge } from './architecture.ts';
12
+ import { parse } from './mermaid.ts';
13
+ import { evaluate, overall } from './score.ts';
14
+
15
+ const SOURCE = `
16
+ flowchart TB
17
+ subgraph app [App]
18
+ Ui[UI]
19
+ end
20
+ subgraph core [Core]
21
+ Db[Database]
22
+ Store[Storage]
23
+ end
24
+ Ui --> Db
25
+ Db -->|writes| Store
26
+ `;
27
+
28
+ /** A model that answers every question with `probability`, counting the calls it receives. */
29
+ const stubModel = (probability: number, calls: { count: number; questions: string[] }) =>
30
+ Layer.effect(
31
+ DecisionModel.DecisionModel,
32
+ DecisionModel.make({
33
+ decide: ({ decisions }) =>
34
+ Effect.sync(() => {
35
+ calls.count += 1;
36
+ calls.questions = Object.keys(decisions);
37
+ return {
38
+ answers: Object.fromEntries(
39
+ Object.keys(decisions).map((key) => [key, { _tag: 'Probability' as const, probability }]),
40
+ ),
41
+ usage: { inputTokens: undefined, outputTokens: undefined },
42
+ };
43
+ }),
44
+ }),
45
+ );
46
+
47
+ describe('architecture', () => {
48
+ test('content keeps groups, nodes and edges, and a layout only when given one', ({ expect }) => {
49
+ const content = contentOf(parse(SOURCE), { title: 'Toy' });
50
+ expect(content.title).toBe('Toy');
51
+ expect(content.groups.map(({ id }) => id)).toEqual(['app', 'core']);
52
+ expect(content.nodes.find(({ id }) => id === 'Db')?.group).toBe('core');
53
+ expect(content.edges).toContainEqual({ from: 'Db', to: 'Store', label: 'writes' });
54
+ expect(content.layout).toBeUndefined();
55
+ expect(contentOf(parse('flowchart TB\n B ..|> A\n B --> C')).edges).toEqual([
56
+ { from: 'B', to: 'A', kind: 'implements' },
57
+ { from: 'B', to: 'C' },
58
+ ]);
59
+ expect(contentOf(parse(SOURCE), { layout: 'row 1 (top): "Db"' }).layout).toBe('row 1 (top): "Db"');
60
+ });
61
+
62
+ test('every rule is answered by one batched call', ({ expect }) => {
63
+ const calls = { count: 0, questions: [] as string[] };
64
+ const scores = Effect.runSync(
65
+ evaluate([judge()], { content: contentOf(parse(SOURCE)) }).pipe(Effect.provide(stubModel(0.8, calls))),
66
+ );
67
+ expect(calls.count).toBe(1);
68
+ expect(calls.questions).toEqual(RULES.map(({ key }) => key));
69
+ expect(scores.map(({ id }) => id)).toEqual(RULES.map(({ id }) => id));
70
+ expect(scores.every(({ kind, score }) => kind === 'architecture' && score === 0.8)).toBe(true);
71
+ expect(overall(scores)).toBeCloseTo(0.8);
72
+ });
73
+
74
+ test('a failed call marks every rule as an error, not as a bad diagram', ({ expect }) => {
75
+ const failing = Layer.effect(
76
+ DecisionModel.DecisionModel,
77
+ DecisionModel.make({
78
+ decide: () =>
79
+ Effect.fail(
80
+ AiError.make({
81
+ module: 'test',
82
+ method: 'decide',
83
+ reason: new AiError.InternalProviderError({ description: 'down' }),
84
+ }),
85
+ ),
86
+ }),
87
+ );
88
+ const scores = Effect.runSync(
89
+ evaluate([judge()], { content: contentOf(parse(SOURCE)) }).pipe(Effect.provide(failing)),
90
+ );
91
+ expect(scores.every(({ error }) => error !== undefined)).toBe(true);
92
+ expect(overall([...scores, { kind: 'cost', score: 0.5 }])).toBe(0.5);
93
+ });
94
+ });