@littlebigbrain/create 0.3.0 → 0.5.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
@@ -21,9 +21,12 @@ npm test
21
21
  | `documents-map` | Maps the links of `documents-link` into the graph's own ontology, as fact proposals |
22
22
  | `review` | Writes the fact proposals at or above a gate as facts and gives the others to a person |
23
23
  | `link` | Connects the mentions of one thing across the records of a graph into one thing, and proposes records of one thing as an identity |
24
+ | `rows` | Reads each CSV or TSV file into one instance per row, each with an edge to the file whose evidence is the row |
25
+ | `sections` | Reads each Markdown file into its sections, each with an edge to its parent whose evidence is its lines |
24
26
 
25
27
  A template id is the id of its starter workflow, with dashes for dots:
26
- `documents-link` is the code of `documents.link`.
28
+ `documents-link` is the code of `documents.link`. `link`, `rows` and
29
+ `sections` have no LBB-run version.
27
30
 
28
31
  ## The project
29
32
 
@@ -32,7 +35,7 @@ A template id is the id of its starter workflow, with dashes for dots:
32
35
  | `src/workflow.ts` | The workflow, its settings, the triggered workflow that sends it the instances (`trigger()`) and the ontology it writes (`ontology()`) |
33
36
  | `src/*.ts` | The template's helpers and `samples.ts`, which puts the samples on a graph |
34
37
  | `src/workflow.test.ts`, `src/testing.ts` | The workflow on its samples, with mock models and no server |
35
- | `src/worker.ts`, `src/setup.ts`, `src/try.ts` | `npm start`, `npm run setup` and `npm run try`; a template that has its own (`link`) keeps them |
38
+ | `src/worker.ts`, `src/setup.ts`, `src/try.ts` | `npm start`, `npm run setup` and `npm run try`; a template that has its own (`link`, `rows`, `sections`) keeps them |
36
39
  | `src/grade-run.ts` | `npm run grade`, in a template that grades a run against a gold file (`link`) |
37
40
  | `samples/` | The samples and their answer key |
38
41
 
package/lib/create.mjs CHANGED
@@ -22,6 +22,8 @@ export const TEMPLATES = [
22
22
  "values-extract",
23
23
  "ontology-fit",
24
24
  "link",
25
+ "rows",
26
+ "sections",
25
27
  ];
26
28
 
27
29
  const PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), "..");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/create",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Create a project from a little big brain starter workflow template: npm create @littlebigbrain",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -342,6 +342,209 @@ export class MockActivities implements LbbActivities {
342
342
  }
343
343
  }
344
344
 
