visualproof 0.2.0 → 0.4.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 (96) hide show
  1. package/README.md +88 -33
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  20. package/dist/assets/webfonts/metrics.json +1 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/mcp/visualproof-mcp.mjs +3 -1
  33. package/dist/server/agent.js +94 -91
  34. package/dist/server/agentdocs.js +1 -1
  35. package/dist/server/artcmd.js +335 -0
  36. package/dist/server/children.js +85 -0
  37. package/dist/server/doctor.js +105 -42
  38. package/dist/server/events.js +10 -0
  39. package/dist/server/export.js +11 -4
  40. package/dist/server/extract.js +3 -2
  41. package/dist/server/figures.js +3 -2
  42. package/dist/server/film.js +380 -0
  43. package/dist/server/history.js +11 -1
  44. package/dist/server/hover.js +8 -5
  45. package/dist/server/live.js +72 -9
  46. package/dist/server/main.js +352 -23
  47. package/dist/server/mcptools.js +4 -1
  48. package/dist/server/media.js +114 -0
  49. package/dist/server/pipeline.js +21 -10
  50. package/dist/server/poppler.js +3 -2
  51. package/dist/server/readonlygate.js +4 -4
  52. package/dist/server/review.js +4 -1
  53. package/dist/server/reviewapi.js +35 -1
  54. package/dist/server/snapshot.js +10 -1
  55. package/dist/server/sourcestore.js +3 -2
  56. package/dist/server/status.js +3 -2
  57. package/dist/server/supervise.js +219 -0
  58. package/dist/server/updates.js +951 -0
  59. package/dist/server/usage.js +3 -1
  60. package/dist/server/version.js +183 -0
  61. package/dist/server/view.js +0 -7
  62. package/dist/server/views.js +24 -0
  63. package/dist/shared/artfonts.js +81 -0
  64. package/dist/shared/artrecipe.js +316 -0
  65. package/dist/shared/collab.js +7 -2
  66. package/dist/shared/declfind.js +95 -0
  67. package/dist/shared/lostfound.js +1 -0
  68. package/dist/shared/pastgraph.js +6 -6
  69. package/dist/shared/review.js +62 -5
  70. package/dist/shared/timeline.js +5 -0
  71. package/dist/web/bundle/app.js +3006 -413
  72. package/dist/web/bundle/edges.js +1 -1
  73. package/dist/web/index.html +13 -1
  74. package/dist/web/src/agentmark.js +14 -0
  75. package/dist/web/src/artwork.js +1000 -0
  76. package/dist/web/src/bundles.js +151 -0
  77. package/dist/web/src/canvas.js +3716 -0
  78. package/dist/web/src/drag.js +61 -0
  79. package/dist/web/src/edgequeue.js +204 -0
  80. package/dist/web/src/gl/gl.js +123 -0
  81. package/dist/web/src/gl/renderer.js +451 -0
  82. package/dist/web/src/halo.js +95 -0
  83. package/dist/web/src/highlight.js +108 -0
  84. package/dist/web/src/overview.js +393 -0
  85. package/dist/web/src/reviewqueue.js +250 -0
  86. package/dist/web/src/selection.js +272 -0
  87. package/dist/web/src/status.js +80 -0
  88. package/dist/web/src/visitors.js +318 -0
  89. package/dist/web/src/visits.js +174 -0
  90. package/dist/web/style.css +196 -1
  91. package/docs/agent-api/README.md +1 -1
  92. package/docs/agent-api/export.md +41 -2
  93. package/docs/agent-api/history.md +5 -3
  94. package/docs/agent-api/mcp.md +1 -1
  95. package/docs/agent-api/reading.md +2 -1
  96. package/package.json +5 -1
