@kanzo-tech/graph 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -48,9 +48,23 @@ Morton-ordered `dense_id`, which spreads the marks over the window instead of dr
48
48
  it. So a view of everything is still a few thousand marks, and they are still everywhere.
49
49
 
50
50
  `openCorpus` — on `@kanzo-tech/graph/duckdb`, because that is the half that needs Mosaic and
51
- fossil's reader — takes where a corpus is and hands back both halves: the source the canvas draws
52
- from, and the relations registered under their own names for the charts, the crossfilter and the
53
- verbs. It takes no column names and no type index; those come off the manifest or they do not come.
51
+ fossil's reader — takes the corpus the host opened with fossil's `open` and the page's `engine()`,
52
+ and hands back both halves: the source the canvas draws from, and the names fossil's verbs query
53
+ the relations by, for the charts and the crossfilter.
54
+
55
+ ```ts
56
+ import { open } from "@fossil-lang/corpus";
57
+ import { engine } from "@kanzo-tech/mosaic";
58
+ import { openCorpus } from "@kanzo-tech/graph/duckdb";
59
+
60
+ const e = await engine();
61
+ const corpus = await open("/corpus/people", { query: e.query });
62
+ const { source, nodes, edges } = await openCorpus({ corpus, engine: e });
63
+ ```
64
+
65
+ Opening is the host's: a host whose files sit behind a signature opens the corpus under a name,
66
+ `` open(`jobs/${id}`, { engine: e, host }) ``, and hands over the same `corpus`. Closing it is the
67
+ host's too. It takes no column names and no type index; those come off the manifest or they do not come.
54
68
  Under `limit` it is asked once for everything and never again, so a corpus that fits pays for
55
69
  nothing.
56
70
 
@@ -1,5 +1,5 @@
1
- import { GapReason, OpenOptions } from '@fossil-lang/corpus';
2
- import { Coordinator, Selection } from '@kanzo-tech/mosaic';
1
+ import { Corpus, GapReason } from '@fossil-lang/corpus';
2
+ import { Engine, Selection } from '@kanzo-tech/mosaic';
3
3
  import { BoundedSource } from './bounded';
4
4
  import { VertexId } from './resident';
5
5
  /**
@@ -52,10 +52,12 @@ export interface DuckSource extends BoundedSource {
52
52
  * argument; the copied `chunk_size` carries the evidence, because it went stale and read
53
53
  * a fraction of a corpus in silence for as long as it did.
54
54
  *
55
- * The consumer knows one thing: **where the corpus is.**
55
+ * The consumer holds two things: **the corpus fossil opened, and the page's engine.**
56
56
  *
57
57
  * ```ts
58
- * const { source } = await openCorpus({ coordinator, dest: "/bench/1000000" });
58
+ * const e = await engine();
59
+ * const corpus = await open("/bench/1000000", { query: e.query });
60
+ * const { source } = await openCorpus({ corpus, engine: e });
59
61
  * ```
60
62
  *
61
63
  * **And this side no longer knows the conventions either, which is the change.** It used to remove
@@ -79,24 +81,27 @@ export interface DuckSource extends BoundedSource {
79
81
  * name reaching this door is a name the manifest should have carried.
80
82
  */
81
83
  export interface OpenCorpusOptions {
82
- coordinator: Coordinator;
83
84
  /**
84
- * The crossfilter this graph draws inside — the same `Selection` the page's charts filter by.
85
+ * The corpus, as fossil's `open` answered it — **opened by the host, and once.**
85
86
  *
86
- * Given, the predicate rides in the slice query and the canvas draws what survives.
87
+ * This door used to open one itself from a `dest`, a `readText` and a `wasm`, which made it the
88
+ * third open of the same corpus on a keasy discover visit: the host had already opened it to
89
+ * query it, and opened it again only so that this could. Opening is the host's, because what it
90
+ * takes is the host's — where the corpus is, and what signs it. Drawing takes the result.
87
91
  */
88
- filterBy?: Selection;
89
- /** Where the corpus lives, without a trailing slash — the directory holding `graph.graph.yml`. */
90
- dest: string;
92
+ corpus: Corpus;
91
93
  /**
92
- * The reader's `.wasm`, for a host with no bundler — and only for one.
94
+ * The page's engine — `engine()` from `@kanzo-tech/mosaic`. The canvas reads its tiles and
95
+ * publishes its clauses through the coordinator; the files are the ones the corpus already made
96
+ * readable, by the names its addressing gives them.
97
+ */
98
+ engine: Pick<Engine, "coordinator">;
99
+ /**
100
+ * The crossfilter this graph draws inside — the same `Selection` the page's charts filter by.
93
101
  *
94
- * Omit it anywhere a bundler runs: fossil's module resolves its own `.wasm` with
95
- * `new URL(…, import.meta.url)`, which Vite, webpack 5 and Turbopack all emit as an asset. Node is
96
- * the host that needs it, because its `fetch` rejects `file://` — a script or a test passes the
97
- * bytes or a `Response`. Passed straight through to fossil's `open`, spelled as fossil spells it.
102
+ * Given, the predicate rides in the slice query and the canvas draws what survives.
98
103
  */
99
- wasm?: OpenOptions["wasm"];
104
+ filterBy?: Selection;
100
105
  /**
101
106
  * Which vertex type to draw, when a corpus carries more than one.
102
107
  *
@@ -110,25 +115,6 @@ export interface OpenCorpusOptions {
110
115
  * asks for names when something has to be *named* rather than painted.
111
116
  */
112
117
  subjects?: boolean;
113
- /**
114
- * How a manifest is read, when a plain `fetch` of its URL is not how this host reads one.
115
- *
116
- * **The default is `manifest` below, and it is the whole of what this file knows about reading a
117
- * corpus** — right for a corpus served off an origin the page can already read, and wrong for a
118
- * host whose blobs sit behind a signature. There the URL fossil composes is correct and
119
- * unreadable, and nothing else on these options carries a credential.
120
- *
121
- * Passed straight through to fossil's `open`, which is where the capability belongs: `open`
122
- * composes every address and lends the reader to each one, so a host that signs a URL signs the
123
- * index and the per-type manifests the index names **without knowing which files those are**.
124
- * That is the point of lending a reader rather than handing over bytes — and it is what keeps a
125
- * signing host on this door, because the alternative it otherwise reaches for is composing the
126
- * addresses itself, which is the convention-copying `openCorpus` exists to end.
127
- *
128
- * It reads manifests and nothing else. The payload is read by the coordinator's own connector,
129
- * which is the host's already.
130
- */
131
- readText?: OpenOptions["readText"];
132
118
  }
133
119
  /**
134
120
  * An opened corpus: the half that **draws** and the half that **answers**.
@@ -137,11 +123,7 @@ export interface OpenCorpusOptions {
137
123
  * address — a handful of files per camera move, chosen from the footer's boxes, with no query. A
138
124
  * chart, a crossfilter clause or a verb reads the *relation*: every row, by column name, in SQL.
139
125
  * Hiding the URLs behind `source` is right for the first and leaves the second with nothing to
140
- * query, so opening a corpus registers views for it.
141
- *
142
- * This is the other side's own shape. `fossil-mcp` describes itself as opening a dataset,
143
- * *registering views over the Parquet the corpus already holds*, and dispatching a verb — the same
144
- * two halves, named the same way, one call apart.
126
+ * query, so this hands on the names fossil's verbs already query the corpus by.
145
127
  */
146
128
  export interface OpenedCorpus {
147
129
  /**
@@ -154,7 +136,7 @@ export interface OpenedCorpus {
154
136
  */
155
137
  source: DuckSource;
156
138
  /**
157
- * The vertex relation, registered and ready to query by name.
139
+ * The vertex relation, as fossil registered it — qualified by the corpus's catalog.
158
140
  *
159
141
  * Every column the manifest declares, including the corpus' own properties — so a clause a chart
160
142
  * publishes over `kind` or `region` lands here with no translation, which is what makes one
@@ -162,7 +144,7 @@ export interface OpenedCorpus {
162
144
  */
163
145
  nodes: string;
164
146
  /**
165
- * The source-ordered edge relations this canvas draws, registered one view per relation.
147
+ * The source-ordered edge relations this canvas draws, one fossil relation each.
166
148
  *
167
149
  * **A list, because a corpus declares a list.** This was one name, picked with `.find` over the
168
150
  * relations whose source is this type — so a corpus declaring two edge labels registered the
@@ -186,12 +168,12 @@ export interface OpenedCorpus {
186
168
  */
187
169
  undrawn: readonly UndrawnRelation[];
188
170
  }
189
- /** One edge relation of the drawn type, and the view its source-ordered adjacency is registered under. */
171
+ /** One edge relation of the drawn type, and the relation fossil registered its adjacency as. */
190
172
  export interface EdgeRelation {
191
173
  readonly edgeType: string;
192
174
  readonly srcType: string;
193
175
  readonly dstType: string;
194
- /** The registered view name — `corpus_{src}_{edge}_{dst}`. */
176
+ /** Fossil's relation, qualified by the corpus's catalog — its `CorpusRelation.sql`. */
195
177
  readonly view: string;
196
178
  }