345
+ // ---- files and derivations ---------------------------------------------------------
346
+
347
+ /** A file the mock holds: its bytes, name, media type and `Document` IRI. */
348
+ export interface MockFile {
349
+ sha256: string;
350
+ name: string;
351
+ mediaType: string;
352
+ document: string;
353
+ bytes: Uint8Array;
354
+ }
355
+
356
+ /**
357
+ * The files and the derivations of a server, for the templates that read
358
+ * files: `files.content` and `workflows.derivations.check` / `record`, the
359
+ * shape of an `LbbClient`. `upload` puts the file's `Document` in `graph`
360
+ * with its name, SHA-256 and media type, as the upload route does.
361
+ *
362
+ * Each upload has its `uploaded_at`, as the server's file record: an upload
363
+ * of a live file keeps it, an upload after `remove` gets a new one. A
364
+ * derivation belongs to the upload of its record. The check names the
365
+ * upload of a live file; a removed file has no upload and no derivation.
366
+ * A record that names another upload than the live one throws `LbbError`
367
+ * 409 `derivation_upload_changed`.
368
+ */
369
+ export class MockContent {
370
+ readonly held = new Map<string, MockFile>();
371
+ readonly recorded = new Map<string, Schemas["Derivation"]>();
372
+ /** The upload of each content's file, live or removed. */
373
+ readonly uploads = new Map<string, { uploadedAt: string; live: boolean }>();
374
+ /** The upload each recorded derivation belongs to. */
375
+ private readonly stamps = new Map<string, string | undefined>();
376
+ /** Every derivation record request, in order. */
377
+ readonly records: Schemas["DerivationRecordRequest"][] = [];
378
+ /** Reads of a file's bytes. */
379
+ reads = 0;
380
+ /** Derivation checks. */
381
+ checks = 0;
382
+
383
+ constructor(readonly graph: MemoryGraph) {}
384
+
385
+ /** Hold a file and write its `Document`. */
386
+ upload(name: string, body: string | Uint8Array, mediaType: string): MockFile {
387
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
388
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
389
+ if (!this.uploads.get(sha256)?.live) {
390
+ this.newUploads += 1;
391
+ const at = new Date(Date.UTC(2026, 9, 15, 9) + this.newUploads * 1_000);
392
+ this.uploads.set(sha256, { uploadedAt: at.toISOString(), live: true });
393
+ }
394
+ const doc = this.graph.entity({ type: "Document", name, key: `file:${sha256}` });
395
+ doc.name = name;
396
+ Object.assign(doc.properties, {
397
+ name,
398
+ file_sha256: sha256,
399
+ content_type: mediaType,
400
+ file_bytes: bytes.length,
401
+ });
402
+ const file = { sha256, name, mediaType, document: doc.iri, bytes };
403
+ this.held.set(sha256, file);
404
+ return file;
405
+ }
406
+
407
+ /** Uploads that gave a content a new `uploaded_at`. */
408
+ private newUploads = 0;
409
+
410
+ /** Delete a file, as `DELETE /v1/files`: its bytes are no longer read; its `Document` stays here. */
411
+ remove(sha256: string): void {
412
+ const upload = this.uploads.get(sha256);
413
+ if (!upload?.live) throw new Error(`no live file ${sha256}`);
414
+ this.uploads.set(sha256, { ...upload, live: false });
415
+ this.held.delete(sha256);
416
+ }
417
+
418
+ /** The `uploaded_at` of a content's file. */
419
+ uploadedAt(sha256: string): string | undefined {
420
+ return this.uploads.get(sha256)?.uploadedAt;
421
+ }
422
+
423
+ /** The byte range of each read, `null` for a whole file, in order. */
424
+ readonly ranges: Array<{ start: number; end: number | null } | null> = [];
425
+ /** The bytes all reads answered. */
426
+ bytesRead = 0;
427
+
428
+ /**
429
+ * A file's bytes, or with `range` bytes `start` to `end` (both included),
430
+ * as `GET /v1/files/content` answers: a range past the end ends at the
431
+ * end, and a range from the end on (a file without bytes too) throws
432
+ * `LbbError` 416 `range_not_satisfiable`.
433
+ */
434
+ readonly files = {
435
+ content: async (
436
+ sha256: string,
437
+ options: { range?: { start: number; end?: number } } = {},
438
+ ): Promise<Uint8Array> => {
439
+ this.reads += 1;
440
+ const file = this.held.get(sha256);
441
+ if (!file)
442
+ throw new LbbError(404, "{}", { code: "file_not_found", message: `no file ${sha256}` });
443
+ const range = options.range;
444
+ this.ranges.push(range ? { start: range.start, end: range.end ?? null } : null);
445
+ if (!range) {
446
+ this.bytesRead += file.bytes.length;
447
+ return file.bytes;
448
+ }
449
+ if (range.start >= file.bytes.length)
450
+ throw new LbbError(416, "{}", {
451
+ code: "range_not_satisfiable",
452
+ message: `no byte of the file from ${range.start} on`,
453
+ });
454
+ const end = Math.min(range.end ?? file.bytes.length - 1, file.bytes.length - 1);
455
+ const bytes = file.bytes.slice(range.start, end + 1);
456
+ this.bytesRead += bytes.length;
457
+ return bytes;
458
+ },
459
+ };
460
+
461
+ readonly workflows = {
462
+ derivations: {
463
+ check: async (key: Schemas["DerivationKey"]): Promise<Schemas["DerivationCheckResponse"]> => {
464
+ this.checks += 1;
465
+ const file = this.uploads.get(key.sha256);
466
+ const upload = file?.live ? file.uploadedAt : undefined;
467
+ const derivation =
468
+ file && !file.live ? undefined : this.current(derivationName(key), key.sha256);
469
+ return {
470
+ exists: Boolean(derivation),
471
+ ...(derivation ? { derivation } : {}),
472
+ ...(upload ? { content_uploaded_at: upload } : {}),
473
+ };
474
+ },
475
+ record: async (
476
+ request: Schemas["DerivationRecordRequest"],
477
+ ): Promise<Schemas["DerivationRecordResponse"]> => {
478
+ this.records.push(request);
479
+ const named = request.content_uploaded_at;
480
+ const upload = this.uploads.get(request.sha256);
481
+ if (named && !(upload?.live && upload.uploadedAt === named))
482
+ throw new LbbError(409, "{}", {
483
+ code: "derivation_upload_changed",
484
+ message: `the file was deleted since upload ${named}: nothing was recorded`,
485
+ param: "content_uploaded_at",
486
+ });
487
+ const name = derivationName(request);
488
+ const kept = this.current(name, request.sha256);
489
+ if (kept) return { recorded: false, derivation: kept };
490
+ const outputs = JSON.stringify(request.outputs ?? null);
491
+ if (outputs.length > 16 * 1024)
492
+ throw new LbbError(400, "{}", {
493
+ code: "derivation_invalid",
494
+ message: "outputs over 16 KiB",
495
+ });
496
+ const derivation = {
497
+ sha256: request.sha256,
498
+ workflow_type: request.workflow_type,
499
+ version: request.version,
500
+ settings_digest: request.settings_digest,
501
+ run_id: request.run_id ?? null,
502
+ outputs: JSON.parse(outputs) as unknown,
503
+ recorded_at: "2026-10-15T09:00:00Z",
504
+ };
505
+ this.recorded.set(name, derivation);
506
+ this.stamps.set(name, upload?.uploadedAt);
507
+ return { recorded: true, derivation };
508
+ },
509
+ },
510
+ };
511
+
512
+ /** The derivation `name` when it belongs to the current upload of its content. */
513
+ private current(name: string, sha256: string): Schemas["Derivation"] | undefined {
514
+ const derivation = this.recorded.get(name);
515
+ const upload = this.uploadedAt(sha256);
516
+ if (!derivation || (upload !== undefined && this.stamps.get(name) !== upload)) return undefined;
517
+ return derivation;
518
+ }
519
+ }
520
+
521
+ const derivationName = (key: Schemas["DerivationKey"]) =>
522
+ `${key.workflow_type}@${key.version}/${key.settings_digest}/${key.sha256}`;
523
+
524
+ /** The message a content trigger sends for one upload: its `Document`, with origin `content`. */
525
+ export function contentMessage(
526
+ workflow: string,
527
+ file: Pick<MockFile, "sha256" | "mediaType" | "document">,
528
+ ): WorkflowTriggerMessage {
529
+ return {
530
+ type: "instances",
531
+ workflow,
532
+ graph: "samples",
533
+ as_of_commit_seq: 7,
534
+ reason: "changed",
535
+ instances: [file.document],
536
+ removed: [],
537
+ origin: {
538
+ kind: "content",
539
+ trigger: workflow,
540
+ sha256: file.sha256,
541
+ media_type: file.mediaType,
542
+ uploaded_at_ms: 1791967651158,
543
+ requests: [`file-upload:${file.sha256}`],
544
+ },
545
+ };
546
+ }
547
+
345
548
  function assertNever(what: string): never {
346
549
  throw new Error(`the test gives the mock no ${what} rule`);
347
550
  }