@@ -93,8 +93,11 @@ export function artNameId(kind, s) {
93
93
  * presence carries, in bytes of their JSON: a heartbeat is at most 16 KB (server/main.ts), and the
94
94
  * rest of where a reader is has room beside them. Names by selection are kept as what differs from
95
95
  * As shown, so that a few names hidden or added are a few names carried. */
96
- export const ART_NAMES_BYTES = 4500;
96
+ export const ART_NAMES_BYTES = 4000;
97
97
  export const ART_TARGETS_BYTES = 2500;
98
+ /** the names a recipe's rule chose (ArtworkLook `ruledNames`): a rule names a few clusters. The four
99
+ * lists full, with the longest title and line, leave a heartbeat room (test/sharesnap.test.ts) */
100
+ export const ART_RULED_BYTES = 1000;
98
101
  /** What `x` adds to a list's JSON: itself, quoted, and a comma. */
99
102
  const jsonBytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length + 1;
100
103
  /** A list of names as given, checked: strings, each once, none empty or longer than a name is (1,000
@@ -148,12 +151,14 @@ export function cleanArtwork(v) {
148
151
  // say them, and the key marks as the targets drew them before (with their stars, unless As any)
149
152
  const namesFrom = o.namesFrom === "none" ? "none" : "board";
150
153
  const hiddenNames = cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId), shownNames = cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId);
154
+ // the names a recipe's rule chose, said since later that night: none where a page does not say them
155
+ const ruledNames = cleanNames(o.ruledNames, ART_RULED_BYTES, isArtNameId);
151
156
  const shownTargets = cleanNames(o.shownTargets, ART_TARGETS_BYTES);
152
157
  const keys = typeof o.keys === "boolean" ? o.keys : targets !== "plain";
153
158
  // the style and the title, said since that night: as the board, the project's own; the foot's
154
159
  // size, said since later that night: the first
155
160
  const style = one(ART_STYLES, o.style) ?? "board";
156
- return { w, h, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, edges, notes: o.notes, targets, shownTargets, keys, selection: o.selection, source: o.source === true, style, title: cleanTitle(o.title), subtitle: cleanTitle(o.subtitle), titleSize: cleanTitleSize(o.titleSize), footSize: cleanFootSize(o.footSize), border: o.border === true, region: [...r] };
161
+ return { w, h, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, ruledNames, edges, notes: o.notes, targets, shownTargets, keys, selection: o.selection, source: o.source === true, style, title: cleanTitle(o.title), subtitle: cleanTitle(o.subtitle), titleSize: cleanTitleSize(o.titleSize), footSize: cleanFootSize(o.footSize), border: o.border === true, region: [...r] };
157
162
  }
158
163
  /** The legend's schemes (CanvasSpec, "Colour schemes"). */
159
164
  export const SCHEMES = ["kind", "status"];
@@ -0,0 +1,95 @@
1
+ // A declaration by the name someone gives (the agents' API, server/agent.ts; the artwork command,
2
+ // server/artcmd.ts): the graph's own name; else the declaration a name it folds is folded into — a
3
+ // declaration Lean generated from a command, or a constructor, recursor, field, equation lemma of
4
+ // one — else, where none is, the names likely meant.
5
+ import { search } from "./search.js";
6
+ /** the last components of names Lean generates beside a declaration (extractor/Extract.lean,
7
+ * `generatedSuffixes`, and the like) */
8
+ export const GENERATED_LAST = /^(?:rec|recOn|casesOn|noConfusion|noConfusionType|ctorElim|ctorElimType|below|brecOn|binductionOn|ibelow|ctorIdx|toCtorIdx|ofNat|ofNat_ctorIdx|sizeOf_spec|injEq|inj|eq_def|induct|mutual_induct|fun_cases|induct_unfolding|ext_iff|congr_simp|mk|elim|(?:eq|match|proof)_\d+|_.*)$/;
9
+ /** The edit distance between two short strings, or `most + 1` once it is more than `most`. */
10
+ export function editDistance(a, b, most) {
11
+ if (Math.abs(a.length - b.length) > most)
12
+ return most + 1;
13
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
14
+ for (let i = 1; i <= a.length; i++) {
15
+ const row = [i];
16
+ let best = i;
17
+ for (let j = 1; j <= b.length; j++) {
18
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
19
+ best = Math.min(best, row[j]);
20
+ }
21
+ if (best > most)
22
+ return most + 1;
23
+ prev = row;
24
+ }
25
+ return Math.min(prev[b.length], most + 1);
26
+ }
27
+ /** The declaration a name the graph has no node for is folded into: a declaration Lean generated
28
+ * from a command (ViewNode.also), or a constructor, recursor, field, equation lemma… of a
29
+ * declaration or of one so generated, which is a name one component below it. A name one
30
+ * component below a declaration that is a typo or two away from a declaration beside it is
31
+ * taken for that typo, not for something folded, unless its last component is one Lean
32
+ * generates. */
33
+ export function foldedAt(g, name) {
34
+ const direct = g.foldedInto.get(name);
35
+ if (direct !== undefined)
36
+ return direct;
37
+ const dot = name.lastIndexOf(".");
38
+ if (dot <= 0)
39
+ return undefined;
40
+ const head = name.slice(0, dot), last = name.slice(dot + 1);
41
+ const at = g.byName.get(head) ?? g.foldedInto.get(head);
42
+ if (at === undefined || !last)
43
+ return undefined;
44
+ if (!GENERATED_LAST.test(last)) {
45
+ const most = last.length < 5 ? 1 : 2;
46
+ const near = (n) => n.length > head.length + 1 && n.startsWith(head + ".") && !n.includes(".", head.length + 1) && editDistance(n.slice(head.length + 1).toLowerCase(), last.toLowerCase(), most) <= most;
47
+ for (const n of g.nodes)
48
+ if (near(n.name))
49
+ return undefined;
50
+ for (const n of g.foldedInto.keys())
51
+ if (near(n))
52
+ return undefined;
53
+ }
54
+ return at;
55
+ }
56
+ /** Up to five names like `name`: the same name in another case; then the same last component,
57
+ * or one a typo or two away, in the same namespace first; then what the search finds. */
58
+ export function guessNames(g, index, name) {
59
+ const dot = name.lastIndexOf(".");
60
+ const last = name.slice(dot + 1).toLowerCase();
61
+ const ns = dot < 0 ? "" : name.slice(0, dot);
62
+ const scored = new Map();
63
+ const offer = (n, score) => {
64
+ if ((scored.get(n) ?? Infinity) > score)
65
+ scored.set(n, score);
66
+ };
67
+ const lower = name.toLowerCase();
68
+ const most = last.length < 5 ? 1 : 2;
69
+ // the graph's declarations, and the names Lean generated from a command that they fold
70
+ for (const name of [...g.nodes.map((n) => n.name), ...g.foldedInto.keys()]) {
71
+ if (name.toLowerCase() === lower)
72
+ offer(name, -1);
73
+ const k = name.lastIndexOf(".");
74
+ const d = editDistance(name.slice(k + 1).toLowerCase(), last, most);
75
+ if (d <= most)
76
+ offer(name, 2 * d + (name.slice(0, Math.max(0, k)) === ns ? 0 : 1));
77
+ }
78
+ let rank = 0;
79
+ for (const q of [name.slice(dot + 1), name.replace(/\./g, " ")]) {
80
+ if (q.trim().length < 2)
81
+ continue;
82
+ const f = search(index, q);
83
+ for (const h of [...f.hits.name, ...f.hits.statement].slice(0, 10))
84
+ offer(g.nodes[h.i].name, 10 + rank++);
85
+ }
86
+ return [...scored]
87
+ .sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
88
+ .slice(0, 5)
89
+ .map(([n]) => n);
90
+ }
91
+ /** The declaration `name` names, its own or the one it is folded into; undefined where none is. */
92
+ export function declAt(g, name) {
93
+ const n = name.trim();
94
+ return g.byName.get(n) ?? foldedAt(g, n);
95
+ }
@@ -64,6 +64,7 @@ export function lostEntries(d, views, has, known = (n) => n.includes("."), pinWh
64
64
  kind: "opinion",
65
65
  name,
66
66
  ...who(o),
67
+ ...(o.recordedBy ? { recordedBy: o.recordedBy } : {}),
67
68
  verdict: o.verdict,
68
69
  ...(o.comment ? { comment: o.comment } : {}),
69
70
  ...(was.length ? { aliases: was.map((n) => lostKey.opinion(name, n, o.at)) } : {}),
@@ -21,12 +21,12 @@ export const PAST_GHOST = 1;
21
21
  export const PAST_CHANGED = 4;
22
22
  /** renamed since: shown under its name then */
23
23
  export const PAST_RENAMED = 8;
24
- /** The git source of a past build: the project's source, the build's own commit; none where the
25
- * build's commit is not known, or only guessed. */
26
- function gitThen(now, commit) {
24
+ /** The git commit of a past build; none where it is not known, or only guessed. Its source is the
25
+ * project's, which the page has now (web/src/main.ts, GET /api/git). */
26
+ function gitThen(commit) {
27
27
  if (!commit || commit.guessed)
28
28
  return { git: undefined };
29
- return { git: { source: now?.source ?? null, commit: commit.hash, branch: commit.branch, ...(commit.dirty ? { dirty: true } : {}) } };
29
+ return { git: { source: null, commit: commit.hash, branch: commit.branch, ...(commit.dirty ? { dirty: true } : {}) } };
30
30
  }
31
31
  /** The past graph of snapshot `snap` (its declarations and its edges, as /graph's parts give them),
32
32
  * marked by `diff` (from it to the latest), with the project's description of `now`. */
@@ -40,8 +40,8 @@ export function pastGraph(snap, edges, now, diff) {
40
40
  const meta = {
41
41
  schema: 1,
42
42
  layoutVersion: now.meta.layoutVersion,
43
- // the commit of that build, where it is known; the source is the project's now
44
- project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(now.meta.project.git, snap.snapshot.commit) },
43
+ // the commit of that build, where it is known
44
+ project: { ...now.meta.project, extractedAt: snap.snapshot.at, ...gitThen(snap.snapshot.commit) },
45
45
  modules: snap.modules.map((m) => ({ name: m.name, file: null, imports: m.imports })),
46
46
  nodes,
47
47
  projectCount: P,
@@ -40,6 +40,7 @@
40
40
  // Declarations are keyed by name, and a name can be anything Lean allows: `toString`,
41
41
  // `constructor`, even `__proto__`. Every object keyed by names here has no prototype (`byName`),
42
42
  // is read with Object.hasOwn, and is never spread or assigned into a plain object.
43
+ import { validName } from "./collab.js";
43
44
  import { applyLibrary, invalidLibrary, isLibraryOp, libraryEventOf, libraryFromJson, libraryNoChange, readLibraryEvent, renamePerson } from "./library.js";
44
45
  import { applyMove, moveIsIdle, readMove } from "./moves.js";
45
46
  export const DEFAULT_TRUSTED = ["Batteries", "Init", "Lean", "Mathlib", "Std"];
@@ -102,6 +103,19 @@ export function invalid(r) {
102
103
  return "agent";
103
104
  if (o.person !== undefined && (!validPersonId(o.person) || o.agent))
104
105
  return "person";
106
+ // an opinion recorded for someone else (ReviewSpec decision 8): a person's, for another person
107
+ if (o.for !== undefined || o.forPerson !== undefined) {
108
+ if (o.op !== "opinion")
109
+ return "only an opinion is recorded for someone else";
110
+ if (o.agent)
111
+ return "an agent records for no one";
112
+ if (typeof o.for !== "string" || !validName(o.for.trim()))
113
+ return "for";
114
+ if (o.forPerson !== undefined && !validPersonId(o.forPerson))
115
+ return "forPerson";
116
+ if (o.for.trim() === o.by.trim() || (o.forPerson !== undefined && o.forPerson === o.person))
117
+ return "an opinion of one's own is recorded as one's own";
118
+ }
105
119
  switch (o.op) {
106
120
  case "opinion":
107
121
  if (o.verdict !== "approve" && o.verdict !== "comment")
@@ -153,6 +167,12 @@ export function eventOf(d, r, toolchain, now) {
153
167
  switch (r.op) {
154
168
  case "opinion": {
155
169
  const e = { op: "opinion", name: r.name, by, ...person, verdict: r.verdict, at: now, toolchain };
170
+ // recorded for someone else (ReviewSpec decision 8)
171
+ if (r.for?.trim()) {
172
+ e.for = r.for.trim();
173
+ if (r.forPerson)
174
+ e.forPerson = r.forPerson;
175
+ }
156
176
  if (r.comment?.trim())
157
177
  e.comment = r.comment.trim();
158
178
  if (r.agent)
@@ -160,7 +180,7 @@ export function eventOf(d, r, toolchain, now) {
160
180
  // the text matters only to an opinion that can go stale, one bound to a version
161
181
  if (r.hash) {
162
182
  e.hash = r.hash;
163
- const was = (Object.hasOwn(d.opinions, r.name) ? d.opinions[r.name] : []).findLast((p) => author(p) === author(e));
183
+ const was = (Object.hasOwn(d.opinions, r.name) ? d.opinions[r.name] : []).findLast((p) => author(p) === voice(e));
164
184
  const same = was?.hash === r.hash ? judgedOn(d, r.name, was) : null;
165
185
  if (r.text !== undefined && !(same?.of === "source" && same.text === r.text))
166
186
  e.text = r.text;
@@ -212,8 +232,8 @@ export function applyEvent(d, e) {
212
232
  case "name":
213
233
  return;
214
234
  case "opinion": {
215
- const { op: _op, name, ...o } = e;
216
- (Object.hasOwn(d.opinions, name) ? d.opinions[name] : (d.opinions[name] = [])).push(o);
235
+ const { op: _op, name, for: forName, forPerson, ...o } = e;
236
+ (Object.hasOwn(d.opinions, name) ? d.opinions[name] : (d.opinions[name] = [])).push(forName === undefined ? o : recordedFor(d, o, forName, forPerson));
217
237
  return;
218
238
  }
219
239
  case "target":
@@ -256,15 +276,27 @@ function named(d, id, name) {
256
276
  if (p?.name === name)
257
277
  return;
258
278
  if (!p) {
259
- d.people[id] = { name, was: [] };
279
+ // first seen after opinions were recorded for them (decision 8): those show under this name,
280
+ // the names they were written under kept, so that a quote of one still finds it
281
+ const was = new Set();
282
+ for (const ops of Object.values(d.opinions))
283
+ for (const o of ops)
284
+ if (o.person === id && o.by !== name) {
285
+ was.add(o.by);
286
+ o.by = name;
287
+ }
288
+ d.people[id] = { name, was: [...was] };
260
289
  return;
261
290
  }
262
291
  p.was = [...p.was.filter((n) => n !== p.name && n !== name), p.name];
263
292
  p.name = name;
264
293
  for (const ops of Object.values(d.opinions))
265
- for (const o of ops)
294
+ for (const o of ops) {
266
295
  if (o.person === id)
267
296
  o.by = name;
297
+ if (o.recordedBy?.person === id)
298
+ o.recordedBy.by = name;
299
+ }
268
300
  for (const t of Object.values(d.targets))
269
301
  if (t.person === id)
270
302
  t.by = name;
@@ -351,6 +383,20 @@ export function readEvent(raw, agentLog, personLog = null) {
351
383
  }
352
384
  if (e.text !== undefined && e.statement !== undefined)
353
385
  return "a text and a statement";
386
+ // recorded for someone else (ReviewSpec decision 8): a person's line, for another person
387
+ if (o.for !== undefined || o.forPerson !== undefined) {
388
+ if (agentLog)
389
+ return "an agent's opinion recorded for someone else";
390
+ if (typeof o.for !== "string" || !validName(o.for))
391
+ return "for";
392
+ if (o.forPerson !== undefined && !validPersonId(o.forPerson))
393
+ return "forPerson";
394
+ if (o.for === by || (o.forPerson !== undefined && o.forPerson === personLog))
395
+ return "an opinion recorded for its own writer";
396
+ e.for = o.for;
397
+ if (o.forPerson !== undefined)
398
+ e.forPerson = o.forPerson;
399
+ }
354
400
  if (agentLog)
355
401
  e.agent = true;
356
402
  return e;
@@ -503,6 +549,17 @@ export function problemsText(ps) {
503
549
  function author(o) {
504
550
  return o.person ? `link ${o.person}` : (o.agent ? "agent " : "person ") + o.by;
505
551
  }
552
+ /** Whose an opinion line is: theirs it was recorded for (ReviewSpec decision 8), else its writer's. */
553
+ function voice(e) {
554
+ return e.for === undefined ? author(e) : author({ by: e.for, verdict: e.verdict, at: e.at, ...(e.forPerson ? { person: e.forPerson } : {}) });
555
+ }
556
+ /** An opinion recorded for someone else (ReviewSpec decision 8), as the review holds it: theirs —
557
+ * under their latest name where they are invited — saying who recorded it. */
558
+ function recordedFor(d, o, forName, forPerson) {
559
+ const { by, person, ...rest } = o;
560
+ const name = forPerson && Object.hasOwn(d.people, forPerson) ? d.people[forPerson].name : forName;
561
+ return { ...rest, by: name, ...(forPerson ? { person: forPerson } : {}), recordedBy: { by, ...(person ? { person } : {}) } };
562
+ }
506
563
  /** Each author's latest opinion on a declaration, people's or agents': a new one replaces their
507
564
  * earlier one. */
508
565
  function latest(d, name, agents) {
@@ -15,6 +15,11 @@ import { SITE_BODY, SITE_STMT } from "./graph.js";
15
15
  import { byName } from "./review.js";
16
16
  /** at most this many names are listed in one event; the counts stay exact */
17
17
  export const NAMES_LISTED = 100;
18
+ /** An event as a read-only reader and an agent are told it (UpdateSpec decision 11): a version set
19
+ * aside without why (what this machine and its servers said of it). */
20
+ export function withoutReasons(e) {
21
+ return e.kind === "tool" && e.setAside ? { ...e, setAside: { version: e.setAside.version } } : e;
22
+ }
18
23
  /** The graph as the TCB needs it: names, content hashes, statement and body edges. */
19
24
  export class TcbIndex {
20
25
  names;