197
179
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"duck-source.d.ts","sourceRoot":"","sources":["../src/duck-source.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAe,SAAS,EAAE,WAAW,EAAqB,MAAM,qBAAqB,CAAC;AAElG,OAAO,KAAK,EAAE,WAAW,EAAc,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC7E,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,WAAW,CAAC;AAE9E,OAAO,EAA6B,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtE;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,UAAW,SAAQ,aAAa;IAC/C;;;;;;;;OAQG;IACH,OAAO,CAAC,QAAQ,EAAE,SAAS,QAAQ,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;CACrD;AAojBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,WAAW,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,kGAAkG;IAClG,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,EAAE,WAAW,CAAC,UAAU,CAAC,CAAC;CACpC;AAyCD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;;OAOG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;;;OAcG;IACH,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B;;;;;;OAMG;IACH,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;CACrC;AAED,0GAA0G;AAC1G,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;CAC5B;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAkclF"}
1
+ {"version":3,"file":"duck-source.d.ts","sourceRoot":"","sources":["../src/duck-source.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,MAAM,EAGN,SAAS,EAEV,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAe,MAAM,EAAc,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACrF,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,WAAW,CAAC;AAE9E,OAAO,EAA6B,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtE;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,UAAW,SAAQ,aAAa;IAC/C;;;;;;;;OAQG;IACH,OAAO,CAAC,QAAQ,EAAE,SAAS,QAAQ,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;CACrD;AAojBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACpC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAkBD;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;;OAOG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;;;OAcG;IACH,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B;;;;;;OAMG;IACH,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;CACrC;AAED,gGAAgG;AAChG,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uFAAuF;IACvF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;CAC5B;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CA8SlF"}
@@ -1,77 +1,77 @@
1
1
  "use client";
2
- import { open as ue, PAYLOAD_COORDINATES as U, PAYLOAD_IDENTITY as de, PAYLOAD_ADDRESS as Ee } from "@fossil-lang/corpus";
3
- import { numbers as I, clausePoints as pe, fillColumn as b, column as ye } from "@kanzo-tech/mosaic";
4
- import { SliceRead as k } from "./slice-client.js";
5
- import { typeOf as me, denseOf as X, vertexId as Ae } from "./resident.js";
6
- const Z = 0;
7
- function $e(e, t) {
2
+ import { PAYLOAD_COORDINATES as M, PAYLOAD_IDENTITY as te, PAYLOAD_ADDRESS as ne } from "@fossil-lang/corpus";
3
+ import { numbers as b, clausePoints as se, fillColumn as x, column as oe } from "@kanzo-tech/mosaic";
4
+ import { SliceRead as F } from "./slice-client.js";
5
+ import { typeOf as ae, denseOf as G, vertexId as ie } from "./resident.js";
6
+ const q = 0;
7
+ function re(e, t) {
8
8
  return {
9
- points: new k(e, t),
10
- links: new k(e, t),
11
- meta: new k(e)
9
+ points: new F(e, t),
10
+ links: new F(e, t),
11
+ meta: new F(e)
12
12
  };
13
13
  }
14
- function Se(e) {
14
+ function ce(e) {
15
15
  let t = Promise.resolve();
16
- return (s) => {
17
- const a = t.then(() => e.ask(() => s));
18
- return t = a.catch(() => {
19
- }), a;
16
+ return (n) => {
17
+ const o = t.then(() => e.ask(() => n));
18
+ return t = o.catch(() => {
19
+ }), o;
20
20
  };
21
21
  }
22
- function q(e) {
22
+ function z(e) {
23
23
  if (e == null) return "";
24
- const s = (Array.isArray(e) ? e : [e]).filter((a) => a != null).map((a) => String(a));
25
- return s.length > 0 ? s.map((a) => `(${a})`).join(" AND ") : "";
24
+ const n = (Array.isArray(e) ? e : [e]).filter((o) => o != null).map((o) => String(o));
25
+ return n.length > 0 ? n.map((o) => `(${o})`).join(" AND ") : "";
26
26
  }
27
- function K(e, t) {
27
+ function P(e, t) {
28
28
  return e ? t ? `(${e}) AND (${t})` : e : t || "TRUE";
29
29
  }
30
- function Te(e, t) {
31
- const a = [
30
+ function le(e, t) {
31
+ const o = [
32
32
  [e.x, t.xMin, ">="],
33
33
  [e.x, t.xMax, "<="],
34
34
  [e.y, t.yMin, ">="],
35
35
  [e.y, t.yMax, "<="]
36
- ].filter(([, o]) => Number.isFinite(o)).map(([o, r, l]) => `${o} ${l} ${r}`);
37
- return a.length > 0 ? a.join(" AND ") : "TRUE";
36
+ ].filter(([, i]) => Number.isFinite(i)).map(([i, c, r]) => `${i} ${r} ${c}`);
37
+ return o.length > 0 ? o.join(" AND ") : "TRUE";
38
38
  }
39
- const Ne = (e) => `greatest(1, CAST(ceil(matched / ${e}.0) AS BIGINT))`;
40
- function V(e, t, s, a, o = !0) {
41
- const r = t.size ? `, ${t.size} AS size` : "", l = t.subject ? `, ${t.subject} AS subject` : "", p = t.category ? "(dense_rank() OVER (ORDER BY cat) - 1)::INTEGER AS category" : "0::INTEGER AS category";
39
+ const ue = (e) => `greatest(1, CAST(ceil(matched / ${e}.0) AS BIGINT))`;
40
+ function W(e, t, n, o, i = !0) {
41
+ const c = t.size ? `, ${t.size} AS size` : "", r = t.subject ? `, ${t.subject} AS subject` : "", u = t.category ? "(dense_rank() OVER (ORDER BY cat) - 1)::INTEGER AS category" : "0::INTEGER AS category";
42
42
  return `WITH pool AS (
43
- SELECT ${t.id} AS id, ${t.x} AS x, ${t.y} AS y${r}${l}${t.category ? `, ${t.category} AS cat` : ""},
43
+ SELECT ${t.id} AS id, ${t.x} AS x, ${t.y} AS y${c}${r}${t.category ? `, ${t.category} AS cat` : ""},
44
44
  count(*) OVER () AS matched
45
45
  FROM ${e}
46
- WHERE ${s}
46
+ WHERE ${n}
47
47
  ), vis AS (
48
- SELECT id, x, y${t.size ? ", size" : ""}${t.subject ? ", subject" : ""}${o ? ", matched" : ""},
49
- ${p},
48
+ SELECT id, x, y${t.size ? ", size" : ""}${t.subject ? ", subject" : ""}${i ? ", matched" : ""},
49
+ ${u},
50
50
  (row_number() OVER (ORDER BY id) - 1)::INTEGER AS local
51
51
  FROM pool
52
- WHERE id % ${Ne(a)} = 0
53
- LIMIT ${a}
52
+ WHERE id % ${ue(o)} = 0
53
+ LIMIT ${o}
54
54
  )`;
55
55
  }
56
- function fe(e, t, s, a) {
57
- if (s === void 0 || !Number.isFinite(s) || s <= 0) return "";
58
- const o = a * s;
59
- return `(${e}.x - ${t}.x) * (${e}.x - ${t}.x) + (${e}.y - ${t}.y) * (${e}.y - ${t}.y) >= ${o * o}`;
56
+ function de(e, t, n, o) {
57
+ if (n === void 0 || !Number.isFinite(n) || n <= 0) return "";
58
+ const i = o * n;
59
+ return `(${e}.x - ${t}.x) * (${e}.x - ${t}.x) + (${e}.y - ${t}.y) * (${e}.y - ${t}.y) >= ${i * i}`;
60
60
  }
61
- function Re(e, t, s, a, o, r, l) {
62
- const p = K(`NOT (${a})`, o), d = fe("a", "b", r, l);
61
+ function Ee(e, t, n, o, i, c, r) {
62
+ const u = P(`NOT (${o})`, i), E = de("a", "b", c, r);
63
63
  return `, out AS (
64
- SELECT ${s.id} AS id, ${s.x} AS x, ${s.y} AS y FROM ${e} WHERE ${p}
64
+ SELECT ${n.id} AS id, ${n.x} AS x, ${n.y} AS y FROM ${e} WHERE ${u}
65
65
  ), reach AS (
66
66
  SELECT id, x, y FROM vis UNION ALL SELECT id, x, y FROM out
67
67
  ), span AS (
68
68
  SELECT sv.local AS src, tv.local AS dst, a.id AS src_id, b.id AS dst_id
69
69
  FROM ${t} e
70
- JOIN reach a ON e.${s.source} = a.id
71
- JOIN reach b ON e.${s.target} = b.id
70
+ JOIN reach a ON e.${n.source} = a.id
71
+ JOIN reach b ON e.${n.target} = b.id
72
72
  LEFT JOIN vis sv ON sv.id = a.id
73
73
  LEFT JOIN vis tv ON tv.id = b.id
74
- WHERE (sv.id IS NOT NULL OR tv.id IS NOT NULL)${d ? ` AND ${d}` : ""}
74
+ WHERE (sv.id IS NOT NULL OR tv.id IS NOT NULL)${E ? ` AND ${E}` : ""}
75
75
  ), anchor AS (
76
76
  SELECT o.id, o.x, o.y,
77
77
  ((SELECT count(*) FROM vis) + row_number() OVER (ORDER BY o.id) - 1)::INTEGER AS local
@@ -80,8 +80,8 @@ function Re(e, t, s, a, o, r, l) {
80
80
  UNION SELECT dst_id FROM span WHERE dst IS NULL)
81
81
  )`;
82
82
  }
83
- function Oe(e, t, s, a, o, r, l, p) {
84
- const d = Te(s, a), O = (r ?? []).filter((u) => me(u) === Z).map(X), w = O.length > 0 ? ` OR ${s.id} IN (${O.join(",")})` : "", m = `(${d})${w}`, y = (u) => K(m, q(u)), T = s.size ? ", size" : "", h = s.subject ? ", subject" : "", N = (u) => Re(e, t, s, m, q(u), l, p);
83
+ function ye(e, t, n, o, i, c, r, u) {
84
+ const E = le(n, o), S = (c ?? []).filter((l) => ae(l) === q).map(G), y = S.length > 0 ? ` OR ${n.id} IN (${S.join(",")})` : "", m = `(${E})${y}`, $ = (l) => P(m, z(l)), T = n.size ? ", size" : "", f = n.subject ? ", subject" : "", A = (l) => Ee(e, t, n, m, z(l), r, u);
85
85
  return {
86
86
  /**
87
87
  * The marks, then the anchors, in one answer — because they are one buffer.
@@ -90,10 +90,10 @@ function Oe(e, t, s, a, o, r, l, p) {
90
90
  * and continues past it over the anchors, so ordering by it puts every mark before every anchor
91
91
  * and makes `marks` a prefix length. `matched` is then still read off row zero.
92
92
  */
93
- points: (u) => `${V(e, s, y(u), o)}${N(u)}
94
- SELECT local, id, x, y, category, matched, TRUE AS mark${T}${h} FROM vis
93
+ points: (l) => `${W(e, n, $(l), i)}${A(l)}
94
+ SELECT local, id, x, y, category, matched, TRUE AS mark${T}${f} FROM vis
95
95
  UNION ALL
96
- SELECT local, id, x, y, 0::INTEGER, NULL::BIGINT, FALSE AS mark${s.size ? ", CAST(NULL AS DOUBLE)" : ""}${s.subject ? ", CAST(NULL AS VARCHAR)" : ""} FROM anchor
96
+ SELECT local, id, x, y, 0::INTEGER, NULL::BIGINT, FALSE AS mark${n.size ? ", CAST(NULL AS DOUBLE)" : ""}${n.subject ? ", CAST(NULL AS VARCHAR)" : ""} FROM anchor
97
97
  ORDER BY local`,
98
98
  /**
99
99
  * One end drawn and both ends positioned.
@@ -104,12 +104,12 @@ function Oe(e, t, s, a, o, r, l, p) {
104
104
  * now, on the same span, which is where it has to sit once the join runs over the held rows
105
105
  * rather than over the visible ones.
106
106
  */
107
- links: (u) => `${V(e, s, y(u), o, !1)}${N(u)}
107
+ links: (l) => `${W(e, n, $(l), i, !1)}${A(l)}
108
108
  SELECT coalesce(sp.src, sa.local) AS src, coalesce(sp.dst, da.local) AS dst
109
109
  FROM span sp
110
110
  LEFT JOIN anchor sa ON sa.id = sp.src_id
111
111
  LEFT JOIN anchor da ON da.id = sp.dst_id`,
112
- assemble: (u, f) => ({
112
+ assemble: (l, p) => ({
113
113
  /**
114
114
  * How many matched, separately from how many came back.
115
115
  *
@@ -118,17 +118,17 @@ function Oe(e, t, s, a, o, r, l, p) {
118
118
  * has been about. Read off the first row rather than asked for: `matched` is constant down the
119
119
  * column, and an empty answer has no row and no matches, which agree.
120
120
  */
121
- n: Number(I(u, "matched")[0] ?? 0),
122
- ...Le(u, f, s.size ? "size" : void 0, s.subject !== void 0)
121
+ n: Number(b(l, "matched")[0] ?? 0),
122
+ ...Ae(l, p, n.size ? "size" : void 0, n.subject !== void 0)
123
123
  })
124
124
  };
125
125
  }
126
- function he(e) {
127
- let t = null, s = null;
128
- const a = /* @__PURE__ */ new Map(), o = (r) => (l) => {
129
- if (!t || !s || (a.set(r, l), a.size < 2)) return;
130
- const p = a.get("points"), d = a.get("links");
131
- a.clear(), s(t.assemble(p, d));
126
+ function pe(e) {
127
+ let t = null, n = null;
128
+ const o = /* @__PURE__ */ new Map(), i = (c) => (r) => {
129
+ if (!t || !n || (o.set(c, r), o.size < 2)) return;
130
+ const u = o.get("points"), E = o.get("links");
131
+ o.clear(), n(t.assemble(u, E));
132
132
  };
133
133
  return {
134
134
  api: {
@@ -139,168 +139,119 @@ function he(e) {
139
139
  * has *already* re-run both reads with the new predicate by the time we hear about it. Asking
140
140
  * again would run the same two queries a second time to learn what is in hand.
141
141
  */
142
- watch(r) {
143
- return s = r, e.points.onAnswer = o("points"), e.links.onAnswer = o("links"), () => {
144
- s = null, a.clear(), t = null, e.points.release(), e.links.release(), e.meta.release();
142
+ watch(c) {
143
+ return n = c, e.points.onAnswer = i("points"), e.links.onAnswer = i("links"), () => {
144
+ n = null, o.clear(), t = null, e.points.release(), e.links.release(), e.meta.release();
145
145
  };
146
146
  }
147
147
  },
148
- async run(r) {
149
- t = r, a.clear();
150
- const [l, p] = await Promise.all([
151
- e.points.ask(r.points),
152
- e.links.ask(r.links)
148
+ async run(c) {
149
+ t = c, o.clear();
150
+ const [r, u] = await Promise.all([
151
+ e.points.ask(c.points),
152
+ e.links.ask(c.links)
153
153
  ]);
154
- return r.assemble(l, p);
154
+ return c.assemble(r, u);
155
155
  }
156
156
  };
157
157
  }
158
- function xe(e, t, s, a) {
158
+ function me(e, t, n, o) {
159
159
  t && t.update(
160
- pe([s], a == null ? void 0 : a.map((o) => [X(o)]), {
160
+ se([n], o == null ? void 0 : o.map((i) => [G(i)]), {
161
161
  source: e.points,
162
162
  clients: /* @__PURE__ */ new Set([e.points, e.links])
163
163
  })
164
164
  );
165
165
  }
166
- function Le(e, t, s, a = !1) {
167
- const o = J(e, "x"), r = new Float32Array(o * 2), l = b(e, "x", r, 0, 2);
168
- b(e, "y", r, 1, 2);
169
- const p = new Int32Array(o);
170
- b(e, "mark", p);
171
- let d = 0;
172
- for (; d < l && p[d] !== 0; ) d++;
173
- const O = new Float64Array(l);
174
- b(e, "id", O);
175
- const w = new BigUint64Array(l);
176
- for (let f = 0; f < l; f++) w[f] = Ae(Z, O[f]);
177
- const m = new Uint16Array(l);
178
- b(e, "category", m);
179
- const y = a ? ye(e, "subject") : void 0;
166
+ function Ae(e, t, n, o = !1) {
167
+ const i = Y(e, "x"), c = new Float32Array(i * 2), r = x(e, "x", c, 0, 2);
168
+ x(e, "y", c, 1, 2);
169
+ const u = new Int32Array(i);
170
+ x(e, "mark", u);
171
+ let E = 0;
172
+ for (; E < r && u[E] !== 0; ) E++;
173
+ const S = new Float64Array(r);
174
+ x(e, "id", S);
175
+ const y = new BigUint64Array(r);
176
+ for (let p = 0; p < r; p++) y[p] = ie(q, S[p]);
177
+ const m = new Uint16Array(r);
178
+ x(e, "category", m);
179
+ const $ = o ? oe(e, "subject") : void 0;
180
180
  let T;
181
- s && (T = new Float32Array(l), b(e, s, T));
182
- const h = J(t, "src"), N = new Float32Array(h * 2), u = b(t, "src", N, 0, 2);
183
- return b(t, "dst", N, 1, 2), {
184
- marks: d,
185
- vertices: w,
186
- subjects: y,
187
- positions: r.subarray(0, l * 2),
188
- links: N.subarray(0, u * 2),
181
+ n && (T = new Float32Array(r), x(e, n, T));
182
+ const f = Y(t, "src"), A = new Float32Array(f * 2), l = x(t, "src", A, 0, 2);
183
+ return x(t, "dst", A, 1, 2), {
184
+ marks: E,
185
+ vertices: y,
186
+ subjects: $,
187
+ positions: c.subarray(0, r * 2),
188
+ links: A.subarray(0, l * 2),
189
189
  categories: m,
190
190
  sizes: T
191
191
  };
192
192
  }
193
- function J(e, t) {
194
- var o;
195
- const s = e == null ? void 0 : e.numRows;
196
- if (typeof s == "number") return s;
197
- const a = (o = e == null ? void 0 : e.getChild) == null ? void 0 : o.call(e, t);
198
- return a ? a.length : Array.from(e).length;
193
+ function Y(e, t) {
194
+ var i;
195
+ const n = e == null ? void 0 : e.numRows;
196
+ if (typeof n == "number") return n;
197
+ const o = (i = e == null ? void 0 : e.getChild) == null ? void 0 : i.call(e, t);
198
+ return o ? o.length : Array.from(e).length;
199
199
  }
200
- async function ge(e) {
201
- const t = await fetch(e);
202
- if (!t.ok) throw new Error(`corpus: ${e} is not readable (${t.status})`);
203
- return t.text();
204
- }
205
- async function Fe(e) {
206
- var z, G, P;
207
- const { coordinator: t, dest: s, filterBy: a, readText: o = ge, subjects: r = !1, vertexType: l, wasm: p } = e, d = $e(t, a), O = Se(d.meta), w = he(d), m = await ue(s, { readText: o, wasm: p }), y = m.vertexType(l), T = m.drawing(y.type), h = T.relations.map((n) => ({
208
- address: n,
200
+ async function xe(e) {
201
+ var D, j, H;
202
+ const { corpus: t, engine: n, filterBy: o, subjects: i = !1, vertexType: c } = e, { addressing: r } = t, u = re(n.coordinator, o), E = ce(u.meta), S = pe(u), y = r.vertexType(c), m = r.drawing(y.type), $ = m.relations.map((s) => ({
203
+ address: s,
209
204
  // Never null on a drawn relation: a declared source-ordered adjacency is what `drawing` admits
210
205
  // one for, and `not-declared` is why the others are in `undrawn` instead.
211
- adjacency: n.adjacency("src"),
212
- view: `corpus_${n.srcType}_${n.edgeType}_${n.dstType}`
213
- })), N = h.map((n) => n.adjacency), u = m.incident(y.type).filter((n) => !T.relations.includes(n)).map((n, i) => ({
214
- edgeType: n.edgeType,
215
- srcType: n.srcType,
216
- dstType: n.dstType,
217
- reason: T.undrawn[i].reason
206
+ adjacency: s.adjacency("src")
207
+ })), T = $.map((s) => s.adjacency), f = r.incident(y.type).filter((s) => !m.relations.includes(s)).map((s, a) => ({
208
+ edgeType: s.edgeType,
209
+ srcType: s.srcType,
210
+ dstType: s.dstType,
211
+ reason: m.undrawn[a].reason
218
212
  }));
219
- let f = null;
220
- const Q = async () => {
221
- var i;
222
- const n = (i = t.databaseConnector) == null ? void 0 : i.call(t);
223
- if (!(n != null && n.getDuckDB)) return null;
224
- try {
225
- return await n.getDuckDB();
226
- } catch {
227
- return null;
228
- }
229
- }, C = /* @__PURE__ */ new Map(), F = /* @__PURE__ */ new Map();
230
- let M = 0;
231
- const ee = 64 * 1024 * 1024, te = 256 * 1024;
232
- async function j(n) {
233
- if (n.length === 0) return [];
234
- const i = await Q();
235
- if (!i) return n.map((c) => `'${c}'`);
236
- const E = await Promise.all(
237
- n.map(async (c) => {
238
- const A = F.get(c);
239
- if (A !== void 0 && (A.startsWith("'") || C.has(A)))
240
- return A;
241
- const x = `corpus_${c.replace(/[^A-Za-z0-9]+/g, "_")}`, L = await fetch(c, { method: "HEAD" }), $ = Number(L.headers.get("content-length"));
242
- if (!L.ok || !Number.isFinite($) || $ > te) {
243
- const g = `'${c}'`;
244
- return F.set(c, g), g;
245
- }
246
- const R = await fetch(c);
247
- if (!R.ok) return `'${c}'`;
248
- const S = new Uint8Array(await R.arrayBuffer());
249
- return await i.registerFileBuffer(x, S), C.set(x, S.byteLength), F.set(c, x), M += S.byteLength, x;
250
- })
251
- );
252
- for (; M > ee && C.size > 0; ) {
253
- const [c, A] = C.entries().next().value;
254
- if (E.includes(c)) break;
255
- C.delete(c), M -= A, await i.dropFile(c);
256
- }
257
- return E.map((c) => c.startsWith("'") ? c : `'${c}'`);
258
- }
259
- const H = y.files(), D = (n) => n.map((i) => `'${i.replace(/'/g, "''")}'`).join(", ");
260
- function B() {
261
- return f ?? (f = (async () => {
262
- const n = D(H), [i, E] = U, c = m.container === "rowgroups" ? "row_group_id" : `list_position([${n}], file_name) - 1`, A = await O(
263
- `SELECT ${c} AS tile,
264
- min(CASE WHEN path_in_schema = '${i}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS x0,
265
- max(CASE WHEN path_in_schema = '${i}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS x1,
266
- min(CASE WHEN path_in_schema = '${E}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS y0,
267
- max(CASE WHEN path_in_schema = '${E}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS y1
268
- FROM parquet_metadata([${n}])
269
- WHERE path_in_schema IN ('${i}', '${E}') GROUP BY 1 ORDER BY 1`
270
- ), x = I(A, "tile"), [L, $, R, S] = ["x0", "x1", "y0", "y1"].map((g) => I(A, g));
271
- return x.map((g, _) => ({
272
- tile: g,
273
- x0: L == null ? void 0 : L[_],
274
- x1: $ == null ? void 0 : $[_],
275
- y0: R == null ? void 0 : R[_],
276
- y1: S == null ? void 0 : S[_]
213
+ let A = null;
214
+ const l = y.files(), p = (s) => s.map((a) => `'${a.replace(/'/g, "''")}'`).join(", ");
215
+ function w() {
216
+ return A ?? (A = (async () => {
217
+ const s = p(l), [a, d] = M, I = r.container === "rowgroups" ? "row_group_id" : `list_position([${s}], file_name) - 1`, _ = await E(
218
+ `SELECT ${I} AS tile,
219
+ min(CASE WHEN path_in_schema = '${a}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS x0,
220
+ max(CASE WHEN path_in_schema = '${a}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS x1,
221
+ min(CASE WHEN path_in_schema = '${d}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS y0,
222
+ max(CASE WHEN path_in_schema = '${d}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS y1
223
+ FROM parquet_metadata([${s}])
224
+ WHERE path_in_schema IN ('${a}', '${d}') GROUP BY 1 ORDER BY 1`
225
+ ), C = b(_, "tile"), [g, N, L, R] = ["x0", "x1", "y0", "y1"].map((h) => b(_, h));
226
+ return C.map((h, O) => ({
227
+ tile: h,
228
+ x0: g == null ? void 0 : g[O],
229
+ x1: N == null ? void 0 : N[O],
230
+ y0: L == null ? void 0 : L[O],
231
+ y1: R == null ? void 0 : R[O]
277
232
  }));
278
- })()), f;
233
+ })()), A;
279
234
  }
280
- function ne(n, i) {
281
- return n.filter((E) => E.x1 >= i.xMin && E.x0 <= i.xMax && E.y1 >= i.yMin && E.y0 <= i.yMax).map((E) => E.tile);
235
+ function V(s, a) {
236
+ return s.filter((d) => d.x1 >= a.xMin && d.x0 <= a.xMax && d.y1 >= a.yMin && d.y0 <= a.yMax).map((d) => d.tile);
282
237
  }
283
- const W = {
284
- id: Ee[0],
285
- subject: r ? de[0] : void 0,
286
- x: U[0],
287
- y: U[1],
288
- source: ((z = N[0]) == null ? void 0 : z.column) ?? "src_dense",
289
- target: ((P = (G = h[0]) == null ? void 0 : G.address.adjacency("dst")) == null ? void 0 : P.column) ?? "dst_dense"
290
- }, v = `corpus_${y.type}`;
291
- await t.exec(
292
- `CREATE OR REPLACE VIEW ${v} AS SELECT * FROM read_parquet([${D(H)}])`
293
- );
294
- for (const n of h)
295
- await t.exec(
296
- `CREATE OR REPLACE VIEW ${n.view} AS
297
- SELECT * FROM read_parquet([${D(n.address.projectionFiles(1, "src"))}])`
298
- );
238
+ const U = {
239
+ id: ne[0],
240
+ subject: i ? te[0] : void 0,
241
+ x: M[0],
242
+ y: M[1],
243
+ source: ((D = T[0]) == null ? void 0 : D.column) ?? "src_dense",
244
+ target: ((H = (j = $[0]) == null ? void 0 : j.address.adjacency("dst")) == null ? void 0 : H.column) ?? "dst_dense"
245
+ }, J = await t.relations(), v = (s, a) => {
246
+ const d = J.find(s);
247
+ if (!d) throw new Error(`openCorpus: the corpus registers no relation for ${a}`);
248
+ return d.sql;
249
+ }, k = v((s) => s.kind === "vertex" && s.name === y.type, y.type);
299
250
  return {
300
251
  source: {
301
- ...w.api,
302
- publish(n) {
303
- xe(d, a, W.id, n);
252
+ ...S.api,
253
+ publish(s) {
254
+ me(u, o, U.id, s);
304
255
  },
305
256
  /**
306
257
  * How many vertices there are — **read, not probed.**
@@ -312,24 +263,24 @@ async function Fe(e) {
312
263
  */
313
264
  async total() {
314
265
  if (y.count !== null) return Number(y.count);
315
- const n = await O(`SELECT count(*) AS n FROM ${v}`);
316
- return Number(I(n, "n")[0] ?? 0);
266
+ const s = await E(`SELECT count(*) AS n FROM ${k}`);
267
+ return Number(b(s, "n")[0] ?? 0);
317
268
  },
318
269
  async extent() {
319
- const n = await B();
270
+ const s = await w();
320
271
  return {
321
- xMin: Math.min(...n.map((i) => i.x0)),
322
- yMin: Math.min(...n.map((i) => i.y0)),
323
- xMax: Math.max(...n.map((i) => i.x1)),
324
- yMax: Math.max(...n.map((i) => i.y1))
272
+ xMin: Math.min(...s.map((a) => a.x0)),
273
+ yMin: Math.min(...s.map((a) => a.y0)),
274
+ xMax: Math.max(...s.map((a) => a.x1)),
275
+ yMax: Math.max(...s.map((a) => a.y1))
325
276
  };
326
277
  },
327
278
  // Regions only, and it says so by being the only method there is. A neighbourhood needs
328
279
  // adjacency this source does not index; `ExploringSource` declared the second question here and
329
280
  // was deleted with it — `index.test.ts` carries why, and fossil's `expand` is what answers it.
330
- async slice(n) {
331
- const { fill: i, limit: E, minLinkPixels: c, perPixel: A, pinned: x, r: L, signal: $, view: R } = n, S = { ...W, category: i, size: L }, g = await B(), _ = ne(g, R);
332
- if (_.length === 0)
281
+ async slice(s) {
282
+ const { fill: a, limit: d, minLinkPixels: I, perPixel: _, pinned: C, r: g, signal: N, view: L } = s, R = { ...U, category: a, size: g }, h = await w(), O = V(h, L);
283
+ if (O.length === 0)
333
284
  return {
334
285
  n: 0,
335
286
  marks: 0,
@@ -338,32 +289,32 @@ async function Fe(e) {
338
289
  links: new Float32Array(0),
339
290
  categories: new Uint16Array(0)
340
291
  };
341
- const se = m.tilesFor({
292
+ const X = r.tilesFor({
342
293
  type: y.type,
343
- tiles: _,
294
+ tiles: O,
344
295
  directions: ["src"]
345
- }), ae = [
346
- ...new Set(N.flatMap((ce) => _.map((le) => ce.tileUrl(le))))
347
- ], [ie, Y] = await Promise.all([
348
- j(se.vertexUrls),
349
- j(ae)
350
- ]);
351
- if ($ != null && $.aborted) throw $.reason;
352
- const oe = `read_parquet([${ie.join(", ")}])`, re = Y.length > 0 ? `read_parquet([${Y.join(", ")}])` : `(SELECT NULL::BIGINT AS ${S.source}, NULL::BIGINT AS ${S.target} WHERE FALSE)`;
353
- return w.run(Oe(oe, re, S, R, E, x, A, c));
296
+ }), B = [
297
+ ...new Set(T.flatMap((Z) => O.map((ee) => Z.tileUrl(ee))))
298
+ ];
299
+ if (N != null && N.aborted) throw N.reason;
300
+ const K = `read_parquet([${p(X.vertexUrls)}])`, Q = B.length > 0 ? `read_parquet([${p(B)}])` : `(SELECT NULL::BIGINT AS ${R.source}, NULL::BIGINT AS ${R.target} WHERE FALSE)`;
301
+ return S.run(ye(K, Q, R, L, d, C, _, I));
354
302
  }
355
303
  },
356
- nodes: v,
357
- edges: h.map(({ address: n, view: i }) => ({
358
- edgeType: n.edgeType,
359
- srcType: n.srcType,
360
- dstType: n.dstType,
361
- view: i
304
+ nodes: k,
305
+ edges: $.map(({ address: s }) => ({
306
+ edgeType: s.edgeType,
307
+ srcType: s.srcType,
308
+ dstType: s.dstType,
309
+ view: v(
310
+ (a) => a.kind === "edge" && a.edgeType === s.edgeType && a.srcType === s.srcType && a.dstType === s.dstType,
311
+ `${s.srcType}_${s.edgeType}_${s.dstType}`
312
+ )
362
313
  })),
363
- undrawn: u
314
+ undrawn: f
364
315
  };
365
316
  }
366
317
  export {
367
- Fe as openCorpus
318
+ xe as openCorpus
368
319
  };
369
320
  //# sourceMappingURL=duck-source.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"duck-source.js","sources":["../src/duck-source.ts"],"sourcesContent":["\"use client\";\n\nimport {\n PAYLOAD_ADDRESS,\n PAYLOAD_COORDINATES,\n PAYLOAD_IDENTITY,\n open as openFossilCorpus,\n} from \"@fossil-lang/corpus\";\nimport type { EdgeAddress, GapReason, OpenOptions, ProjectionAddress } from \"@fossil-lang/corpus\";\nimport { clausePoints, column, fillColumn, numbers } from \"@kanzo-tech/mosaic\";\nimport type { Coordinator, FilterExpr, Selection } from \"@kanzo-tech/mosaic\";\nimport type { BoundedSource, Slice, SliceRequest, Viewport } from \"./bounded\";\nimport { SliceRead } from \"./slice-client\";\nimport { denseOf, typeOf, vertexId, type VertexId } from \"./resident\";\n\n/**\n * The source: a corpus fossil wrote, read through DuckDB.\n *\n * On a subpath because Mosaic and fossil's reader are optional peers and this is the half that needs\n * them. The root barrel ships no source at all, which is the whole of what that split now means: a\n * host that installs neither gets the rendering surface and draws nothing.\n *\n * **There used to be two here and the second was neutral about storage.** `duckBoundedSource` took\n * two ordinary relations and the column names that made sense of them, and that neutrality earned\n * its keep once — it let bounded be measured against unbounded before anything committed to a layout\n * on disk. What it cost afterwards is the reason it is gone: a corpus already declares those names\n * in its manifest, so the second source was this side writing down what the other side owns, and the\n * two answered the same question in two dialects of the same SQL.\n *\n * **Every query in this file goes through a `SliceRead`, and there is no other path.** `onceQuery`\n * was the other one — a throwaway client per query, on this subpath, re-exported for the two\n * showcases that also read a relation directly. It is gone: a read the page's filters cannot reach\n * is a picture that disagrees with the page, and `slice-client.ts` carries what that cost.\n */\n\n/**\n * A DuckDB-backed source, and the one thing it can do that the render contract knows nothing about.\n *\n * `BoundedSource` says what a renderer needs: answer a bounded question. Publishing a selection is\n * the other direction of the same seam and it is Mosaic's, not the renderer's — so it lives on the\n * concrete type rather than on the contract, beside `watch`, which is on the contract because the\n * query loop is what has to act on it.\n */\nexport interface DuckSource extends BoundedSource {\n /**\n * The reader's own selection, as a clause the rest of the page filters by. `null` retracts it.\n *\n * **The graph is exempt from its own clause, and that is the whole difference from the greyout it\n * replaces.** While the canvas *faded* excluded rows it could take its own clause too — the row was\n * still drawn and still selectable, and the fade was the brush. A canvas that now draws what\n * survives would answer a lasso by deleting everything the reader did not lasso, which is not a\n * selection, it is a filter nobody asked for.\n */\n publish(vertices: readonly VertexId[] | null): void;\n}\n\n/**\n * The type index every vertex this source returns wears — **zero, because there is one of them.**\n *\n * A `dense_id` numbers within one vertex type, so an identity is the pair and the second half has to\n * come from somewhere. It used to be an option: the general source took a `typeIndex` because only\n * the caller knew which relation it had handed over, and a defaulted `0` would have let a second\n * relation ship the first one's identities with nothing raised. `openCorpus` draws **one** vertex\n * type — `vertexType` picks which, and it is numbered zero either way — so the option had one legal\n * argument and it is written here instead, once, where the reason fits beside it.\n *\n * **What would reverse it:** a canvas drawing two vertex types at once, which is what a multi-type\n * corpus asks for. Then the number comes back — off the manifest's own type ordering rather than off\n * a caller, because by then the corpus is what knows.\n */\nconst VERTEX_TYPE = 0;\n\ninterface Columns {\n id: string;\n /** `undefined` when the host did not ask to be able to name a vertex. */\n subject: string | undefined;\n x: string;\n y: string;\n /**\n * The categorical column, when a channel named one — **and `undefined` is not a missing value.**\n *\n * It used to default to `community`, in both sources, which is this side writing what the corpus\n * owns: a relation that has no such column answered `Referenced column \"community\" not found`, and\n * one that has a differently named cluster column was silently coloured by the wrong thing. Neither\n * failure is the caller's, and both were invented here.\n *\n * Unbound, every point is one colour — which is Plot's own answer to a mark with no `fill` channel,\n * and an honest picture rather than a guess.\n */\n category: string | undefined;\n size: string | undefined;\n source: string;\n target: string;\n}\n\n/**\n * The three reads a source makes, and which of them the page can filter.\n *\n * `points` and `links` carry the crossfilter; `meta` deliberately does not. How big the corpus is,\n * where it sits and what its tile footers say are facts about the corpus rather than about the\n * page's current question — and a `total()` that shrank with the filters would make the view's own\n * \"20,000 of 1,000,000\" a fraction of itself, which is the one number a bounded renderer owes its\n * reader honestly.\n */\ninterface Reads {\n points: SliceRead;\n links: SliceRead;\n meta: SliceRead;\n}\n\nfunction openReads(coordinator: Coordinator, filterBy?: Selection): Reads {\n return {\n points: new SliceRead(coordinator, filterBy),\n links: new SliceRead(coordinator, filterBy),\n meta: new SliceRead(coordinator),\n };\n}\n\n/**\n * The metadata reads, queued behind each other.\n *\n * One client answers one question at a time — a second `ask` supersedes the first — and `total()`,\n * `extent()` and the tile probing are issued by different effects with no ordering between them. A\n * queue rather than a client each, because they *already* run one at a time: DuckDB-WASM answers\n * over one connection, measured, so three clients would buy three registrations and no concurrency.\n */\nfunction metaAsker(read: SliceRead): (sql: string) => Promise<unknown> {\n let queue: Promise<unknown> = Promise.resolve();\n return (sql) => {\n const next = queue.then(() => read.ask(() => sql));\n queue = next.catch(() => undefined);\n return next;\n };\n}\n\n/**\n * The page's predicate, as SQL text.\n *\n * The reads here are CTEs over window functions rather than builder queries — `row_number()` over the\n * visible set is what makes a slice's links speak in buffer positions — so the predicate has to be\n * interpolated rather than handed to `Query.where`. Mosaic's expression nodes stringify to the same\n * SQL the builder would emit, which is what makes that safe rather than a re-implementation.\n */\nfunction predicateSql(filter: FilterExpr | undefined): string {\n if (filter == null) return \"\";\n const list = Array.isArray(filter) ? filter : [filter];\n const clauses = list.filter((node) => node != null).map((node) => String(node));\n return clauses.length > 0 ? clauses.map((c) => `(${c})`).join(\" AND \") : \"\";\n}\n\n/** Two predicates, conjoined, where an absent one contributes nothing rather than `AND TRUE`. */\nfunction both(left: string, right: string): string {\n if (!left) return right || \"TRUE\";\n if (!right) return left;\n return `(${left}) AND (${right})`;\n}\n\n/**\n * The re-indexing happens in SQL, and that is the whole trick.\n *\n * `row_number() - 1` over the visible set gives every returned point a position in the arrays about\n * to be built, so the edge query can join to it twice and hand back links that already speak in\n * those positions. No id→index map is constructed in JavaScript — which is the 148 ms `load()` spent\n * at 200,000 nodes, gone by construction rather than by optimisation.\n *\n * The `LIMIT` sits inside the CTE, so the numbering is over what survives it. Numbering first and\n * limiting after would hand out indices into an array that was never built.\n */\n/**\n * A rectangle as a SQL predicate, and an **unbounded** rectangle as no predicate at all.\n *\n * `shouldSlice` answers `false` for a graph that fits, and the loop then asks for everything — a\n * viewport whose edges are `±Infinity`. Interpolated, that reads `x BETWEEN -Infinity AND Infinity`,\n * and SQL has no infinity literal: DuckDB parses `Infinity` as a **column name** and fails with\n * `Referenced column \"Infinity\" not found`. So an open edge contributes no clause, and a rectangle\n * open on every side is `TRUE` — which is also the right plan, because a query that wants every row\n * has nothing to prune.\n */\nfunction bboxSql(c: Columns, view: Viewport): string {\n const bounds: [string, number, string][] = [\n [c.x, view.xMin, \">=\"],\n [c.x, view.xMax, \"<=\"],\n [c.y, view.yMin, \">=\"],\n [c.y, view.yMax, \"<=\"],\n ];\n const clauses = bounds\n .filter(([, value]) => Number.isFinite(value))\n .map(([column, value, op]) => `${column} ${op} ${value}`);\n return clauses.length > 0 ? clauses.join(\" AND \") : \"TRUE\";\n}\n\n/**\n * How far apart the sampled ids are — one every `ceil(matched / limit)`.\n *\n * **In SQL rather than in JavaScript because the number it divides is only known inside the query.**\n * `matched` is a window aggregate over the rows the `WHERE` kept, so a caller wanting to compute\n * this outside would have to count first and slice second — two round trips down a connection that\n * answers one at a time, which is the shape `BENCHMARKS.md` records as a hung tab rather than a slow\n * one. As a column reference it costs the pass that was being made anyway.\n *\n * `greatest(1, …)` because an empty window makes the divisor zero, and a modulo by zero is an error\n * rather than an empty answer. At `matched <= limit` it is exactly 1 and `id % 1 = 0` keeps every\n * row: a window that fits is not sampled, it is returned.\n */\nconst strideSql = (limit: number) => `greatest(1, CAST(ceil(matched / ${limit}.0) AS BIGINT))`;\n\n/**\n * The visible set: what the rectangle matched, and the sample of it that gets drawn.\n *\n * **Two CTEs, and the second one is the whole of the far view.** `pool` is every row the predicate\n * kept, carrying `count(*) OVER ()` — a window function is evaluated over everything the `WHERE`\n * kept and `LIMIT` applies after it, so that column is the number that *matched* rather than the\n * number returned. It is what deleted the third query: `SELECT count(*) FROM … WHERE <the same\n * predicate>` was a second scan to learn a number the first scan already had to compute.\n *\n * `vis` then keeps one row in `stride`, **striding over the id rather than taking the front of the\n * ordering**, and that is the difference between a picture of the window and a picture of one corner\n * of it. A corpus numbers `dense_id` along the Morton curve, so every `s`-th id is a spatially\n * stratified sample; the same `LIMIT` with no stride returns a contiguous run of the curve, which is\n * a sub-region. Measured against the truth at screen resolution, L1@8px over blocks of eight pixels:\n * a stride sample of 20,000 scores 0.167 / 0.240 / 0.269 at 200k / 1M / 5M against a uniform null of\n * 1.044 / 0.829 / 0.731 — see `/docs/design/graph`.\n *\n * @param matched Whether to project the pre-sample count out to the caller.\n *\n * It rides on the points read only. The links read builds the same CTEs to join against and never\n * looks at the column — but it does compute it, because the stride is a function of it and both\n * reads have to select the *same* rows or a slice would draw edges to vertices it did not return.\n */\nfunction visibleCte(\n nodes: string,\n c: Columns,\n where: string,\n limit: number,\n matched = true,\n): string {\n const size = c.size ? `, ${c.size} AS size` : \"\";\n // Selected in the CTE rather than joined back afterwards: the numbering is over what survives the\n // LIMIT, and a second pass keyed on `local` would be a second scan to fetch a column the first one\n // was already standing on.\n const subject = c.subject ? `, ${c.subject} AS subject` : \"\";\n // No categorical binding, no ranking: a literal zero is the ordinal every point wears, and the\n // scale hands that one colour. Ranking a column nobody named is how a default column gets invented.\n // Ranked over the sample rather than over the window, so the ordinals are contiguous across what\n // is actually drawn — which is what the colour scale is handed.\n const category = c.category\n ? `(dense_rank() OVER (ORDER BY cat) - 1)::INTEGER AS category`\n : \"0::INTEGER AS category\";\n return `WITH pool AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y${size}${subject}${\n c.category ? `, ${c.category} AS cat` : \"\"\n },\n count(*) OVER () AS matched\n FROM ${nodes}\n WHERE ${where}\n ), vis AS (\n SELECT id, x, y${c.size ? \", size\" : \"\"}${c.subject ? \", subject\" : \"\"}${\n matched ? \", matched\" : \"\"\n },\n ${category},\n (row_number() OVER (ORDER BY id) - 1)::INTEGER AS local\n FROM pool\n WHERE id % ${strideSql(limit)} = 0\n LIMIT ${limit}\n )`;\n}\n\n/**\n * The shortest edge worth a row, as a predicate over the two endpoints — **squared, and on purpose.**\n *\n * A distance is compared against a threshold, and squaring both sides removes a `sqrt` per row from\n * a predicate evaluated once per candidate edge. It changes no answer: both sides are non-negative.\n *\n * `undefined` when the caller said nothing about resolution, and then there is no predicate at all\n * rather than a permissive one — a request with no canvas behind it (`EVERYTHING`) has no pixels to\n * measure three of.\n */\nfunction longEnough(\n a: string,\n b: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n if (perPixel === undefined || !Number.isFinite(perPixel) || perPixel <= 0) return \"\";\n const floor = minLinkPixels * perPixel;\n return `(${a}.x - ${b}.x) * (${a}.x - ${b}.x) + (${a}.y - ${b}.y) * (${a}.y - ${b}.y) >= ${floor * floor}`;\n}\n\n/**\n * The far ends, and the edges that reach them — **out of bytes the reader already fetched.**\n *\n * An edge with one end outside the rectangle is dropped today, and that loses 19.31% / 31.94% /\n * 28.92% of the edges incident to a window at 200k / 1M / 5M; 7,930 of 20,000 vertices carry at least\n * one at five million. What was missing was never the edge row — a window reads the `by_source` tiles\n * of every vertex it draws, so the row is in hand — it was **a position to draw the far end at**.\n *\n * **And a tile answers that for free.** A tile is 4,096 rows of a Morton-ordered relation and its\n * bounding box is far wider than the rows the rectangle keeps, so the vertices just outside the\n * window are usually in a tile the window already fetched. Measured over five windows of\n * `docs/public/bench/1000000`, 2026-08-19: relaxing the join from *inside the rectangle* to *inside\n * the tiles that were read* takes the drawn edges of five windows from 616,885 to 781,562 of 906,337\n * incident — **56.9% of everything the reader was losing, at no request, no query and no byte.**\n *\n * **The tile boundary is also a distance filter, and that is what settles the drawing.** Over every\n * far end a window loses, the median sits 1.64 semi-widths out and the worst 47.1 — which is why a\n * stub clipped to the viewport is a lie: nothing distinguishes 1.1 from 47. The far ends a held tile\n * can answer are the near ones: median 1.11–1.85 semi-widths, 90th percentile 1.29–2.60, worst\n * **6.74**, against 7.09–16.91 for the full reachable set. So the honest picture and the free one are\n * the same picture, and there is no trade to make: the anchors are drawn where the vertices are, and\n * the long tail stays undrawn because its bytes are not here — not because we decided.\n *\n * @param held The relation whose rows the reader is holding — the tiles a corpus fetched for this\n * window, which is the same relation the marks were read from. **Only an addressed source can name\n * one**, and that is why there is no longer a branch here for a source that cannot: over an\n * ordinary relation `held` would be the whole node table, and the join would scan the corpus twice\n * per camera move — the unbounded pattern wearing a bounded interface.\n */\nfunction anchorCte(\n held: string,\n edges: string,\n c: Columns,\n spatial: string,\n filter: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n const outside = both(`NOT (${spatial})`, filter);\n const long = longEnough(\"a\", \"b\", perPixel, minLinkPixels);\n return `, out AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y FROM ${held} WHERE ${outside}\n ), reach AS (\n SELECT id, x, y FROM vis UNION ALL SELECT id, x, y FROM out\n ), span AS (\n SELECT sv.local AS src, tv.local AS dst, a.id AS src_id, b.id AS dst_id\n FROM ${edges} e\n JOIN reach a ON e.${c.source} = a.id\n JOIN reach b ON e.${c.target} = b.id\n LEFT JOIN vis sv ON sv.id = a.id\n LEFT JOIN vis tv ON tv.id = b.id\n WHERE (sv.id IS NOT NULL OR tv.id IS NOT NULL)${long ? ` AND ${long}` : \"\"}\n ), anchor AS (\n SELECT o.id, o.x, o.y,\n ((SELECT count(*) FROM vis) + row_number() OVER (ORDER BY o.id) - 1)::INTEGER AS local\n FROM out o\n WHERE o.id IN (SELECT src_id FROM span WHERE src IS NULL\n UNION SELECT dst_id FROM span WHERE dst IS NULL)\n )`;\n}\n\n/**\n * A question, in the two halves it is asked in and the one place they are put back together.\n *\n * The reads run through the coordinator, so the *same* pair of SQL builders serves both directions:\n * the camera pulling an answer, and the page's filters pushing one. `assemble` is therefore a\n * function of the two results and of nothing else — no closure over which of the two paths asked,\n * because a slice that came back because somebody brushed a histogram is the same slice.\n */\ninterface Plan {\n points: (filter: FilterExpr) => string;\n links: (filter: FilterExpr) => string;\n assemble: (points: unknown, links: unknown) => Slice;\n}\n\n/**\n * The one plan there is: a rectangle, its sample, and the edges both of whose ends survived it.\n *\n * It was called `detail` because it was one of two, and the other one — a `GROUP BY` over a\n * categorical column, one super-node per group — is gone. There is no mode to be in.\n */\nfunction region(\n nodes: string,\n edges: string,\n c: Columns,\n view: Viewport,\n limit: number,\n pinned: VertexId[] | undefined,\n perPixel: number | undefined,\n minLinkPixels: number,\n): Plan {\n const bbox = bboxSql(c, view);\n // A dragged node is drawn where the reader dropped it and indexed where it always was, so the\n // rectangle cannot find it. Riding along in the predicate is what keeps it on screen — and it\n // stays a predicate rather than a second query so the numbering still covers everything returned.\n //\n // Only this relation's own vertices: a pinned set spans the whole canvas, and asking one node\n // table for another type's dense ids returns the wrong rows rather than none. `VERTEX_TYPE` is the\n // whole of what \"this relation\" means here — see the constant.\n const mine = (pinned ?? []).filter((v) => typeOf(v) === VERTEX_TYPE).map(denseOf);\n const pins = mine.length > 0 ? ` OR ${c.id} IN (${mine.join(\",\")})` : \"\";\n const spatial = `(${bbox})${pins}`;\n /**\n * The page's predicate outside the pin, not inside it.\n *\n * A pin says *where to look*; the filters say *what exists*. Written the other way round —\n * `bbox AND filter OR pinned` — a pinned node would survive a filter that excludes it, and the\n * canvas would draw a vertex the rest of the page has agreed is not there.\n */\n const where = (filter: FilterExpr) => both(spatial, predicateSql(filter));\n const size = c.size ? \", size\" : \"\";\n const subject = c.subject ? \", subject\" : \"\";\n // The relation the marks were read from *is* what the reader is holding, so the anchor CTE is\n // unconditional: there is no source left that fetches a window without also fetching the tiles\n // around it.\n const anchors = (filter: FilterExpr) =>\n anchorCte(nodes, edges, c, spatial, predicateSql(filter), perPixel, minLinkPixels);\n\n return {\n /**\n * The marks, then the anchors, in one answer — because they are one buffer.\n *\n * `ORDER BY local` is load-bearing rather than tidy: `local` runs `0..marks-1` over the sample\n * and continues past it over the anchors, so ordering by it puts every mark before every anchor\n * and makes `marks` a prefix length. `matched` is then still read off row zero.\n */\n points: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit)}${anchors(filter)}\n SELECT local, id, x, y, category, matched, TRUE AS mark${size}${subject} FROM vis\n UNION ALL\n SELECT local, id, x, y, 0::INTEGER, NULL::BIGINT, FALSE AS mark${\n c.size ? \", CAST(NULL AS DOUBLE)\" : \"\"\n }${c.subject ? \", CAST(NULL AS VARCHAR)\" : \"\"} FROM anchor\n ORDER BY local`,\n /**\n * One end drawn and both ends positioned.\n *\n * There was a second form here — both ends drawn, edges leaving the window dropped — for a source\n * that fetched no bytes beyond the rectangle and therefore had no position to put a far end at.\n * It went with that source, and `longEnough` with it — the length predicate lives in `anchorCte`\n * now, on the same span, which is where it has to sit once the join runs over the held rows\n * rather than over the visible ones.\n */\n links: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit, false)}${anchors(filter)}\n SELECT coalesce(sp.src, sa.local) AS src, coalesce(sp.dst, da.local) AS dst\n FROM span sp\n LEFT JOIN anchor sa ON sa.id = sp.src_id\n LEFT JOIN anchor da ON da.id = sp.dst_id`,\n assemble: (points, links) => ({\n /**\n * How many matched, separately from how many came back.\n *\n * Without it the view cannot tell a reader \"there is more here than I am showing you\", and a\n * truncated slice looks exactly like a complete one — which is the failure this whole branch\n * has been about. Read off the first row rather than asked for: `matched` is constant down the\n * column, and an empty answer has no row and no matches, which agree.\n */\n n: Number(numbers(points, \"matched\")[0] ?? 0),\n ...arrays(points, links, c.size ? \"size\" : undefined, c.subject !== undefined),\n }),\n };\n}\n\n/**\n * The half of a source that runs a [`Plan`], and the half that answers when nobody asked.\n *\n * Both sources need exactly this and neither should own a second copy of it — which is why it is a\n * function over the reads rather than two blocks of the same bookkeeping. What it holds is the\n * *standing* plan: the last question the camera put, kept so an answer arriving because the page\n * filtered something can be put back together the same way.\n */\nfunction watcher(reads: Reads) {\n let standing: Plan | null = null;\n let listener: ((slice: Slice) => void) | null = null;\n /** The half-answers of a push, waiting for their sibling. */\n const landed = new Map<\"points\" | \"links\", unknown>();\n\n const arrived = (half: \"points\" | \"links\") => (data: unknown) => {\n if (!standing || !listener) return;\n landed.set(half, data);\n if (landed.size < 2) return;\n const points = landed.get(\"points\");\n const links = landed.get(\"links\");\n landed.clear();\n listener(standing.assemble(points, links));\n };\n\n return {\n api: {\n /**\n * Say when the answer changes for a reason the camera cannot see.\n *\n * The reason it hands over a whole `Slice` rather than a nudge to ask again: the coordinator\n * has *already* re-run both reads with the new predicate by the time we hear about it. Asking\n * again would run the same two queries a second time to learn what is in hand.\n */\n watch(answered: (slice: Slice) => void): () => void {\n listener = answered;\n reads.points.onAnswer = arrived(\"points\");\n reads.links.onAnswer = arrived(\"links\");\n return () => {\n listener = null;\n landed.clear();\n standing = null;\n reads.points.release();\n reads.links.release();\n reads.meta.release();\n };\n },\n },\n async run(plan: Plan): Promise<Slice> {\n standing = plan;\n // Cleared because these two are halves of the *previous* question: keeping one would pair a\n // stale rectangle's points with the new rectangle's links the next time the page filters.\n landed.clear();\n const [points, links] = await Promise.all([\n reads.points.ask(plan.points),\n reads.links.ask(plan.links),\n ]);\n return plan.assemble(points, links);\n },\n };\n}\n\n/**\n * The reader's own gesture, as a clause — and the graph exempted from it.\n *\n * `clausePoints` defaults `clients` to the clause's source when that source is itself a client, which\n * is the exemption a crossfilter is built on. Here the source is a plain object and there are two\n * clients to exempt, so the set is written out: a lasso must filter the page's charts and leave the\n * canvas showing what the reader lassoed *in context*, rather than deleting everything else.\n */\nfunction publishSelection(\n reads: Reads,\n filterBy: Selection | undefined,\n idField: string,\n vertices: readonly VertexId[] | null,\n): void {\n if (!filterBy) return;\n filterBy.update(\n clausePoints([idField], vertices?.map((vertex) => [denseOf(vertex)]), {\n source: reads.points,\n clients: new Set([reads.points, reads.links]),\n }),\n );\n}\n\n/**\n * Arrow columns to the parallel typed arrays the renderer takes — sized once, filled in place.\n *\n * `fillColumn` rather than `numbers`: Arrow already hands back a typed buffer, and the obvious route\n * through `Array.from(...).map(Number)` allocates two full-length boxed arrays on the way to a third\n * that was the actual destination. Three copies to move nothing. Here the point buffers are sized\n * against the query's own `LIMIT` and each column is written straight into its stride, so `x` and\n * `y` interleave with no seam between them and no intermediate at all.\n */\nfunction arrays(\n points: unknown,\n links: unknown,\n sizeField?: string,\n withSubjects = false,\n): Omit<Slice, \"n\"> {\n // Sized from the answer rather than from the request's `limit`, which it used to be: an answer\n // carries the window's marks *and* the anchors the edges leaving it end at, so `limit` is no longer\n // an upper bound on the rows. Under-sizing here would drop the anchors silently and leave every\n // link that pointed at one indexing past the buffer.\n const rows = countOf(points, \"x\");\n const positions = new Float32Array(rows * 2);\n const n = fillColumn(points, \"x\", positions, 0, 2);\n fillColumn(points, \"y\", positions, 1, 2);\n\n // Where the marks stop. `mark` is `TRUE` down the sample and `FALSE` down the anchors, and the\n // query orders by `local`, so this is a prefix length rather than a count — which is what lets\n // `residentOf` and `buffers` treat \"is this a mark\" as an index comparison.\n const marked = new Int32Array(rows);\n fillColumn(points, \"mark\", marked);\n let marks = 0;\n while (marks < n && marked[marks] !== 0) marks++;\n\n // The dense ids land in a scratch and are widened into identities as they are copied across.\n //\n // In place, into the destination, would be better and is not available: `fillColumn` writes\n // `Number(…)`, and a `BigUint64Array` element takes a `bigint` only — assigning a `number` to one\n // throws rather than coercing. That refusal is the same guarantee this whole change is for, so the\n // extra `n`-long buffer is the price of the boundary being enforced by the runtime and not by us.\n const dense = new Float64Array(n);\n fillColumn(points, \"id\", dense);\n const vertices = new BigUint64Array(n);\n for (let i = 0; i < n; i++) vertices[i] = vertexId(VERTEX_TYPE, dense[i] as number);\n const categories = new Uint16Array(n);\n fillColumn(points, \"category\", categories);\n\n // `column` rather than `fillColumn`: an IRI is a string, so there is no typed buffer to write\n // into and no interleaving to express. It is the one thing a slice carries that never reaches the\n // GPU, which is why asking for it is a decision rather than a default.\n const subjects = withSubjects ? (column(points, \"subject\") as string[]) : undefined;\n\n let sizes: Float32Array | undefined;\n if (sizeField) {\n sizes = new Float32Array(n);\n fillColumn(points, sizeField, sizes);\n }\n\n // The edge count is not bounded by the point limit, so it is asked for rather than assumed.\n const edgeCount = countOf(links, \"src\");\n const edges = new Float32Array(edgeCount * 2);\n const wrote = fillColumn(links, \"src\", edges, 0, 2);\n fillColumn(links, \"dst\", edges, 1, 2);\n\n return {\n marks,\n vertices,\n subjects,\n positions: positions.subarray(0, n * 2),\n links: edges.subarray(0, wrote * 2),\n categories,\n sizes,\n };\n}\n\n/** A row count for a result that does not advertise one, without materialising the rows. */\nfunction countOf(rows: unknown, field: string): number {\n const advertised = (rows as { numRows?: number } | null)?.numRows;\n if (typeof advertised === \"number\") return advertised;\n const child = (rows as { getChild?: (f: string) => { length: number } | null })?.getChild?.(field);\n if (child) return child.length;\n return Array.from(rows as Iterable<unknown>).length;\n}\n\n/**\n * A corpus that fossil wrote, read by address — **and the addressing is fossil's.**\n *\n * **The five things a call site used to know, and now does not.** Drawing a corpus meant deriving\n * the chunk URLs from a `chunk_size` copied by hand, knowing how a tile is named, knowing what the\n * edge directory is called, knowing GraphAr's column names, and knowing that a glob cannot work over\n * a plain HTTP origin because there is no listing. Five conventions and about forty lines, none of\n * it the business of something that wants to draw a graph. `/docs/design/graph` carries the\n * argument; the copied `chunk_size` carries the evidence, because it went stale and read\n * a fraction of a corpus in silence for as long as it did.\n *\n * The consumer knows one thing: **where the corpus is.**\n *\n * ```ts\n * const { source } = await openCorpus({ coordinator, dest: \"/bench/1000000\" });\n * ```\n *\n * **And this side no longer knows the conventions either, which is the change.** It used to remove\n * that defect for its callers by committing it one level down — a YAML line-scanner, a\n * `chunk{k}.parquet` spelling, a `by_source/tile{k}.parquet` spelling and a `HEAD`-probing search\n * for a tile count — and by the time those were deleted all four were **wrong**: fossil writes\n * `container: rowgroups`, one `tiles.parquet` whose row groups are the tiles, and `vertex_count`\n * had been in the manifest the whole time the search was probing for it. Fossil's `open` is\n * `fossil_graph::plan` compiled to wasm32: the arithmetic the native reader runs, not a second\n * implementation of it that agrees until it does not.\n *\n * **Addressed, not queried.** The manifests and the per-tile boxes are read once and kept; after\n * that a camera move is arithmetic over boxes and a list of URLs. There is deliberately no request\n * on the path between the camera moving and a URL being computable — the moment there is one, this\n * has become the `viewport` verb fossil deleted.\n *\n * **What it is not.** It takes no column names and no type index. Those come from the manifest or\n * they do not come. There used to be a second source here for the case this is not — an arbitrary\n * relation with `x`/`y` that nobody wrote as a corpus — and taking `idField` here would have been\n * that one with extra steps. It is gone, so the rule is simpler than the guard against it: a column\n * name reaching this door is a name the manifest should have carried.\n */\n\nexport interface OpenCorpusOptions {\n coordinator: Coordinator;\n /**\n * The crossfilter this graph draws inside — the same `Selection` the page's charts filter by.\n *\n * Given, the predicate rides in the slice query and the canvas draws what survives.\n */\n filterBy?: Selection;\n /** Where the corpus lives, without a trailing slash — the directory holding `graph.graph.yml`. */\n dest: string;\n /**\n * The reader's `.wasm`, for a host with no bundler — and only for one.\n *\n * Omit it anywhere a bundler runs: fossil's module resolves its own `.wasm` with\n * `new URL(…, import.meta.url)`, which Vite, webpack 5 and Turbopack all emit as an asset. Node is\n * the host that needs it, because its `fetch` rejects `file://` — a script or a test passes the\n * bytes or a `Response`. Passed straight through to fossil's `open`, spelled as fossil spells it.\n */\n wasm?: OpenOptions[\"wasm\"];\n /**\n * Which vertex type to draw, when a corpus carries more than one.\n *\n * Defaults to the first the manifest names. A corpus of one type never passes it; a corpus of\n * several has to, because *which graph do you mean* is not a question a reader can answer.\n */\n vertexType?: string;\n /**\n * Read the identity column as well. **Off by default, and the same trade as everywhere else:**\n * `subject` costs about twice the drawing tile, so the drawing path carries addresses and a host\n * asks for names when something has to be *named* rather than painted.\n */\n subjects?: boolean;\n /**\n * How a manifest is read, when a plain `fetch` of its URL is not how this host reads one.\n *\n * **The default is `manifest` below, and it is the whole of what this file knows about reading a\n * corpus** — right for a corpus served off an origin the page can already read, and wrong for a\n * host whose blobs sit behind a signature. There the URL fossil composes is correct and\n * unreadable, and nothing else on these options carries a credential.\n *\n * Passed straight through to fossil's `open`, which is where the capability belongs: `open`\n * composes every address and lends the reader to each one, so a host that signs a URL signs the\n * index and the per-type manifests the index names **without knowing which files those are**.\n * That is the point of lending a reader rather than handing over bytes — and it is what keeps a\n * signing host on this door, because the alternative it otherwise reaches for is composing the\n * addresses itself, which is the convention-copying `openCorpus` exists to end.\n *\n * It reads manifests and nothing else. The payload is read by the coordinator's own connector,\n * which is the host's already.\n */\n readText?: OpenOptions[\"readText\"];\n}\n\n/** A relation this source draws: its address, the adjacency it reads it from, and its view name. */\ninterface DrawnRelation {\n readonly address: EdgeAddress;\n /** The source-ordered orientation — the CSR one, which is the drawing read. */\n readonly adjacency: ProjectionAddress;\n /** The registered view name — `corpus_{src}_{edge}_{dst}`. */\n readonly view: string;\n}\n\n/** One tile's bounding box, from the footer. A tile with no `x`/`y` statistics is not in the list. */\ninterface TileBox {\n tile: number;\n x0: number;\n x1: number;\n y0: number;\n y1: number;\n}\n\n/**\n * One manifest, as text — **the whole of what this file still knows about reading a corpus.**\n *\n * It takes an absolute URL because that is what fossil's door hands it. `open(dest, { readText })`\n * composes every address itself: it reads the index, then the per-type manifests the index names,\n * and nothing else. Which files those are is no longer a question asked on this side — the\n * twelve-line scan of the index's `vertices:`/`edges:` lists that used to stand here was the third\n * copy of a sequence the door now publishes, and the index's own file name left this file with it.\n *\n * A file that is not there raises here and reaches the caller as a `CorpusManifestError` naming the\n * URL, which is a better error than any invented on this side.\n *\n * The default rather than the only one: `OpenCorpusOptions.readText` replaces it, and a host behind\n * signed URLs is the case that needs to.\n */\nasync function manifest(url: string): Promise<string> {\n const response = await fetch(url);\n if (!response.ok) throw new Error(`corpus: ${url} is not readable (${response.status})`);\n return response.text();\n}\n\n/**\n * An opened corpus: the half that **draws** and the half that **answers**.\n *\n * A host needs both over the same bytes and they are not the same access. The canvas reads tiles by\n * address — a handful of files per camera move, chosen from the footer's boxes, with no query. A\n * chart, a crossfilter clause or a verb reads the *relation*: every row, by column name, in SQL.\n * Hiding the URLs behind `source` is right for the first and leaves the second with nothing to\n * query, so opening a corpus registers views for it.\n *\n * This is the other side's own shape. `fossil-mcp` describes itself as opening a dataset,\n * *registering views over the Parquet the corpus already holds*, and dispatching a verb — the same\n * two halves, named the same way, one call apart.\n */\nexport interface OpenedCorpus {\n /**\n * For the canvas: `<GraphCanvas source={…}>`. Reads tiles, never the whole relation.\n *\n * A `DuckSource`, and `CorpusSource` is gone with the reason it existed: `extent()` was declared\n * there because a source over an unlaid-out relation has no answer to it, and *optional on the\n * base contract* says that better than a second interface — a relation with `x`/`y` has an extent\n * too, and it was the one host that could not frame its opening view.\n */\n source: DuckSource;\n /**\n * The vertex relation, registered and ready to query by name.\n *\n * Every column the manifest declares, including the corpus' own properties — so a clause a chart\n * publishes over `kind` or `region` lands here with no translation, which is what makes one\n * crossfilter serve the canvas and the charts.\n */\n nodes: string;\n /**\n * The source-ordered edge relations this canvas draws, registered one view per relation.\n *\n * **A list, because a corpus declares a list.** This was one name, picked with `.find` over the\n * relations whose source is this type — so a corpus declaring two edge labels registered the\n * first and dropped the second with nothing raised, and a chart over `corpus_Person_edges` was a\n * chart over half the graph. `frame` on the other side takes every one of them\n * (`packages/corpus/src/corpus.ts`, `edges.filter((e) => e.srcType === address.type)`), and\n * fossil's own `verbs()` registers a view per relation under `{src}_{edge}_{dst}` rather than\n * unioning them — which is also what keeps two relations' differing property columns from having\n * to agree on one schema.\n *\n * Empty when the corpus declares no relation this source can draw; {@link OpenedCorpus.undrawn}\n * is then where the ones it declared went.\n */\n edges: readonly EdgeRelation[];\n /**\n * The relations incident to this type that the canvas does **not** read, and why.\n *\n * A single-type canvas can draw a relation only where *both* endpoints are numbered in its own\n * `dense_id` space. The rest are dropped from the read rather than mixed into it, and reported\n * here rather than dropped in silence.\n */\n undrawn: readonly UndrawnRelation[];\n}\n\n/** One edge relation of the drawn type, and the view its source-ordered adjacency is registered under. */\nexport interface EdgeRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n /** The registered view name — `corpus_{src}_{edge}_{dst}`. */\n readonly view: string;\n}\n\n/**\n * One relation incident to the drawn type that this source cannot read, and why.\n *\n * **The reason is fossil's {@link GapReason} and not a second spelling of it** — `other-space` when\n * an endpoint is another vertex type, so the `dense_id` on that side numbers a different set of\n * vertices and this source holds no coordinates for any of them; `not-declared` when both endpoints\n * are this type and the corpus publishes no source-ordered adjacency to read. The two the drawing\n * read can produce; the third, `not-requested`, is `tilesFor`'s and never reaches here.\n *\n * What this adds to a `Gap` is the endpoint pair, which a `Gap` does not carry: it names a relation\n * by label and orientation, and a corpus may declare two relations under one label.\n */\nexport interface UndrawnRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n readonly reason: GapReason;\n}\n\nexport async function openCorpus(options: OpenCorpusOptions): Promise<OpenedCorpus> {\n const { coordinator, dest, filterBy, readText = manifest, subjects = false, vertexType, wasm } = options;\n\n const reads = openReads(coordinator, filterBy);\n const meta = metaAsker(reads.meta);\n const watching = watcher(reads);\n\n /**\n * The addressing — **one `await`, one lent capability, and no arithmetic of ours.**\n *\n * Fossil's `open` takes where the corpus is and a way to read text, and hands back every URL the\n * corpus can produce, whichever container it declares. It needs no engine on this rung: lent a\n * reader, it fetches the index and the per-type manifests the index names, and nothing more.\n * What used to stand here — a line-scanning YAML reader, a `chunk{k}` spelling, an\n * edge-directory spelling and a `HEAD`-probing search for a tile count — was four conventions\n * fossil owns, written down on this side, and stale in all four by the time they were deleted.\n * A fifth went the same way once the door published the sequence rather than the file name: the\n * scan that worked out which manifests to ask for.\n *\n * **`readText` and not `manifestFiles`**, which is the other engine-free rung: holding the bytes\n * is what that one is for, and this never held them for its own sake — it fetched them only to\n * hand them over. It is the host's reader where one was given, and `fetch` where none was; either\n * way the addresses it is lent to are fossil's, which is the half that does not move.\n */\n const addressing = await openFossilCorpus(dest, { readText, wasm });\n\n const type = addressing.vertexType(vertexType);\n /**\n * Which relations this canvas may draw, and which incident ones it may not — **asked, not\n * derived.**\n *\n * A picture is one type's `dense_id` space, so a relation that LEAVES the type has its far ends\n * numbered in another type's, both spaces are dense from zero, and `BIGINT` compares against\n * `BIGINT` without complaining: the join matches and draws a line between two vertices with\n * nothing between them. The rule that refuses it used to be written here too — a filter over\n * `incident` on `srcType`/`dstType` plus a null check on the source-ordered adjacency — which was\n * the second copy of a rule that belongs to the addressing. It is `ReadPlan::drawing`\n * (`crates/fossil-graph/src/plan.rs`), in Rust, said once, and this is the call.\n *\n * **Not `tilesFor`, which answers the other question.** Its `edgeUrls` is every relation whose\n * *source* is this type, cross-type ones included — right for incidence, unusable for a picture.\n * Confusing the two is the bug this call closes.\n *\n * `by_target` is not asked for and its absence is reported rather than hidden: a window's\n * drawable edges all have their source on screen, so the source-aligned tiles are complete for\n * drawing and incomplete for incidence. `tilesFor` says which below, in `gaps`.\n */\n const drawing = addressing.drawing(type.type);\n const drawn: DrawnRelation[] = drawing.relations.map((address) => ({\n address,\n // Never null on a drawn relation: a declared source-ordered adjacency is what `drawing` admits\n // one for, and `not-declared` is why the others are in `undrawn` instead.\n adjacency: address.adjacency(\"src\") as ProjectionAddress,\n view: `corpus_${address.srcType}_${address.edgeType}_${address.dstType}`,\n }));\n const adjacencies = drawn.map((relation) => relation.adjacency);\n /**\n * The rejected ones, with their endpoints — **the reasons are fossil's, the endpoints are the\n * corpus's, and neither is a judgement of ours.**\n *\n * The two lists are one partition of `incident` in declaration order: fossil walks the plan's\n * edges once and puts each incident one in `relations` or in `undrawn`, so the incident relations\n * missing from the first are the gaps of the second, in order. That join is here because a `Gap`\n * names a relation by label and orientation, and a label does not identify one — two relations\n * may share it — so the endpoint types cannot be read back off the gap alone.\n */\n const undrawn: UndrawnRelation[] = addressing\n .incident(type.type)\n .filter((edge) => !drawing.relations.includes(edge))\n .map((edge, index) => ({\n edgeType: edge.edgeType,\n srcType: edge.srcType,\n dstType: edge.dstType,\n reason: drawing.undrawn[index]!.reason,\n }));\n\n /**\n * The boxes, and the one query this source makes that is not a slice.\n *\n * Read on first use rather than in the factory: a host that constructs a source and never draws\n * should not pay for it, and the cost is a footer read over the payload. Kept forever after —\n * tiles are precomputed and their boxes cannot move without the corpus being rewritten.\n *\n * **Held as the promise rather than as the answer**, which the move to one shared metadata client\n * forced and which was a latent defect before it: `total()` and `extent()` are called by different\n * effects with nothing ordering them, so two loads used to run concurrently and probe the whole\n * tile range twice.\n */\n let loading: Promise<TileBox[]> | null = null;\n\n /**\n * The tiles a window needs, held as bytes so that **panning back is free**.\n *\n * This is the one item on `BENCHMARKS.md`'s fix list that no amount of query tuning substitutes\n * for, and the measurement that puts it there is blunt: two of six drag steps at ten million\n * transfer zero new bytes and still cost 247 requests each, because every visit re-reads the same\n * footers and column chunks over HTTP. A tile fetched once and registered as a file is read from\n * memory forever after — no request, no range negotiation, no metadata round trip.\n *\n * **Keyed on the URL and not on the tile**, which is what makes it container-independent for\n * free: under `files` a tile is a file and the two keys agree, and under `rowgroups` every tile\n * names one `tiles.parquet`, which a tile-keyed cache would fetch once per tile.\n *\n * **The trade is honest and not free.** A registered file is the *whole* file, where DuckDB over\n * HTTP reads only the column chunks a query projects — the first visit costs more bytes and every\n * later one costs none. Which way that nets out depends on how the corpus is cut, which is the\n * corpus's decision and not ours.\n *\n * Discovered rather than required: a coordinator whose connector is not DuckDB-WASM has no\n * filesystem to register into, and reads by URL exactly as before. `@duckdb/duckdb-wasm` is\n * deliberately not a dependency of this package, so the capability is named structurally.\n */\n interface Registrar {\n registerFileBuffer(name: string, buffer: Uint8Array): Promise<void>;\n dropFile(name: string): Promise<void>;\n }\n const registrar = async (): Promise<Registrar | null> => {\n const connector = coordinator.databaseConnector?.() as\n | { getDuckDB?: () => Promise<Registrar> }\n | null\n | undefined;\n if (!connector?.getDuckDB) return null;\n try {\n return await connector.getDuckDB();\n } catch {\n return null;\n }\n };\n\n /** Registered name → how many bytes it is holding. Insertion order is the eviction order. */\n const resident = new Map<string, number>();\n /** URL → the name DuckDB should read it from, once that has been decided one way or the other. */\n const decided = new Map<string, string>();\n let held = 0;\n\n /**\n * Sixty-four megabytes of tiles, evicted oldest-first.\n *\n * A budget rather than a count, because a tile's size is the corpus's decision and a count would\n * mean something different for every one. Oldest-first rather than least-recently-used: a reader\n * pans, and a pan revisits what it just left, so recency and insertion order agree where it\n * matters — and an LRU's bookkeeping is a second structure to keep correct for a difference nobody\n * has measured.\n */\n const BUDGET = 64 * 1024 * 1024;\n\n /**\n * A file is worth holding when it is **cheap to fetch whole** — 256 KB, and the number is\n * measured.\n *\n * Registering a file means downloading all of it. On the million-node corpus a vertex chunk was\n * 74 KB and the edge chunks far larger, and caching both took a cold window from about 200 ms to\n * **17,979 ms** while a repeat fell to **11 ms** — a thousandfold win on revisit paid for with an\n * eighteen-second first paint, which is not a trade anybody would take.\n *\n * So the rule is a property of the file rather than a flag, and the corpus decides: one\n * `tiles.parquet` per set decides against, which is right for it — the row groups a window wants\n * are a byte range, and a range read is what DuckDB already does.\n */\n const WORTH_HOLDING = 256 * 1024;\n\n /**\n * The names DuckDB should read these URLs from — registered buffers where possible, URLs where\n * not, quoted either way.\n *\n * Fetched concurrently, because a window is a handful of files and they are independent; a\n * sequential loop here would make the first paint the sum of them rather than the slowest.\n */\n async function readable(urls: readonly string[]): Promise<string[]> {\n if (urls.length === 0) return [];\n const db = await registrar();\n if (!db) return urls.map((url) => `'${url}'`);\n const names = await Promise.all(\n urls.map(async (url) => {\n const already = decided.get(url);\n if (already !== undefined && (!already.startsWith(\"'\") ? resident.has(already) : true)) {\n return already;\n }\n // A name DuckDB can hold a buffer under, derived from the URL so that two tiles of two\n // types never collide and the same tile twice never registers twice.\n const name = `corpus_${url.replace(/[^A-Za-z0-9]+/g, \"_\")}`;\n // The size first, which is one metadata request against a body that may be megabytes — and\n // the same request a footer read already makes, so the shape is not new here.\n const probe = await fetch(url, { method: \"HEAD\" });\n const size = Number(probe.headers.get(\"content-length\"));\n if (!probe.ok || !Number.isFinite(size) || size > WORTH_HOLDING) {\n const plain = `'${url}'`;\n decided.set(url, plain);\n return plain;\n }\n const response = await fetch(url);\n // A file that will not load is not a reason to fail the whole window: fall back to the URL\n // and let DuckDB report whatever it finds there, which is the error a reader can act on.\n if (!response.ok) return `'${url}'`;\n const bytes = new Uint8Array(await response.arrayBuffer());\n await db.registerFileBuffer(name, bytes);\n resident.set(name, bytes.byteLength);\n decided.set(url, name);\n held += bytes.byteLength;\n return name;\n }),\n );\n while (held > BUDGET && resident.size > 0) {\n const [oldest, size] = resident.entries().next().value as [string, number];\n // Never evict a file this very window is about to read, or the query reads a dropped file.\n if (names.includes(oldest)) break;\n resident.delete(oldest);\n held -= size;\n await db.dropFile(oldest);\n }\n return names.map((n) => (n.startsWith(\"'\") ? n : `'${n}'`));\n }\n\n /**\n * Where the payload is, as the list of files that hold it.\n *\n * One file per tile under `container: files`, one file in total under `rowgroups` — and this side\n * does not know or care which, because the address is asked for rather than composed. Throws when\n * the manifest declares no `vertex_count`, which is the one absence that makes a corpus\n * un-enumerable.\n */\n const payloadFiles = type.files();\n /** A URL list as a SQL list literal. Every read below composes one and none of them composes a URL. */\n const quoted = (urls: readonly string[]) =>\n urls.map((url) => `'${url.replace(/'/g, \"''\")}'`).join(\", \");\n\n /**\n * The boxes, from the footer — **which tile a row group is, asked of the addressing.**\n *\n * `parquet_metadata` reports a `file_name` and a `row_group_id`, and which of the two names the\n * tile is the container's business: under `rowgroups` row group `k` IS tile `k`; under `files`\n * the file is, and its row groups are the writer's business, so their boxes are merged. This used\n * to read the tile out of the URL with `regexp_extract(file_name, 'chunk(\\d+)')` — one\n * container's spelling hard-coded into a query, and `NULL` for every row of a corpus fossil\n * writes today.\n *\n * `min_value`/`max_value`, never `min`/`max`. Parquet's original statistics fields are defined by\n * *signed* byte comparison, which is meaningless for an unsigned column — a writer that gets this\n * right leaves them empty. A reader that only knows the deprecated pair concludes the footer\n * carries no box for the column the whole address is built on. `coalesce` keeps the float columns\n * working either way.\n */\n function load(): Promise<TileBox[]> {\n loading ??= (async () => {\n const files = quoted(payloadFiles);\n const [xCol, yCol] = PAYLOAD_COORDINATES;\n // Which of `file_name` and `row_group_id` names the tile, as an expression rather than as a\n // branch in JavaScript: the grouping has to happen where the rows are either way.\n const tile =\n addressing.container === \"rowgroups\"\n ? \"row_group_id\"\n : `list_position([${files}], file_name) - 1`;\n const stats = await meta(\n `SELECT ${tile} AS tile,\n min(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS x0,\n max(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS x1,\n min(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS y0,\n max(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS y1\n FROM parquet_metadata([${files}])\n WHERE path_in_schema IN ('${xCol}', '${yCol}') GROUP BY 1 ORDER BY 1`,\n );\n const tiles = numbers(stats, \"tile\");\n const [x0, x1, y0, y1] = [\"x0\", \"x1\", \"y0\", \"y1\"].map((f) => numbers(stats, f));\n return tiles.map((t, i) => ({\n tile: t as number,\n x0: x0?.[i] as number,\n x1: x1?.[i] as number,\n y0: y0?.[i] as number,\n y1: y1?.[i] as number,\n }));\n })();\n return loading;\n }\n\n /** The tiles a rectangle touches. Pure — this is the whole of the selection, and it makes no call. */\n function intersecting(all: TileBox[], view: Viewport): number[] {\n return all\n .filter((b) => b.x1 >= view.xMin && b.x0 <= view.xMax && b.y1 >= view.yMin && b.y0 <= view.yMax)\n .map((b) => b.tile);\n }\n\n /**\n * Everything the corpus fixes, and nothing it does not — **by ROLE, not by name.**\n *\n * The address, the identity and the coordinates are facts of the format, and the names they are\n * written under come off `@fossil-lang/corpus`'s generated column table rather than four string\n * literals here. The endpoint columns come off the adjacency's own address. What colours and what\n * sizes are channels, so they arrive with the request and are filled in per slice.\n */\n const fixed = {\n id: PAYLOAD_ADDRESS[0] as string,\n subject: subjects ? (PAYLOAD_IDENTITY[0] as string) : undefined,\n x: PAYLOAD_COORDINATES[0] as string,\n y: PAYLOAD_COORDINATES[1] as string,\n source: adjacencies[0]?.column ?? \"src_dense\",\n target: drawn[0]?.address.adjacency(\"dst\")?.column ?? \"dst_dense\",\n } as const;\n\n /**\n * The relation half, registered once at open.\n *\n * A view rather than a table: `CREATE TABLE AS` would pull the corpus into memory, which is the\n * working set the whole bounded path exists to refuse. A view leaves the bytes where they are and\n * lets each query fetch the ranges it needs.\n *\n * Over **every** file of the payload, deliberately — this is the surface that answers *what does\n * it mean*, and a count, a histogram or a crossfilter clause is a question about the corpus rather\n * than about the window. The addressed reading is `slice`, beside it, and the two are different\n * access to the same bytes rather than two versions of one.\n */\n const nodesView = `corpus_${type.type}`;\n await coordinator.exec(\n `CREATE OR REPLACE VIEW ${nodesView} AS SELECT * FROM read_parquet([${quoted(payloadFiles)}])`,\n );\n // One view per relation and never a union of them: a relation carries its own properties, so two\n // unioned relations would have to agree on a schema to be readable at all, and a caller reading\n // the result could not say which label a row came from. `{src}_{edge}_{dst}` is the name fossil's\n // own `verbs()` registers them under, prefixed here because these views are not `TEMP`.\n for (const relation of drawn) {\n await coordinator.exec(\n `CREATE OR REPLACE VIEW ${relation.view} AS\n SELECT * FROM read_parquet([${quoted(relation.address.projectionFiles(1, \"src\"))}])`,\n );\n }\n\n const source: DuckSource = {\n ...watching.api,\n\n publish(vertices) {\n publishSelection(reads, filterBy, fixed.id, vertices);\n },\n\n /**\n * How many vertices there are — **read, not probed.**\n *\n * The manifest declares `vertex_count`. What stood here was a note saying no manifest carries\n * it, and a doubling-then-bisecting `HEAD` search for the last chunk plus a\n * `parquet_file_metadata` read of its row count — about a dozen requests and a query, per\n * corpus, for a number already in hand.\n */\n async total() {\n if (type.count !== null) return Number(type.count);\n const rows = await meta(`SELECT count(*) AS n FROM ${nodesView}`);\n return Number(numbers(rows, \"n\")[0] ?? 0);\n },\n\n async extent() {\n const all = await load();\n return {\n xMin: Math.min(...all.map((b) => b.x0)),\n yMin: Math.min(...all.map((b) => b.y0)),\n xMax: Math.max(...all.map((b) => b.x1)),\n yMax: Math.max(...all.map((b) => b.y1)),\n };\n },\n\n // Regions only, and it says so by being the only method there is. A neighbourhood needs\n // adjacency this source does not index; `ExploringSource` declared the second question here and\n // was deleted with it — `index.test.ts` carries why, and fossil's `expand` is what answers it.\n async slice(request: SliceRequest): Promise<Slice> {\n const { fill, limit, minLinkPixels, perPixel, pinned, r, signal, view } = request;\n const columns: Columns = { ...fixed, category: fill, size: r };\n const all = await load();\n /**\n * The tiles the rectangle touches — **and the far view is not a special case of this.**\n *\n * It used to be: past a zoom threshold the selection was replaced by every tile, because the\n * aggregate branch was going to read the whole relation anyway. A window that covers the\n * extent already intersects every box, so the branch was arithmetic restating itself, and it\n * is the reason `Viewport` carried a `zoom` at all.\n */\n const selected = intersecting(all, view);\n // Nothing selected is a legitimate answer — the camera is over empty space — and asking\n // `read_parquet([])` is a syntax error rather than an empty result. Checked before the files\n // are fetched, so an empty window costs no bytes at all.\n if (selected.length === 0) {\n return {\n n: 0,\n marks: 0,\n vertices: new BigUint64Array(0),\n positions: new Float32Array(0),\n links: new Float32Array(0),\n categories: new Uint16Array(0),\n };\n }\n\n /**\n * The URLs those tiles are in — **asked, not composed**, and distinct.\n *\n * The vertex half off `tilesFor`; the edge half off each drawn relation's own adjacency,\n * which is `frame`'s shape for the same reason (`corpus.ts`, `edgeLevels.flatMap((set) =>\n * held.map((tile) => set.tileUrl(tile)))`). `tilesFor`'s `edgeUrls` is every relation whose\n * SOURCE is this type, so a cross-type one is in it — and `drawing` is where that set is\n * narrowed to the relations whose far end this source can position. The source-ordered\n * orientation is the drawing read either way: every edge a window can draw has its source on\n * screen, therefore in one of these files.\n */\n const addressed = addressing.tilesFor({\n type: type.type,\n tiles: selected,\n directions: [\"src\"],\n });\n const edgeUrls = [\n ...new Set(adjacencies.flatMap((a) => selected.map((tile) => a.tileUrl(tile)))),\n ];\n // Both halves at once: the vertex files and the edge files a window touches are independent\n // reads, and the window is not drawable until both have landed.\n const [vertexFiles, edgeFiles] = await Promise.all([\n readable(addressed.vertexUrls),\n readable(edgeUrls),\n ]);\n /**\n * The camera moved while the tiles were arriving, so this question is already the wrong one.\n *\n * Checked here rather than left to the caller because of what comes next: the reads hold one\n * standing question each, so a request that resumes after the loop moved on would *supersede*\n * the newer one and reject it — the stale question winning the race against the live one.\n *\n * `signal.reason` and not a sentinel of ours: an aborted signal already carries what it was\n * aborted with, which for `AbortController.abort()` is a `DOMException` named `AbortError`.\n * Rethrowing it is the whole of the cancellation contract a source owes — see `SliceRequest`.\n */\n if (signal?.aborted) throw signal.reason;\n const nodes = `read_parquet([${vertexFiles.join(\", \")}])`;\n // A corpus that declares no adjacency for this type still has to answer: the links query is\n // built either way, so what it reads is an empty relation of the right shape rather than a\n // `read_parquet([])`, which is a syntax error, or the vertex view, which has neither column.\n const relation =\n edgeFiles.length > 0\n ? `read_parquet([${edgeFiles.join(\", \")}])`\n : `(SELECT NULL::BIGINT AS ${columns.source}, NULL::BIGINT AS ${columns.target} WHERE FALSE)`;\n\n // `nodes` is both the window's rows and the bytes the reader holds: the tiles that answer\n // \"what is in the rectangle\" are the tiles that answer \"where is the far end of an edge that\n // leaves it\".\n return watching.run(region(nodes, relation, columns, view, limit, pinned, perPixel, minLinkPixels));\n },\n };\n\n return {\n source,\n nodes: nodesView,\n edges: drawn.map(({ address, view }) => ({\n edgeType: address.edgeType,\n srcType: address.srcType,\n dstType: address.dstType,\n view,\n })),\n undrawn,\n };\n}\n"],"names":[],"mappings":";;;;;AAsEA;AAwCA;AACE;AAAO;AACsC;AACD;AACX;AAEnC;AAUA;AACE;AACA;AACE;AACA;AAAmB;AACZ;AAEX;AAUA;AACE;AAEA;AACA;AACF;AAGA;AACE;AAGF;AAuBA;AAOE;AAN2C;AACpB;AACA;AACA;AACA;AAKvB;AACF;AAeA;AAyBA;AAOE;AAYA;AAAO;AAGL;AAAA;AAEY;AACC;AAAA;AAIb;AACiB;AAAA;AAAA;AAGY;AAChB;AAEjB;AAYA;AAME;AACA;AACA;AACF;AA+BA;AASE;AAEA;AAAO;AACsE;AAAA;AAAA;AAAA;AAAA;AAK/D;AACgB;AACA;AAAA;AAAA;AAG8C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQ9E;AAsBA;AAUE;AA2BA;AAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAS4D;AACS;AAAA;AAI1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYwB;AAAA;AAAA;AAAA;AAAA;AAK1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASgB;AACiC;AAAA;AAGnF;AAUA;AACE;AAGA;AAKE;AACA;AAEA;AACyC;AAG3C;AAAO;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASD;AAIE;AAKW;AACb;AACF;AAAA;AAGA;AAIA;AAA0C;AACZ;AACF;AAE5B;AAAkC;AACpC;AAEJ;AAUA;AAME;AACS;AAC+D;AACtD;AAC8B;AAC7C;AAEL;AAWA;AAUE;AAGA;AAKA;AACA;AACA;AACA;AAQA;AACA;AACA;AACA;AACA;AACA;AAKA;AAEA;AACA;AAMA;AAGA;AAEO;AACL;AACA;AACA;AACsC;AACJ;AAClC;AACA;AAEJ;AAGA;;AACE;AACA;AACA;AACA;AAEF;AA8HA;AACE;AACA;AACA;AACF;AAuFA;;AACE;AA+CmE;AACjE;AAAA;AAAA;AAGkC;AACoC;AAgB/C;AACN;AACD;AACA;AACkB;AAepC;AA4BA;;AACE;AAIA;AACA;AACE;AAAuB;AAEvB;AAAO;AACT;AAOF;AAWA;AAwBA;AACE;AACA;AACA;AACA;AAA4B;AAExB;AACA;AACE;AAIF;AAKA;AACE;AACA;AACO;AAET;AAGA;AACA;AACA;AAIO;AACR;AAEH;AACE;AAEA;AACA;AAEwB;AAE1B;AAA0D;AAW5D;AAqBA;AACE;AACE;AAQoB;AACJ;AAC2B;AACA;AACA;AACA;AACV;AACa;AAI9C;AAA4B;AACpB;AACG;AACA;AACA;AACA;AACT;AAEG;AAIT;AACE;AAEoB;AAWtB;AAAc;AACS;AACiC;AAC9B;AACA;AACU;AACoB;AAgBxD;AAAkB;AAC0E;AAM5F;AACE;AAAkB;AACuB;AAC4C;AAsHvF;AAAO;AAlHoB;AACb;AAGV;AAAoD;AACtD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWE;AACA;AACA;AAAwC;AAC1C;AAGE;AACA;AAAO;AACiC;AACA;AACA;AACA;AAAA;AAE1C;AAAA;AAAA;AAAA;AAME;AAeA;AACE;AAAO;AACF;AACI;AACuB;AACD;AACJ;AACI;AAejC;AAAsC;AACzB;AACJ;AACW;AAEH;AAC+D;AAI7B;AACpB;AACZ;AAanB;AACA;AAYA;AAAkG;AACpG;AAAA;AAKO;AACkC;AACrB;AACD;AACA;AACjB;AACA;AACF;AAEJ;;;;"}
1
+ {"version":3,"file":"duck-source.js","sources":["../src/duck-source.ts"],"sourcesContent":["\"use client\";\n\nimport { PAYLOAD_ADDRESS, PAYLOAD_COORDINATES, PAYLOAD_IDENTITY } from \"@fossil-lang/corpus\";\nimport type {\n Corpus,\n CorpusRelation,\n EdgeAddress,\n GapReason,\n ProjectionAddress,\n} from \"@fossil-lang/corpus\";\nimport { clausePoints, column, fillColumn, numbers } from \"@kanzo-tech/mosaic\";\nimport type { Coordinator, Engine, FilterExpr, Selection } from \"@kanzo-tech/mosaic\";\nimport type { BoundedSource, Slice, SliceRequest, Viewport } from \"./bounded\";\nimport { SliceRead } from \"./slice-client\";\nimport { denseOf, typeOf, vertexId, type VertexId } from \"./resident\";\n\n/**\n * The source: a corpus fossil wrote, read through DuckDB.\n *\n * On a subpath because Mosaic and fossil's reader are optional peers and this is the half that needs\n * them. The root barrel ships no source at all, which is the whole of what that split now means: a\n * host that installs neither gets the rendering surface and draws nothing.\n *\n * **There used to be two here and the second was neutral about storage.** `duckBoundedSource` took\n * two ordinary relations and the column names that made sense of them, and that neutrality earned\n * its keep once — it let bounded be measured against unbounded before anything committed to a layout\n * on disk. What it cost afterwards is the reason it is gone: a corpus already declares those names\n * in its manifest, so the second source was this side writing down what the other side owns, and the\n * two answered the same question in two dialects of the same SQL.\n *\n * **Every query in this file goes through a `SliceRead`, and there is no other path.** `onceQuery`\n * was the other one — a throwaway client per query, on this subpath, re-exported for the two\n * showcases that also read a relation directly. It is gone: a read the page's filters cannot reach\n * is a picture that disagrees with the page, and `slice-client.ts` carries what that cost.\n */\n\n/**\n * A DuckDB-backed source, and the one thing it can do that the render contract knows nothing about.\n *\n * `BoundedSource` says what a renderer needs: answer a bounded question. Publishing a selection is\n * the other direction of the same seam and it is Mosaic's, not the renderer's — so it lives on the\n * concrete type rather than on the contract, beside `watch`, which is on the contract because the\n * query loop is what has to act on it.\n */\nexport interface DuckSource extends BoundedSource {\n /**\n * The reader's own selection, as a clause the rest of the page filters by. `null` retracts it.\n *\n * **The graph is exempt from its own clause, and that is the whole difference from the greyout it\n * replaces.** While the canvas *faded* excluded rows it could take its own clause too — the row was\n * still drawn and still selectable, and the fade was the brush. A canvas that now draws what\n * survives would answer a lasso by deleting everything the reader did not lasso, which is not a\n * selection, it is a filter nobody asked for.\n */\n publish(vertices: readonly VertexId[] | null): void;\n}\n\n/**\n * The type index every vertex this source returns wears — **zero, because there is one of them.**\n *\n * A `dense_id` numbers within one vertex type, so an identity is the pair and the second half has to\n * come from somewhere. It used to be an option: the general source took a `typeIndex` because only\n * the caller knew which relation it had handed over, and a defaulted `0` would have let a second\n * relation ship the first one's identities with nothing raised. `openCorpus` draws **one** vertex\n * type — `vertexType` picks which, and it is numbered zero either way — so the option had one legal\n * argument and it is written here instead, once, where the reason fits beside it.\n *\n * **What would reverse it:** a canvas drawing two vertex types at once, which is what a multi-type\n * corpus asks for. Then the number comes back — off the manifest's own type ordering rather than off\n * a caller, because by then the corpus is what knows.\n */\nconst VERTEX_TYPE = 0;\n\ninterface Columns {\n id: string;\n /** `undefined` when the host did not ask to be able to name a vertex. */\n subject: string | undefined;\n x: string;\n y: string;\n /**\n * The categorical column, when a channel named one — **and `undefined` is not a missing value.**\n *\n * It used to default to `community`, in both sources, which is this side writing what the corpus\n * owns: a relation that has no such column answered `Referenced column \"community\" not found`, and\n * one that has a differently named cluster column was silently coloured by the wrong thing. Neither\n * failure is the caller's, and both were invented here.\n *\n * Unbound, every point is one colour — which is Plot's own answer to a mark with no `fill` channel,\n * and an honest picture rather than a guess.\n */\n category: string | undefined;\n size: string | undefined;\n source: string;\n target: string;\n}\n\n/**\n * The three reads a source makes, and which of them the page can filter.\n *\n * `points` and `links` carry the crossfilter; `meta` deliberately does not. How big the corpus is,\n * where it sits and what its tile footers say are facts about the corpus rather than about the\n * page's current question — and a `total()` that shrank with the filters would make the view's own\n * \"20,000 of 1,000,000\" a fraction of itself, which is the one number a bounded renderer owes its\n * reader honestly.\n */\ninterface Reads {\n points: SliceRead;\n links: SliceRead;\n meta: SliceRead;\n}\n\nfunction openReads(coordinator: Coordinator, filterBy?: Selection): Reads {\n return {\n points: new SliceRead(coordinator, filterBy),\n links: new SliceRead(coordinator, filterBy),\n meta: new SliceRead(coordinator),\n };\n}\n\n/**\n * The metadata reads, queued behind each other.\n *\n * One client answers one question at a time — a second `ask` supersedes the first — and `total()`,\n * `extent()` and the tile probing are issued by different effects with no ordering between them. A\n * queue rather than a client each, because they *already* run one at a time: DuckDB-WASM answers\n * over one connection, measured, so three clients would buy three registrations and no concurrency.\n */\nfunction metaAsker(read: SliceRead): (sql: string) => Promise<unknown> {\n let queue: Promise<unknown> = Promise.resolve();\n return (sql) => {\n const next = queue.then(() => read.ask(() => sql));\n queue = next.catch(() => undefined);\n return next;\n };\n}\n\n/**\n * The page's predicate, as SQL text.\n *\n * The reads here are CTEs over window functions rather than builder queries — `row_number()` over the\n * visible set is what makes a slice's links speak in buffer positions — so the predicate has to be\n * interpolated rather than handed to `Query.where`. Mosaic's expression nodes stringify to the same\n * SQL the builder would emit, which is what makes that safe rather than a re-implementation.\n */\nfunction predicateSql(filter: FilterExpr | undefined): string {\n if (filter == null) return \"\";\n const list = Array.isArray(filter) ? filter : [filter];\n const clauses = list.filter((node) => node != null).map((node) => String(node));\n return clauses.length > 0 ? clauses.map((c) => `(${c})`).join(\" AND \") : \"\";\n}\n\n/** Two predicates, conjoined, where an absent one contributes nothing rather than `AND TRUE`. */\nfunction both(left: string, right: string): string {\n if (!left) return right || \"TRUE\";\n if (!right) return left;\n return `(${left}) AND (${right})`;\n}\n\n/**\n * The re-indexing happens in SQL, and that is the whole trick.\n *\n * `row_number() - 1` over the visible set gives every returned point a position in the arrays about\n * to be built, so the edge query can join to it twice and hand back links that already speak in\n * those positions. No id→index map is constructed in JavaScript — which is the 148 ms `load()` spent\n * at 200,000 nodes, gone by construction rather than by optimisation.\n *\n * The `LIMIT` sits inside the CTE, so the numbering is over what survives it. Numbering first and\n * limiting after would hand out indices into an array that was never built.\n */\n/**\n * A rectangle as a SQL predicate, and an **unbounded** rectangle as no predicate at all.\n *\n * `shouldSlice` answers `false` for a graph that fits, and the loop then asks for everything — a\n * viewport whose edges are `±Infinity`. Interpolated, that reads `x BETWEEN -Infinity AND Infinity`,\n * and SQL has no infinity literal: DuckDB parses `Infinity` as a **column name** and fails with\n * `Referenced column \"Infinity\" not found`. So an open edge contributes no clause, and a rectangle\n * open on every side is `TRUE` — which is also the right plan, because a query that wants every row\n * has nothing to prune.\n */\nfunction bboxSql(c: Columns, view: Viewport): string {\n const bounds: [string, number, string][] = [\n [c.x, view.xMin, \">=\"],\n [c.x, view.xMax, \"<=\"],\n [c.y, view.yMin, \">=\"],\n [c.y, view.yMax, \"<=\"],\n ];\n const clauses = bounds\n .filter(([, value]) => Number.isFinite(value))\n .map(([column, value, op]) => `${column} ${op} ${value}`);\n return clauses.length > 0 ? clauses.join(\" AND \") : \"TRUE\";\n}\n\n/**\n * How far apart the sampled ids are — one every `ceil(matched / limit)`.\n *\n * **In SQL rather than in JavaScript because the number it divides is only known inside the query.**\n * `matched` is a window aggregate over the rows the `WHERE` kept, so a caller wanting to compute\n * this outside would have to count first and slice second — two round trips down a connection that\n * answers one at a time, which is the shape `BENCHMARKS.md` records as a hung tab rather than a slow\n * one. As a column reference it costs the pass that was being made anyway.\n *\n * `greatest(1, …)` because an empty window makes the divisor zero, and a modulo by zero is an error\n * rather than an empty answer. At `matched <= limit` it is exactly 1 and `id % 1 = 0` keeps every\n * row: a window that fits is not sampled, it is returned.\n */\nconst strideSql = (limit: number) => `greatest(1, CAST(ceil(matched / ${limit}.0) AS BIGINT))`;\n\n/**\n * The visible set: what the rectangle matched, and the sample of it that gets drawn.\n *\n * **Two CTEs, and the second one is the whole of the far view.** `pool` is every row the predicate\n * kept, carrying `count(*) OVER ()` — a window function is evaluated over everything the `WHERE`\n * kept and `LIMIT` applies after it, so that column is the number that *matched* rather than the\n * number returned. It is what deleted the third query: `SELECT count(*) FROM … WHERE <the same\n * predicate>` was a second scan to learn a number the first scan already had to compute.\n *\n * `vis` then keeps one row in `stride`, **striding over the id rather than taking the front of the\n * ordering**, and that is the difference between a picture of the window and a picture of one corner\n * of it. A corpus numbers `dense_id` along the Morton curve, so every `s`-th id is a spatially\n * stratified sample; the same `LIMIT` with no stride returns a contiguous run of the curve, which is\n * a sub-region. Measured against the truth at screen resolution, L1@8px over blocks of eight pixels:\n * a stride sample of 20,000 scores 0.167 / 0.240 / 0.269 at 200k / 1M / 5M against a uniform null of\n * 1.044 / 0.829 / 0.731 — see `/docs/design/graph`.\n *\n * @param matched Whether to project the pre-sample count out to the caller.\n *\n * It rides on the points read only. The links read builds the same CTEs to join against and never\n * looks at the column — but it does compute it, because the stride is a function of it and both\n * reads have to select the *same* rows or a slice would draw edges to vertices it did not return.\n */\nfunction visibleCte(\n nodes: string,\n c: Columns,\n where: string,\n limit: number,\n matched = true,\n): string {\n const size = c.size ? `, ${c.size} AS size` : \"\";\n // Selected in the CTE rather than joined back afterwards: the numbering is over what survives the\n // LIMIT, and a second pass keyed on `local` would be a second scan to fetch a column the first one\n // was already standing on.\n const subject = c.subject ? `, ${c.subject} AS subject` : \"\";\n // No categorical binding, no ranking: a literal zero is the ordinal every point wears, and the\n // scale hands that one colour. Ranking a column nobody named is how a default column gets invented.\n // Ranked over the sample rather than over the window, so the ordinals are contiguous across what\n // is actually drawn — which is what the colour scale is handed.\n const category = c.category\n ? `(dense_rank() OVER (ORDER BY cat) - 1)::INTEGER AS category`\n : \"0::INTEGER AS category\";\n return `WITH pool AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y${size}${subject}${\n c.category ? `, ${c.category} AS cat` : \"\"\n },\n count(*) OVER () AS matched\n FROM ${nodes}\n WHERE ${where}\n ), vis AS (\n SELECT id, x, y${c.size ? \", size\" : \"\"}${c.subject ? \", subject\" : \"\"}${\n matched ? \", matched\" : \"\"\n },\n ${category},\n (row_number() OVER (ORDER BY id) - 1)::INTEGER AS local\n FROM pool\n WHERE id % ${strideSql(limit)} = 0\n LIMIT ${limit}\n )`;\n}\n\n/**\n * The shortest edge worth a row, as a predicate over the two endpoints — **squared, and on purpose.**\n *\n * A distance is compared against a threshold, and squaring both sides removes a `sqrt` per row from\n * a predicate evaluated once per candidate edge. It changes no answer: both sides are non-negative.\n *\n * `undefined` when the caller said nothing about resolution, and then there is no predicate at all\n * rather than a permissive one — a request with no canvas behind it (`EVERYTHING`) has no pixels to\n * measure three of.\n */\nfunction longEnough(\n a: string,\n b: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n if (perPixel === undefined || !Number.isFinite(perPixel) || perPixel <= 0) return \"\";\n const floor = minLinkPixels * perPixel;\n return `(${a}.x - ${b}.x) * (${a}.x - ${b}.x) + (${a}.y - ${b}.y) * (${a}.y - ${b}.y) >= ${floor * floor}`;\n}\n\n/**\n * The far ends, and the edges that reach them — **out of bytes the reader already fetched.**\n *\n * An edge with one end outside the rectangle is dropped today, and that loses 19.31% / 31.94% /\n * 28.92% of the edges incident to a window at 200k / 1M / 5M; 7,930 of 20,000 vertices carry at least\n * one at five million. What was missing was never the edge row — a window reads the `by_source` tiles\n * of every vertex it draws, so the row is in hand — it was **a position to draw the far end at**.\n *\n * **And a tile answers that for free.** A tile is 4,096 rows of a Morton-ordered relation and its\n * bounding box is far wider than the rows the rectangle keeps, so the vertices just outside the\n * window are usually in a tile the window already fetched. Measured over five windows of\n * `docs/public/bench/1000000`, 2026-08-19: relaxing the join from *inside the rectangle* to *inside\n * the tiles that were read* takes the drawn edges of five windows from 616,885 to 781,562 of 906,337\n * incident — **56.9% of everything the reader was losing, at no request, no query and no byte.**\n *\n * **The tile boundary is also a distance filter, and that is what settles the drawing.** Over every\n * far end a window loses, the median sits 1.64 semi-widths out and the worst 47.1 — which is why a\n * stub clipped to the viewport is a lie: nothing distinguishes 1.1 from 47. The far ends a held tile\n * can answer are the near ones: median 1.11–1.85 semi-widths, 90th percentile 1.29–2.60, worst\n * **6.74**, against 7.09–16.91 for the full reachable set. So the honest picture and the free one are\n * the same picture, and there is no trade to make: the anchors are drawn where the vertices are, and\n * the long tail stays undrawn because its bytes are not here — not because we decided.\n *\n * @param held The relation whose rows the reader is holding — the tiles a corpus fetched for this\n * window, which is the same relation the marks were read from. **Only an addressed source can name\n * one**, and that is why there is no longer a branch here for a source that cannot: over an\n * ordinary relation `held` would be the whole node table, and the join would scan the corpus twice\n * per camera move — the unbounded pattern wearing a bounded interface.\n */\nfunction anchorCte(\n held: string,\n edges: string,\n c: Columns,\n spatial: string,\n filter: string,\n perPixel: number | undefined,\n minLinkPixels: number,\n): string {\n const outside = both(`NOT (${spatial})`, filter);\n const long = longEnough(\"a\", \"b\", perPixel, minLinkPixels);\n return `, out AS (\n SELECT ${c.id} AS id, ${c.x} AS x, ${c.y} AS y FROM ${held} WHERE ${outside}\n ), reach AS (\n SELECT id, x, y FROM vis UNION ALL SELECT id, x, y FROM out\n ), span AS (\n SELECT sv.local AS src, tv.local AS dst, a.id AS src_id, b.id AS dst_id\n FROM ${edges} e\n JOIN reach a ON e.${c.source} = a.id\n JOIN reach b ON e.${c.target} = b.id\n LEFT JOIN vis sv ON sv.id = a.id\n LEFT JOIN vis tv ON tv.id = b.id\n WHERE (sv.id IS NOT NULL OR tv.id IS NOT NULL)${long ? ` AND ${long}` : \"\"}\n ), anchor AS (\n SELECT o.id, o.x, o.y,\n ((SELECT count(*) FROM vis) + row_number() OVER (ORDER BY o.id) - 1)::INTEGER AS local\n FROM out o\n WHERE o.id IN (SELECT src_id FROM span WHERE src IS NULL\n UNION SELECT dst_id FROM span WHERE dst IS NULL)\n )`;\n}\n\n/**\n * A question, in the two halves it is asked in and the one place they are put back together.\n *\n * The reads run through the coordinator, so the *same* pair of SQL builders serves both directions:\n * the camera pulling an answer, and the page's filters pushing one. `assemble` is therefore a\n * function of the two results and of nothing else — no closure over which of the two paths asked,\n * because a slice that came back because somebody brushed a histogram is the same slice.\n */\ninterface Plan {\n points: (filter: FilterExpr) => string;\n links: (filter: FilterExpr) => string;\n assemble: (points: unknown, links: unknown) => Slice;\n}\n\n/**\n * The one plan there is: a rectangle, its sample, and the edges both of whose ends survived it.\n *\n * It was called `detail` because it was one of two, and the other one — a `GROUP BY` over a\n * categorical column, one super-node per group — is gone. There is no mode to be in.\n */\nfunction region(\n nodes: string,\n edges: string,\n c: Columns,\n view: Viewport,\n limit: number,\n pinned: VertexId[] | undefined,\n perPixel: number | undefined,\n minLinkPixels: number,\n): Plan {\n const bbox = bboxSql(c, view);\n // A dragged node is drawn where the reader dropped it and indexed where it always was, so the\n // rectangle cannot find it. Riding along in the predicate is what keeps it on screen — and it\n // stays a predicate rather than a second query so the numbering still covers everything returned.\n //\n // Only this relation's own vertices: a pinned set spans the whole canvas, and asking one node\n // table for another type's dense ids returns the wrong rows rather than none. `VERTEX_TYPE` is the\n // whole of what \"this relation\" means here — see the constant.\n const mine = (pinned ?? []).filter((v) => typeOf(v) === VERTEX_TYPE).map(denseOf);\n const pins = mine.length > 0 ? ` OR ${c.id} IN (${mine.join(\",\")})` : \"\";\n const spatial = `(${bbox})${pins}`;\n /**\n * The page's predicate outside the pin, not inside it.\n *\n * A pin says *where to look*; the filters say *what exists*. Written the other way round —\n * `bbox AND filter OR pinned` — a pinned node would survive a filter that excludes it, and the\n * canvas would draw a vertex the rest of the page has agreed is not there.\n */\n const where = (filter: FilterExpr) => both(spatial, predicateSql(filter));\n const size = c.size ? \", size\" : \"\";\n const subject = c.subject ? \", subject\" : \"\";\n // The relation the marks were read from *is* what the reader is holding, so the anchor CTE is\n // unconditional: there is no source left that fetches a window without also fetching the tiles\n // around it.\n const anchors = (filter: FilterExpr) =>\n anchorCte(nodes, edges, c, spatial, predicateSql(filter), perPixel, minLinkPixels);\n\n return {\n /**\n * The marks, then the anchors, in one answer — because they are one buffer.\n *\n * `ORDER BY local` is load-bearing rather than tidy: `local` runs `0..marks-1` over the sample\n * and continues past it over the anchors, so ordering by it puts every mark before every anchor\n * and makes `marks` a prefix length. `matched` is then still read off row zero.\n */\n points: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit)}${anchors(filter)}\n SELECT local, id, x, y, category, matched, TRUE AS mark${size}${subject} FROM vis\n UNION ALL\n SELECT local, id, x, y, 0::INTEGER, NULL::BIGINT, FALSE AS mark${\n c.size ? \", CAST(NULL AS DOUBLE)\" : \"\"\n }${c.subject ? \", CAST(NULL AS VARCHAR)\" : \"\"} FROM anchor\n ORDER BY local`,\n /**\n * One end drawn and both ends positioned.\n *\n * There was a second form here — both ends drawn, edges leaving the window dropped — for a source\n * that fetched no bytes beyond the rectangle and therefore had no position to put a far end at.\n * It went with that source, and `longEnough` with it — the length predicate lives in `anchorCte`\n * now, on the same span, which is where it has to sit once the join runs over the held rows\n * rather than over the visible ones.\n */\n links: (filter) =>\n `${visibleCte(nodes, c, where(filter), limit, false)}${anchors(filter)}\n SELECT coalesce(sp.src, sa.local) AS src, coalesce(sp.dst, da.local) AS dst\n FROM span sp\n LEFT JOIN anchor sa ON sa.id = sp.src_id\n LEFT JOIN anchor da ON da.id = sp.dst_id`,\n assemble: (points, links) => ({\n /**\n * How many matched, separately from how many came back.\n *\n * Without it the view cannot tell a reader \"there is more here than I am showing you\", and a\n * truncated slice looks exactly like a complete one — which is the failure this whole branch\n * has been about. Read off the first row rather than asked for: `matched` is constant down the\n * column, and an empty answer has no row and no matches, which agree.\n */\n n: Number(numbers(points, \"matched\")[0] ?? 0),\n ...arrays(points, links, c.size ? \"size\" : undefined, c.subject !== undefined),\n }),\n };\n}\n\n/**\n * The half of a source that runs a [`Plan`], and the half that answers when nobody asked.\n *\n * Both sources need exactly this and neither should own a second copy of it — which is why it is a\n * function over the reads rather than two blocks of the same bookkeeping. What it holds is the\n * *standing* plan: the last question the camera put, kept so an answer arriving because the page\n * filtered something can be put back together the same way.\n */\nfunction watcher(reads: Reads) {\n let standing: Plan | null = null;\n let listener: ((slice: Slice) => void) | null = null;\n /** The half-answers of a push, waiting for their sibling. */\n const landed = new Map<\"points\" | \"links\", unknown>();\n\n const arrived = (half: \"points\" | \"links\") => (data: unknown) => {\n if (!standing || !listener) return;\n landed.set(half, data);\n if (landed.size < 2) return;\n const points = landed.get(\"points\");\n const links = landed.get(\"links\");\n landed.clear();\n listener(standing.assemble(points, links));\n };\n\n return {\n api: {\n /**\n * Say when the answer changes for a reason the camera cannot see.\n *\n * The reason it hands over a whole `Slice` rather than a nudge to ask again: the coordinator\n * has *already* re-run both reads with the new predicate by the time we hear about it. Asking\n * again would run the same two queries a second time to learn what is in hand.\n */\n watch(answered: (slice: Slice) => void): () => void {\n listener = answered;\n reads.points.onAnswer = arrived(\"points\");\n reads.links.onAnswer = arrived(\"links\");\n return () => {\n listener = null;\n landed.clear();\n standing = null;\n reads.points.release();\n reads.links.release();\n reads.meta.release();\n };\n },\n },\n async run(plan: Plan): Promise<Slice> {\n standing = plan;\n // Cleared because these two are halves of the *previous* question: keeping one would pair a\n // stale rectangle's points with the new rectangle's links the next time the page filters.\n landed.clear();\n const [points, links] = await Promise.all([\n reads.points.ask(plan.points),\n reads.links.ask(plan.links),\n ]);\n return plan.assemble(points, links);\n },\n };\n}\n\n/**\n * The reader's own gesture, as a clause — and the graph exempted from it.\n *\n * `clausePoints` defaults `clients` to the clause's source when that source is itself a client, which\n * is the exemption a crossfilter is built on. Here the source is a plain object and there are two\n * clients to exempt, so the set is written out: a lasso must filter the page's charts and leave the\n * canvas showing what the reader lassoed *in context*, rather than deleting everything else.\n */\nfunction publishSelection(\n reads: Reads,\n filterBy: Selection | undefined,\n idField: string,\n vertices: readonly VertexId[] | null,\n): void {\n if (!filterBy) return;\n filterBy.update(\n clausePoints([idField], vertices?.map((vertex) => [denseOf(vertex)]), {\n source: reads.points,\n clients: new Set([reads.points, reads.links]),\n }),\n );\n}\n\n/**\n * Arrow columns to the parallel typed arrays the renderer takes — sized once, filled in place.\n *\n * `fillColumn` rather than `numbers`: Arrow already hands back a typed buffer, and the obvious route\n * through `Array.from(...).map(Number)` allocates two full-length boxed arrays on the way to a third\n * that was the actual destination. Three copies to move nothing. Here the point buffers are sized\n * against the query's own `LIMIT` and each column is written straight into its stride, so `x` and\n * `y` interleave with no seam between them and no intermediate at all.\n */\nfunction arrays(\n points: unknown,\n links: unknown,\n sizeField?: string,\n withSubjects = false,\n): Omit<Slice, \"n\"> {\n // Sized from the answer rather than from the request's `limit`, which it used to be: an answer\n // carries the window's marks *and* the anchors the edges leaving it end at, so `limit` is no longer\n // an upper bound on the rows. Under-sizing here would drop the anchors silently and leave every\n // link that pointed at one indexing past the buffer.\n const rows = countOf(points, \"x\");\n const positions = new Float32Array(rows * 2);\n const n = fillColumn(points, \"x\", positions, 0, 2);\n fillColumn(points, \"y\", positions, 1, 2);\n\n // Where the marks stop. `mark` is `TRUE` down the sample and `FALSE` down the anchors, and the\n // query orders by `local`, so this is a prefix length rather than a count — which is what lets\n // `residentOf` and `buffers` treat \"is this a mark\" as an index comparison.\n const marked = new Int32Array(rows);\n fillColumn(points, \"mark\", marked);\n let marks = 0;\n while (marks < n && marked[marks] !== 0) marks++;\n\n // The dense ids land in a scratch and are widened into identities as they are copied across.\n //\n // In place, into the destination, would be better and is not available: `fillColumn` writes\n // `Number(…)`, and a `BigUint64Array` element takes a `bigint` only — assigning a `number` to one\n // throws rather than coercing. That refusal is the same guarantee this whole change is for, so the\n // extra `n`-long buffer is the price of the boundary being enforced by the runtime and not by us.\n const dense = new Float64Array(n);\n fillColumn(points, \"id\", dense);\n const vertices = new BigUint64Array(n);\n for (let i = 0; i < n; i++) vertices[i] = vertexId(VERTEX_TYPE, dense[i] as number);\n const categories = new Uint16Array(n);\n fillColumn(points, \"category\", categories);\n\n // `column` rather than `fillColumn`: an IRI is a string, so there is no typed buffer to write\n // into and no interleaving to express. It is the one thing a slice carries that never reaches the\n // GPU, which is why asking for it is a decision rather than a default.\n const subjects = withSubjects ? (column(points, \"subject\") as string[]) : undefined;\n\n let sizes: Float32Array | undefined;\n if (sizeField) {\n sizes = new Float32Array(n);\n fillColumn(points, sizeField, sizes);\n }\n\n // The edge count is not bounded by the point limit, so it is asked for rather than assumed.\n const edgeCount = countOf(links, \"src\");\n const edges = new Float32Array(edgeCount * 2);\n const wrote = fillColumn(links, \"src\", edges, 0, 2);\n fillColumn(links, \"dst\", edges, 1, 2);\n\n return {\n marks,\n vertices,\n subjects,\n positions: positions.subarray(0, n * 2),\n links: edges.subarray(0, wrote * 2),\n categories,\n sizes,\n };\n}\n\n/** A row count for a result that does not advertise one, without materialising the rows. */\nfunction countOf(rows: unknown, field: string): number {\n const advertised = (rows as { numRows?: number } | null)?.numRows;\n if (typeof advertised === \"number\") return advertised;\n const child = (rows as { getChild?: (f: string) => { length: number } | null })?.getChild?.(field);\n if (child) return child.length;\n return Array.from(rows as Iterable<unknown>).length;\n}\n\n/**\n * A corpus that fossil wrote, read by address — **and the addressing is fossil's.**\n *\n * **The five things a call site used to know, and now does not.** Drawing a corpus meant deriving\n * the chunk URLs from a `chunk_size` copied by hand, knowing how a tile is named, knowing what the\n * edge directory is called, knowing GraphAr's column names, and knowing that a glob cannot work over\n * a plain HTTP origin because there is no listing. Five conventions and about forty lines, none of\n * it the business of something that wants to draw a graph. `/docs/design/graph` carries the\n * argument; the copied `chunk_size` carries the evidence, because it went stale and read\n * a fraction of a corpus in silence for as long as it did.\n *\n * The consumer holds two things: **the corpus fossil opened, and the page's engine.**\n *\n * ```ts\n * const e = await engine();\n * const corpus = await open(\"/bench/1000000\", { query: e.query });\n * const { source } = await openCorpus({ corpus, engine: e });\n * ```\n *\n * **And this side no longer knows the conventions either, which is the change.** It used to remove\n * that defect for its callers by committing it one level down — a YAML line-scanner, a\n * `chunk{k}.parquet` spelling, a `by_source/tile{k}.parquet` spelling and a `HEAD`-probing search\n * for a tile count — and by the time those were deleted all four were **wrong**: fossil writes\n * `container: rowgroups`, one `tiles.parquet` whose row groups are the tiles, and `vertex_count`\n * had been in the manifest the whole time the search was probing for it. Fossil's `open` is\n * `fossil_graph::plan` compiled to wasm32: the arithmetic the native reader runs, not a second\n * implementation of it that agrees until it does not.\n *\n * **Addressed, not queried.** The manifests and the per-tile boxes are read once and kept; after\n * that a camera move is arithmetic over boxes and a list of URLs. There is deliberately no request\n * on the path between the camera moving and a URL being computable — the moment there is one, this\n * has become the `viewport` verb fossil deleted.\n *\n * **What it is not.** It takes no column names and no type index. Those come from the manifest or\n * they do not come. There used to be a second source here for the case this is not — an arbitrary\n * relation with `x`/`y` that nobody wrote as a corpus — and taking `idField` here would have been\n * that one with extra steps. It is gone, so the rule is simpler than the guard against it: a column\n * name reaching this door is a name the manifest should have carried.\n */\n\nexport interface OpenCorpusOptions {\n /**\n * The corpus, as fossil's `open` answered it — **opened by the host, and once.**\n *\n * This door used to open one itself from a `dest`, a `readText` and a `wasm`, which made it the\n * third open of the same corpus on a keasy discover visit: the host had already opened it to\n * query it, and opened it again only so that this could. Opening is the host's, because what it\n * takes is the host's — where the corpus is, and what signs it. Drawing takes the result.\n */\n corpus: Corpus;\n /**\n * The page's engine — `engine()` from `@kanzo-tech/mosaic`. The canvas reads its tiles and\n * publishes its clauses through the coordinator; the files are the ones the corpus already made\n * readable, by the names its addressing gives them.\n */\n engine: Pick<Engine, \"coordinator\">;\n /**\n * The crossfilter this graph draws inside — the same `Selection` the page's charts filter by.\n *\n * Given, the predicate rides in the slice query and the canvas draws what survives.\n */\n filterBy?: Selection;\n /**\n * Which vertex type to draw, when a corpus carries more than one.\n *\n * Defaults to the first the manifest names. A corpus of one type never passes it; a corpus of\n * several has to, because *which graph do you mean* is not a question a reader can answer.\n */\n vertexType?: string;\n /**\n * Read the identity column as well. **Off by default, and the same trade as everywhere else:**\n * `subject` costs about twice the drawing tile, so the drawing path carries addresses and a host\n * asks for names when something has to be *named* rather than painted.\n */\n subjects?: boolean;\n}\n\n/** A relation this source draws: its address, and the adjacency it reads it from. */\ninterface DrawnRelation {\n readonly address: EdgeAddress;\n /** The source-ordered orientation — the CSR one, which is the drawing read. */\n readonly adjacency: ProjectionAddress;\n}\n\n/** One tile's bounding box, from the footer. A tile with no `x`/`y` statistics is not in the list. */\ninterface TileBox {\n tile: number;\n x0: number;\n x1: number;\n y0: number;\n y1: number;\n}\n\n/**\n * An opened corpus: the half that **draws** and the half that **answers**.\n *\n * A host needs both over the same bytes and they are not the same access. The canvas reads tiles by\n * address — a handful of files per camera move, chosen from the footer's boxes, with no query. A\n * chart, a crossfilter clause or a verb reads the *relation*: every row, by column name, in SQL.\n * Hiding the URLs behind `source` is right for the first and leaves the second with nothing to\n * query, so this hands on the names fossil's verbs already query the corpus by.\n */\nexport interface OpenedCorpus {\n /**\n * For the canvas: `<GraphCanvas source={…}>`. Reads tiles, never the whole relation.\n *\n * A `DuckSource`, and `CorpusSource` is gone with the reason it existed: `extent()` was declared\n * there because a source over an unlaid-out relation has no answer to it, and *optional on the\n * base contract* says that better than a second interface — a relation with `x`/`y` has an extent\n * too, and it was the one host that could not frame its opening view.\n */\n source: DuckSource;\n /**\n * The vertex relation, as fossil registered it — qualified by the corpus's catalog.\n *\n * Every column the manifest declares, including the corpus' own properties — so a clause a chart\n * publishes over `kind` or `region` lands here with no translation, which is what makes one\n * crossfilter serve the canvas and the charts.\n */\n nodes: string;\n /**\n * The source-ordered edge relations this canvas draws, one fossil relation each.\n *\n * **A list, because a corpus declares a list.** This was one name, picked with `.find` over the\n * relations whose source is this type — so a corpus declaring two edge labels registered the\n * first and dropped the second with nothing raised, and a chart over `corpus_Person_edges` was a\n * chart over half the graph. `frame` on the other side takes every one of them\n * (`packages/corpus/src/corpus.ts`, `edges.filter((e) => e.srcType === address.type)`), and\n * fossil's own `verbs()` registers a view per relation under `{src}_{edge}_{dst}` rather than\n * unioning them — which is also what keeps two relations' differing property columns from having\n * to agree on one schema.\n *\n * Empty when the corpus declares no relation this source can draw; {@link OpenedCorpus.undrawn}\n * is then where the ones it declared went.\n */\n edges: readonly EdgeRelation[];\n /**\n * The relations incident to this type that the canvas does **not** read, and why.\n *\n * A single-type canvas can draw a relation only where *both* endpoints are numbered in its own\n * `dense_id` space. The rest are dropped from the read rather than mixed into it, and reported\n * here rather than dropped in silence.\n */\n undrawn: readonly UndrawnRelation[];\n}\n\n/** One edge relation of the drawn type, and the relation fossil registered its adjacency as. */\nexport interface EdgeRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n /** Fossil's relation, qualified by the corpus's catalog — its `CorpusRelation.sql`. */\n readonly view: string;\n}\n\n/**\n * One relation incident to the drawn type that this source cannot read, and why.\n *\n * **The reason is fossil's {@link GapReason} and not a second spelling of it** — `other-space` when\n * an endpoint is another vertex type, so the `dense_id` on that side numbers a different set of\n * vertices and this source holds no coordinates for any of them; `not-declared` when both endpoints\n * are this type and the corpus publishes no source-ordered adjacency to read. The two the drawing\n * read can produce; the third, `not-requested`, is `tilesFor`'s and never reaches here.\n *\n * What this adds to a `Gap` is the endpoint pair, which a `Gap` does not carry: it names a relation\n * by label and orientation, and a corpus may declare two relations under one label.\n */\nexport interface UndrawnRelation {\n readonly edgeType: string;\n readonly srcType: string;\n readonly dstType: string;\n readonly reason: GapReason;\n}\n\nexport async function openCorpus(options: OpenCorpusOptions): Promise<OpenedCorpus> {\n const { corpus, engine, filterBy, subjects = false, vertexType } = options;\n const { addressing } = corpus;\n\n const reads = openReads(engine.coordinator, filterBy);\n const meta = metaAsker(reads.meta);\n const watching = watcher(reads);\n\n const type = addressing.vertexType(vertexType);\n /**\n * Which relations this canvas may draw, and which incident ones it may not — **asked, not\n * derived.**\n *\n * A picture is one type's `dense_id` space, so a relation that LEAVES the type has its far ends\n * numbered in another type's, both spaces are dense from zero, and `BIGINT` compares against\n * `BIGINT` without complaining: the join matches and draws a line between two vertices with\n * nothing between them. The rule that refuses it used to be written here too — a filter over\n * `incident` on `srcType`/`dstType` plus a null check on the source-ordered adjacency — which was\n * the second copy of a rule that belongs to the addressing. It is `ReadPlan::drawing`\n * (`crates/fossil-graph/src/plan.rs`), in Rust, said once, and this is the call.\n *\n * **Not `tilesFor`, which answers the other question.** Its `edgeUrls` is every relation whose\n * *source* is this type, cross-type ones included — right for incidence, unusable for a picture.\n * Confusing the two is the bug this call closes.\n *\n * `by_target` is not asked for and its absence is reported rather than hidden: a window's\n * drawable edges all have their source on screen, so the source-aligned tiles are complete for\n * drawing and incomplete for incidence. `tilesFor` says which below, in `gaps`.\n */\n const drawing = addressing.drawing(type.type);\n const drawn: DrawnRelation[] = drawing.relations.map((address) => ({\n address,\n // Never null on a drawn relation: a declared source-ordered adjacency is what `drawing` admits\n // one for, and `not-declared` is why the others are in `undrawn` instead.\n adjacency: address.adjacency(\"src\") as ProjectionAddress,\n }));\n const adjacencies = drawn.map((relation) => relation.adjacency);\n /**\n * The rejected ones, with their endpoints — **the reasons are fossil's, the endpoints are the\n * corpus's, and neither is a judgement of ours.**\n *\n * The two lists are one partition of `incident` in declaration order: fossil walks the plan's\n * edges once and puts each incident one in `relations` or in `undrawn`, so the incident relations\n * missing from the first are the gaps of the second, in order. That join is here because a `Gap`\n * names a relation by label and orientation, and a label does not identify one — two relations\n * may share it — so the endpoint types cannot be read back off the gap alone.\n */\n const undrawn: UndrawnRelation[] = addressing\n .incident(type.type)\n .filter((edge) => !drawing.relations.includes(edge))\n .map((edge, index) => ({\n edgeType: edge.edgeType,\n srcType: edge.srcType,\n dstType: edge.dstType,\n reason: drawing.undrawn[index]!.reason,\n }));\n\n /**\n * The boxes, and the one query this source makes that is not a slice.\n *\n * Read on first use rather than in the factory: a host that constructs a source and never draws\n * should not pay for it, and the cost is a footer read over the payload. Kept forever after —\n * tiles are precomputed and their boxes cannot move without the corpus being rewritten.\n *\n * **Held as the promise rather than as the answer**, which the move to one shared metadata client\n * forced and which was a latent defect before it: `total()` and `extent()` are called by different\n * effects with nothing ordering them, so two loads used to run concurrently and probe the whole\n * tile range twice.\n */\n let loading: Promise<TileBox[]> | null = null;\n\n /**\n * Where the payload is, as the list of files that hold it.\n *\n * One file per tile under `container: files`, one file in total under `rowgroups` — and this side\n * does not know or care which, because the address is asked for rather than composed. Throws when\n * the manifest declares no `vertex_count`, which is the one absence that makes a corpus\n * un-enumerable.\n */\n const payloadFiles = type.files();\n /** A URL list as a SQL list literal. Every read below composes one and none of them composes a URL. */\n const quoted = (urls: readonly string[]) =>\n urls.map((url) => `'${url.replace(/'/g, \"''\")}'`).join(\", \");\n\n /**\n * The boxes, from the footer — **which tile a row group is, asked of the addressing.**\n *\n * `parquet_metadata` reports a `file_name` and a `row_group_id`, and which of the two names the\n * tile is the container's business: under `rowgroups` row group `k` IS tile `k`; under `files`\n * the file is, and its row groups are the writer's business, so their boxes are merged. This used\n * to read the tile out of the URL with `regexp_extract(file_name, 'chunk(\\d+)')` — one\n * container's spelling hard-coded into a query, and `NULL` for every row of a corpus fossil\n * writes today.\n *\n * `min_value`/`max_value`, never `min`/`max`. Parquet's original statistics fields are defined by\n * *signed* byte comparison, which is meaningless for an unsigned column — a writer that gets this\n * right leaves them empty. A reader that only knows the deprecated pair concludes the footer\n * carries no box for the column the whole address is built on. `coalesce` keeps the float columns\n * working either way.\n */\n function load(): Promise<TileBox[]> {\n loading ??= (async () => {\n const files = quoted(payloadFiles);\n const [xCol, yCol] = PAYLOAD_COORDINATES;\n // Which of `file_name` and `row_group_id` names the tile, as an expression rather than as a\n // branch in JavaScript: the grouping has to happen where the rows are either way.\n const tile =\n addressing.container === \"rowgroups\"\n ? \"row_group_id\"\n : `list_position([${files}], file_name) - 1`;\n const stats = await meta(\n `SELECT ${tile} AS tile,\n min(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS x0,\n max(CASE WHEN path_in_schema = '${xCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS x1,\n min(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_min_value, stats_min)::DOUBLE END) AS y0,\n max(CASE WHEN path_in_schema = '${yCol}' THEN coalesce(stats_max_value, stats_max)::DOUBLE END) AS y1\n FROM parquet_metadata([${files}])\n WHERE path_in_schema IN ('${xCol}', '${yCol}') GROUP BY 1 ORDER BY 1`,\n );\n const tiles = numbers(stats, \"tile\");\n const [x0, x1, y0, y1] = [\"x0\", \"x1\", \"y0\", \"y1\"].map((f) => numbers(stats, f));\n return tiles.map((t, i) => ({\n tile: t as number,\n x0: x0?.[i] as number,\n x1: x1?.[i] as number,\n y0: y0?.[i] as number,\n y1: y1?.[i] as number,\n }));\n })();\n return loading;\n }\n\n /** The tiles a rectangle touches. Pure — this is the whole of the selection, and it makes no call. */\n function intersecting(all: TileBox[], view: Viewport): number[] {\n return all\n .filter((b) => b.x1 >= view.xMin && b.x0 <= view.xMax && b.y1 >= view.yMin && b.y0 <= view.yMax)\n .map((b) => b.tile);\n }\n\n /**\n * Everything the corpus fixes, and nothing it does not — **by ROLE, not by name.**\n *\n * The address, the identity and the coordinates are facts of the format, and the names they are\n * written under come off `@fossil-lang/corpus`'s generated column table rather than four string\n * literals here. The endpoint columns come off the adjacency's own address. What colours and what\n * sizes are channels, so they arrive with the request and are filled in per slice.\n */\n const fixed = {\n id: PAYLOAD_ADDRESS[0] as string,\n subject: subjects ? (PAYLOAD_IDENTITY[0] as string) : undefined,\n x: PAYLOAD_COORDINATES[0] as string,\n y: PAYLOAD_COORDINATES[1] as string,\n source: adjacencies[0]?.column ?? \"src_dense\",\n target: drawn[0]?.address.adjacency(\"dst\")?.column ?? \"dst_dense\",\n } as const;\n\n /**\n * The relation half — **fossil's views, not a second set of ours.**\n *\n * A chart, a count or a crossfilter clause reads every row by name, so the canvas has to hand a\n * host relation names as well as tiles. This used to register its own: persistent `corpus_*`\n * views over the same files fossil's verbs already viewed as `TEMP`, with a different rule for\n * which edge files a relation is — two view sets over one corpus, and the persistent one outlived\n * it. `relations()` answers with the names the verbs query, qualified by the corpus's own catalog,\n * so closing the corpus takes them with it.\n */\n const relations = await corpus.relations();\n const relationOf = (match: (r: CorpusRelation) => boolean, what: string): string => {\n const found = relations.find(match);\n if (!found) throw new Error(`openCorpus: the corpus registers no relation for ${what}`);\n return found.sql;\n };\n const nodesView = relationOf((r) => r.kind === \"vertex\" && r.name === type.type, type.type);\n\n const source: DuckSource = {\n ...watching.api,\n\n publish(vertices) {\n publishSelection(reads, filterBy, fixed.id, vertices);\n },\n\n /**\n * How many vertices there are — **read, not probed.**\n *\n * The manifest declares `vertex_count`. What stood here was a note saying no manifest carries\n * it, and a doubling-then-bisecting `HEAD` search for the last chunk plus a\n * `parquet_file_metadata` read of its row count — about a dozen requests and a query, per\n * corpus, for a number already in hand.\n */\n async total() {\n if (type.count !== null) return Number(type.count);\n const rows = await meta(`SELECT count(*) AS n FROM ${nodesView}`);\n return Number(numbers(rows, \"n\")[0] ?? 0);\n },\n\n async extent() {\n const all = await load();\n return {\n xMin: Math.min(...all.map((b) => b.x0)),\n yMin: Math.min(...all.map((b) => b.y0)),\n xMax: Math.max(...all.map((b) => b.x1)),\n yMax: Math.max(...all.map((b) => b.y1)),\n };\n },\n\n // Regions only, and it says so by being the only method there is. A neighbourhood needs\n // adjacency this source does not index; `ExploringSource` declared the second question here and\n // was deleted with it — `index.test.ts` carries why, and fossil's `expand` is what answers it.\n async slice(request: SliceRequest): Promise<Slice> {\n const { fill, limit, minLinkPixels, perPixel, pinned, r, signal, view } = request;\n const columns: Columns = { ...fixed, category: fill, size: r };\n const all = await load();\n /**\n * The tiles the rectangle touches — **and the far view is not a special case of this.**\n *\n * It used to be: past a zoom threshold the selection was replaced by every tile, because the\n * aggregate branch was going to read the whole relation anyway. A window that covers the\n * extent already intersects every box, so the branch was arithmetic restating itself, and it\n * is the reason `Viewport` carried a `zoom` at all.\n */\n const selected = intersecting(all, view);\n // Nothing selected is a legitimate answer — the camera is over empty space — and asking\n // `read_parquet([])` is a syntax error rather than an empty result. Checked before the files\n // are fetched, so an empty window costs no bytes at all.\n if (selected.length === 0) {\n return {\n n: 0,\n marks: 0,\n vertices: new BigUint64Array(0),\n positions: new Float32Array(0),\n links: new Float32Array(0),\n categories: new Uint16Array(0),\n };\n }\n\n /**\n * The URLs those tiles are in — **asked, not composed**, and distinct.\n *\n * The vertex half off `tilesFor`; the edge half off each drawn relation's own adjacency,\n * which is `frame`'s shape for the same reason (`corpus.ts`, `edgeLevels.flatMap((set) =>\n * held.map((tile) => set.tileUrl(tile)))`). `tilesFor`'s `edgeUrls` is every relation whose\n * SOURCE is this type, so a cross-type one is in it — and `drawing` is where that set is\n * narrowed to the relations whose far end this source can position. The source-ordered\n * orientation is the drawing read either way: every edge a window can draw has its source on\n * screen, therefore in one of these files.\n */\n const addressed = addressing.tilesFor({\n type: type.type,\n tiles: selected,\n directions: [\"src\"],\n });\n const edgeUrls = [\n ...new Set(adjacencies.flatMap((a) => selected.map((tile) => a.tileUrl(tile)))),\n ];\n // Both halves at once: the vertex files and the edge files a window touches are independent\n // reads, and the window is not drawable until both have landed.\n /**\n * The camera moved while the tiles were arriving, so this question is already the wrong one.\n *\n * Checked here rather than left to the caller because of what comes next: the reads hold one\n * standing question each, so a request that resumes after the loop moved on would *supersede*\n * the newer one and reject it — the stale question winning the race against the live one.\n *\n * `signal.reason` and not a sentinel of ours: an aborted signal already carries what it was\n * aborted with, which for `AbortController.abort()` is a `DOMException` named `AbortError`.\n * Rethrowing it is the whole of the cancellation contract a source owes — see `SliceRequest`.\n */\n if (signal?.aborted) throw signal.reason;\n const nodes = `read_parquet([${quoted(addressed.vertexUrls)}])`;\n // A corpus that declares no adjacency for this type still has to answer: the links query is\n // built either way, so what it reads is an empty relation of the right shape rather than a\n // `read_parquet([])`, which is a syntax error, or the vertex view, which has neither column.\n const relation =\n edgeUrls.length > 0\n ? `read_parquet([${quoted(edgeUrls)}])`\n : `(SELECT NULL::BIGINT AS ${columns.source}, NULL::BIGINT AS ${columns.target} WHERE FALSE)`;\n\n // `nodes` is both the window's rows and the bytes the reader holds: the tiles that answer\n // \"what is in the rectangle\" are the tiles that answer \"where is the far end of an edge that\n // leaves it\".\n return watching.run(region(nodes, relation, columns, view, limit, pinned, perPixel, minLinkPixels));\n },\n };\n\n return {\n source,\n nodes: nodesView,\n edges: drawn.map(({ address }) => ({\n edgeType: address.edgeType,\n srcType: address.srcType,\n dstType: address.dstType,\n view: relationOf(\n (r) =>\n r.kind === \"edge\" &&\n r.edgeType === address.edgeType &&\n r.srcType === address.srcType &&\n r.dstType === address.dstType,\n `${address.srcType}_${address.edgeType}_${address.dstType}`,\n ),\n })),\n undrawn,\n };\n}\n"],"names":[],"mappings":";;;;;AAuEA;AAwCA;AACE;AAAO;AACsC;AACD;AACX;AAEnC;AAUA;AACE;AACA;AACE;AACA;AAAmB;AACZ;AAEX;AAUA;AACE;AAEA;AACA;AACF;AAGA;AACE;AAGF;AAuBA;AAOE;AAN2C;AACpB;AACA;AACA;AACA;AAKvB;AACF;AAeA;AAyBA;AAOE;AAYA;AAAO;AAGL;AAAA;AAEY;AACC;AAAA;AAIb;AACiB;AAAA;AAAA;AAGY;AAChB;AAEjB;AAYA;AAME;AACA;AACA;AACF;AA+BA;AASE;AAEA;AAAO;AACsE;AAAA;AAAA;AAAA;AAAA;AAK/D;AACgB;AACA;AAAA;AAAA;AAG8C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQ9E;AAsBA;AAUE;AA2BA;AAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAS4D;AACS;AAAA;AAI1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYwB;AAAA;AAAA;AAAA;AAAA;AAK1C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASgB;AACiC;AAAA;AAGnF;AAUA;AACE;AAGA;AAKE;AACA;AAEA;AACyC;AAG3C;AAAO;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASD;AAIE;AAKW;AACb;AACF;AAAA;AAGA;AAIA;AAA0C;AACZ;AACF;AAE5B;AAAkC;AACpC;AAEJ;AAUA;AAME;AACS;AAC+D;AACtD;AAC8B;AAC7C;AAEL;AAWA;AAUE;AAGA;AAKA;AACA;AACA;AACA;AAQA;AACA;AACA;AACA;AACA;AACA;AAKA;AAEA;AACA;AAMA;AAGA;AAEO;AACL;AACA;AACA;AACsC;AACJ;AAClC;AACA;AAEJ;AAGA;;AACE;AACA;AACA;AACA;AAEF;AAgLA;;AACE;AA6BmE;AACjE;AAAA;AAAA;AAGkC;AAgBX;AACN;AACD;AACA;AACkB;AAepC;AAUA;AAqBA;AACE;AACE;AAQoB;AACJ;AAC2B;AACA;AACA;AACA;AACV;AACa;AAI9C;AAA4B;AACpB;AACG;AACA;AACA;AACA;AACT;AAEG;AAIT;AACE;AAEoB;AAWtB;AAAc;AACS;AACiC;AAC9B;AACA;AACU;AACoB;AAetD;AACA;AACA;AAAa;AAkHf;AAAO;AA9GoB;AACb;AAGV;AAAoD;AACtD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWE;AACA;AACA;AAAwC;AAC1C;AAGE;AACA;AAAO;AACiC;AACA;AACA;AACA;AAAA;AAE1C;AAAA;AAAA;AAAA;AAME;AAeA;AACE;AAAO;AACF;AACI;AACuB;AACD;AACJ;AACI;AAejC;AAAsC;AACzB;AACJ;AACW;AAEH;AAC+D;AAehF;AACA;AAYA;AAAkG;AACpG;AAAA;AAKO;AAC4B;AACf;AACD;AACA;AACX;AAKoB;AACiC;AAAA;AAE3D;AACF;AAEJ;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/graph",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Kanzo graph rendering — a GPU force layout over cosmos.gl, the DuckDB relation that feeds it, and the Mosaic client that keeps it inside a crossfilter. Sibling of @kanzo-tech/ui, not part of it: the admission rules exclude graphs from the generic vocabulary by name.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "./package.json": "./package.json"
28
28
  },
29
29
  "//peers": "cosmos.gl is a real, required peer — this package is a renderer and there is nothing left of it without one. The Mosaic half is optional and lives entirely on `./duckdb`, and the reason moved when the second source left: it used to be `a consumer drawing a graph from arrays it already has should not pay for a database`, which stopped being true the day `memorySource` was deleted — there is no arrays-in-hand consumer to protect any more. What the split protects now is the ROOT BARREL, which is a rendering surface: hooks, looks, identity, the buffers a slice implies, and no source at all. A host embedding a canvas whose slices arrive from somewhere else — its own `BoundedSource`, a worker, a test — installs cosmos.gl and nothing else, and `openCorpus` is what costs a database. That half is now ONE entry, `@kanzo-tech/mosaic`, and the list is shorter than the thing it replaced for a reason worth keeping: this package used to reach the coordinator through `@kanzo-tech/ui/analytics`, whose barrel re-exports the React charts and `@uwdata/vgplot` with them. The import is static, so a host that installed the two peers we documented — mosaic-core and mosaic-sql — still could not open `./duckdb`, and vgplot had to be declared here to paper over a dependency this package never names. It names none of the three now: they are `@kanzo-tech/mosaic`'s peers, declared once, which is also why their version ranges can no longer disagree. @duckdb/duckdb-wasm stays absent for the reason it always was — nothing in any of our dist names it, and a host that boots its own DuckDB depends on it directly. `scripts/smoke-install.mjs` installs no optional peer and imports the root barrel, which is the check that reads this list rather than trusting it.",
30
- "//fossil": "The reader is on npm, so the range is a range. This entry said LOCAL LINK, NOT A RELEASE STATE while `@fossil-lang/corpus` existed only in a checkout beside this one; it was published as `0.3.0-alpha.5` and the `link:` and the `*` went with the reason for them. The peer itself is not temporary: fossil's reader sits beside `@kanzo-tech/mosaic` on `./duckdb` and is wanted for the same reason — a host reading a corpus brings its own, and a host that only renders pays for neither. The residual cost this entry used to name is GONE rather than mitigated: `duckBoundedSource` shared the subpath and did not need this peer, so a host on it either installed the reader or the entry had to be split. There is nothing on `./duckdb` now that does not want fossil, so the subpath and the peer are the same decision and there is no entry to split. The floor is `0.3.0-alpha.8` because that release renamed `wasmUrl` to `wasm` and resolves its module as a bundler asset; `openCorpus` passes `wasm` through and has nothing to hand an older reader.",
30
+ "//fossil": "The reader is on npm, so the range is a range. This entry said LOCAL LINK, NOT A RELEASE STATE while `@fossil-lang/corpus` existed only in a checkout beside this one; it was published as `0.3.0-alpha.5` and the `link:` and the `*` went with the reason for them. The peer itself is not temporary: fossil's reader sits beside `@kanzo-tech/mosaic` on `./duckdb` and is wanted for the same reason — a host reading a corpus brings its own, and a host that only renders pays for neither. The residual cost this entry used to name is GONE rather than mitigated: `duckBoundedSource` shared the subpath and did not need this peer, so a host on it either installed the reader or the entry had to be split. There is nothing on `./duckdb` now that does not want fossil, so the subpath and the peer are the same decision and there is no entry to split. The floor is `0.3.0-alpha.10` because `openCorpus` takes the corpus the host opened and hands on the names fossil's verbs query it by — `CorpusRelation.sql`, qualified by the corpus's own catalog — and that release is the one that answers with them.",
31
31
  "peerDependencies": {
32
32
  "@cosmos.gl/graph": "^3.4.0",
33
- "@fossil-lang/corpus": "^0.3.0-alpha.8",
33
+ "@fossil-lang/corpus": "^0.3.0-alpha.10",
34
34
  "react": "^19.0.0",
35
- "@kanzo-tech/mosaic": "0.8.0",
36
- "@kanzo-tech/ui": "0.8.0"
35
+ "@kanzo-tech/mosaic": "0.9.0",
36
+ "@kanzo-tech/ui": "0.9.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
39
  "@fossil-lang/corpus": {
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@cosmos.gl/graph": "^3.4.0",
48
- "@fossil-lang/corpus": "^0.3.0-alpha.8",
48
+ "@fossil-lang/corpus": "^0.3.0-alpha.10",
49
49
  "@testing-library/dom": "^10.4.1",
50
50
  "@testing-library/react": "^16.3.2",
51
51
  "@types/react": "^19.0.0",
@@ -56,7 +56,7 @@
56
56
  "react": "^19.0.0",
57
57
  "react-dom": "^19.0.0",
58
58
  "rollup-plugin-preserve-directives": "^0.4.0",
59
- "@kanzo-tech/mosaic": "0.8.0"
59
+ "@kanzo-tech/mosaic": "0.9.0"
60
60
  },
61
61
  "repository": {
62
62
  "type": "git",