@@ -395,6 +598,27 @@ export class Crash extends Error {
395
598
 
396
599
  const STEP_KEY = /^[A-Za-z0-9_.-]{1,128}$/;
397
600
 
601
+ /**
602
+ * A workflow value as JSON, as the SDK's worker writes it: a value JSON
603
+ * cannot hold (undefined, a function, a number that is not finite) fails
604
+ * the turn there, so it fails here too.
605
+ */
606
+ function workflowJson(value: unknown, where: string): string {
607
+ return JSON.stringify(value, (key, v: unknown) => {
608
+ const bad =
609
+ v === undefined ||
610
+ typeof v === "function" ||
611
+ typeof v === "symbol" ||
612
+ typeof v === "bigint" ||
613
+ (typeof v === "number" && !Number.isFinite(v));
614
+ if (bad)
615
+ throw new Error(
616
+ `Workflow values must be JSON serializable (use null for no result): ${where}${key ? `.${key}` : ""} is ${typeof v}`,
617
+ );
618
+ return v;
619
+ });
620
+ }
621
+
398
622
  /**
399
623
  * Run one turn of `definition` as the workflow engine does: each step runs
400
624
  * once and its output is saved; with `saved`, the saved steps replay without
@@ -457,7 +681,7 @@ export async function runTurn<S>(
457
681
  heartbeat: async () => {},
458
682
  progress: (phase, counts) => turn.progress.push(progressCall(key, phase, counts)),
459
683
  });
460
- const json = JSON.stringify(output ?? null);
684
+ const json = workflowJson(output, `step "${key}"`);
461
685
  if (json.length > 64 * 1024)
462
686
  throw new Error(`step ${key} saves ${json.length} bytes, more than 64 KiB`);
463
687
  turn.bytes += json.length;
@@ -481,6 +705,8 @@ export async function runTurn<S>(
481
705
  }),
482
706
  } as unknown as WorkflowContext;
483
707
  const result = (await definition.execute(ctx, state, message)) as TurnResult<S>;
708
+ workflowJson(result.state, "state");
709
+ if (result.continuation) workflowJson(result.continuation, "continuation");
484
710
  return { result, turn };
485
711
  }
486
712
 
@@ -0,0 +1,157 @@
1
+ # rows
2
+
3
+ Reads each CSV or TSV file of a graph into one instance per row. A
4
+ customer list gives one `Customer` per row, with the values of its
5
+ columns. Each instance has an edge `READ_FROM` to the file's `Document`.
6
+ The edge's evidence is the row's text, and its source is the file's IRI
7
+ with the row: `https://littlebigbrain.com/e/41ab…#row=5`
8
+ ([evidence.md](../../../docs/reference/evidence.md#evidence-from-a-file)).
9
+
10
+ The template has no LBB-run version. It is a named workflow with a
11
+ **content trigger**: LBB sends it each upload of `text/csv` or
12
+ `text/tab-separated-values` within seconds, without a wait for a
13
+ publication ([workflows.md](../../../docs/reference/workflows.md#content-triggers)).
14
+
15
+ ## How it runs
16
+
17
+ Each message holds one file. The first turn of a file:
18
+
19
+ 1. **Check.** Reads the file's name with a strong read, and the
20
+ derivation of the content for `rows@v1` and the settings
21
+ ([Derivations](../../../docs/reference/workflows.md#derivations)). A
22
+ content with a derivation is skipped, so the same bytes under a second
23
+ name are read once. So is a file whose name the settings' `match` does
24
+ not match. Other settings are another derivation, and read the file
25
+ again.
26
+ 2. **Plan.** Reads the header row: the first window of the file, by byte
27
+ range (`client.files.content(sha256, { range })`). Each column maps to
28
+ a property: the settings' `columns`, else each
29
+ column whose header is a property of the graph by its normalized name
30
+ (`Customer ID` and `customer_id` match). A property must be in the
31
+ graph's property registry with a scalar type. The key column keys each
32
+ instance; a column named `name`, `title` or `label` names it.
33
+ 3. **Represents.** With `represents`, when the file's name without its
34
+ extension is the label of exactly one instance of that class, writes
35
+ `Document REPRESENTS <instance>`. `q3-customers.csv` represents the
36
+ report `Q3 customers`: the labels compare in lower case, with `-`, `_`
37
+ and spaces as one space. No match, or more than one, writes nothing and
38
+ proposes nothing.
39
+
40
+ Then each turn reads at most `rowsPerTurn` rows and writes them in owned
41
+ writes of 400 rows. A turn reads from the byte offset where the last one
42
+ stopped, a window of 4 MiB per read, and parses only complete rows. A row
43
+ that a window does not end, such as a quoted field with a line break across
44
+ the window's edge, is read on in the next window. The first turn reads on
45
+ from the bytes of its header read, so a file is read about once in all. A
46
+ row is one instance of the settings' class:
47
+
48
+ - its key: the key column's value, else `<sha256>:<row>`. A row whose key
49
+ column is empty is left out;
50
+ - its values, each in its property's type. A value that does not take the
51
+ type is left out and counted. An empty cell removes the property, as the
52
+ owned write replaces the mapped properties of the instance;
53
+ - the edge `READ_FROM` to the `Document`, with the evidence
54
+ `{text: <the row's text>, source_id: "<Document IRI>#row=<n>"}`.
55
+
56
+ Rows count as RFC 7111 counts them: from 1, and the header is row 1. A
57
+ blank row counts and writes nothing. When the file has more rows, the turn
58
+ continues (`ctx.continue`): the message's cursor keeps the byte offset of
59
+ the next row (`byte`) and its row number (`row`), so the row numbers stay
60
+ exact. A turn that runs again after a crash reads its rows again from the
61
+ same offset. A cursor without `byte`, from an earlier version of the
62
+ template, reads the file again from its start. The last turn
63
+ records the derivation with what it wrote: the rows, the instances, the
64
+ columns it mapped and left out, the instance the file represents, and the
65
+ commits of its writes. The record names the upload the check
66
+ answered (`content_uploaded_at`). The cursor carries it from turn to turn.
67
+ When the file was deleted while the run read it, the record writes nothing
68
+ (`409 derivation_upload_changed`). The run ends and counts the file as
69
+ skipped. The run of a new upload reads the file again. Say the file was
70
+ deleted and not uploaded again. The next read of its bytes answers
71
+ `404 file_not_found`. The run ends there too, and counts the file as
72
+ skipped.
73
+
74
+ The parser follows RFC 4180: fields in double quotes can hold the
75
+ delimiter, line breaks and quotes (`""`). A row whose quoted field holds a
76
+ line break spans two lines and keeps one row number. TSV takes the same
77
+ quotes, with a tab as the delimiter. A byte order mark is dropped. The
78
+ parser reads bytes: the delimiter, the quote and the line breaks are ASCII,
79
+ so a window's edge inside a character of UTF-8 or between CR and LF changes
80
+ no row.
81
+
82
+ ## Settings
83
+
84
+ `defaults` in `workflow.ts`, which fit the samples:
85
+
86
+ | Setting | Default | Meaning |
87
+ | --- | --- | --- |
88
+ | `className` | `Customer` | The class of the instances, one per data row |
89
+ | `key` | `Customer ID` | The column whose value keys each instance. Without it, the key is `<sha256>:<row>` |
90
+ | `columns` | none | Column to property. Without it, each column whose header is a property of the graph |
91
+ | `represents` | `{class: "Report"}` | Write `REPRESENTS` to the one instance of this class that the file's name names |
92
+ | `match` | `customers` | A regular expression on the file's name. Without it, every CSV and TSV file |
93
+ | `rowsPerTurn` | 5,000 | Rows one turn reads, 1 to 20,000 |
94
+
95
+ `trigger(settings)` names the workflow `rows`, with the content trigger
96
+ `rows` for the two media types. It owns the class, `READ_FROM`,
97
+ `REPRESENTS` when `represents` is set, and the properties of `columns`.
98
+ `ontology(settings)` adds the class and `READ_FROM`. The properties come
99
+ from the graph: add them with `lbb.evolve` before the first file.
100
+
101
+ The settings digest is the SHA-256 of the settings' canonical JSON: a
102
+ change of any setting reads every file again.
103
+
104
+ Two content workflows own one relation when no upload can reach both:
105
+ `rows` and `sections`, both with `represents`, go on one graph, because no
106
+ media type of one meets a media type of the other. A second workflow that
107
+ writes `REPRESENTS` for a type that meets one of these, such as `text/*`, is
108
+ refused with `409 workflow_owns_conflict`
109
+ ([starter-workflows.md](../../../docs/reference/starter-workflows.md#cycles-and-conflicts)).
110
+
111
+ ## Commands
112
+
113
+ `setup.ts`, `worker.ts` and `try.ts` are the template's own:
114
+ `npm run setup` creates the named workflow with
115
+ `client.workflows.named.put(trigger())`, and `npm start` serves
116
+ `createWorkflow(lbbActivities(client), client)`: the client reads the
117
+ files' bytes and the derivations. A fourth argument sets how the workflow
118
+ reads: `{ window, maxRecord }`, the bytes of one read (default 4 MiB, at
119
+ least 64) and of the longest row (default 16 MiB). They change no output,
120
+ so they are not settings and enter no settings digest. `npm run try` creates the graph
121
+ `try-rows`, adds the sample properties and reports, uploads the two sample
122
+ files and creates the workflow. A content trigger reads the uploads from
123
+ before its creation too.
124
+
125
+ ## Limits
126
+
127
+ - A turn holds at most one window and the longest row in memory. A row
128
+ longer than 16 MiB, such as a quote that never closes, stops the file:
129
+ the turn fails with an error that names the row and its byte offset.
130
+ - A turn that stops at `rowsPerTurn` in the middle of a window reads the
131
+ rest of that window again in the next turn.
132
+ - A file deleted later keeps the instances it gave; its `READ_FROM` edges
133
+ go with its `Document`.
134
+ - A message that comes again while a large file is still in progress (a
135
+ run now) reads the file again. The writes are the same.
136
+
137
+ ## Samples and test
138
+
139
+ `samples/q3-customers.csv` is an invented customer list of 8 data rows. It
140
+ holds a quoted name with a comma, a name with quotes, an address with a
141
+ line break, a value that is not a date, a blank row, an empty cell and a
142
+ row without a key. `samples/q3-orders.tsv` is an invented order table.
143
+ `samples/properties.json` lists the graph properties the columns fill, and
144
+ `samples/reports.json` three reports the file names can name.
145
+ `samples/expected.json` gives the instances a correct run writes.
146
+
147
+ `workflow.test.ts` runs the workflow without a server (`MockContent` holds
148
+ the files and the derivations). It checks the answer key for both files,
149
+ the evidence of each row, the skip by derivation and by name, a file of
150
+ more rows than a turn reads, parts of 400 rows, a header-only file and an
151
+ empty file, `represents` with zero, one and two matching instances, keys
152
+ without a key column, the `columns` setting, the phases, and the parser
153
+ on RFC 4180's cases. The reads by range: a file of many windows read once
154
+ with exact row numbers, every window size from 64 to 200 bytes over
155
+ quoted fields, letters beyond ASCII and the three line breaks, a CRLF file
156
+ whose window ends between CR and LF, a turn that continues from its byte
157
+ offset (also after a crash), and a row longer than the longest row.