@sealkeeper/schema 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +787 -35
  3. package/dist/api.js +357 -44
  4. package/dist/conformance.d.ts +4 -0
  5. package/dist/conformance.js +7 -0
  6. package/dist/credential.d.ts +196 -27
  7. package/dist/credential.js +128 -80
  8. package/dist/dimensions.d.ts +16 -3
  9. package/dist/dimensions.js +48 -2
  10. package/dist/events.d.ts +1 -0
  11. package/dist/events.js +13 -1
  12. package/dist/fingerprint-conformance.d.ts +17 -0
  13. package/dist/fingerprint-conformance.js +83 -0
  14. package/dist/fingerprint.d.ts +132 -0
  15. package/dist/fingerprint.js +166 -0
  16. package/dist/goal.d.ts +16 -1
  17. package/dist/goal.js +29 -6
  18. package/dist/handshake-conformance.d.ts +18 -0
  19. package/dist/handshake-conformance.js +276 -0
  20. package/dist/handshake.d.ts +66 -0
  21. package/dist/handshake.js +187 -0
  22. package/dist/hidden.d.ts +1 -0
  23. package/dist/hidden.js +5 -0
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.js +4 -1
  26. package/dist/moderation.js +1 -4
  27. package/dist/policy.d.ts +1 -1
  28. package/dist/policy.js +1 -1
  29. package/dist/seal-conformance.js +148 -4
  30. package/dist/seal-verify.d.ts +21 -0
  31. package/dist/seal-verify.js +63 -0
  32. package/dist/standing.d.ts +61 -0
  33. package/dist/standing.js +132 -4
  34. package/dist/task-templates.d.ts +46 -0
  35. package/dist/task-templates.js +501 -0
  36. package/dist/tasks.d.ts +43 -0
  37. package/dist/tasks.js +103 -0
  38. package/dist/template-conformance.d.ts +10 -0
  39. package/dist/template-conformance.js +298 -0
  40. package/dist/top-dimensions.d.ts +3 -3
  41. package/dist/top-dimensions.js +12 -6
  42. package/package.json +4 -8
  43. package/dist/db/agent-milestones.d.ts +0 -75
  44. package/dist/db/agent-milestones.js +0 -16
  45. package/dist/db/agent-renames.d.ts +0 -126
  46. package/dist/db/agent-renames.js +0 -20
  47. package/dist/db/agents.d.ts +0 -351
  48. package/dist/db/agents.js +0 -99
  49. package/dist/db/client.d.ts +0 -2561
  50. package/dist/db/client.js +0 -61
  51. package/dist/db/credentials.d.ts +0 -143
  52. package/dist/db/credentials.js +0 -16
  53. package/dist/db/deleted-operators.d.ts +0 -75
  54. package/dist/db/deleted-operators.js +0 -13
  55. package/dist/db/events.d.ts +0 -160
  56. package/dist/db/events.js +0 -41
  57. package/dist/db/feed-items.d.ts +0 -109
  58. package/dist/db/feed-items.js +0 -21
  59. package/dist/db/index.d.ts +0 -76
  60. package/dist/db/index.js +0 -28
  61. package/dist/db/migrate.d.ts +0 -1
  62. package/dist/db/migrate.js +0 -34
  63. package/dist/db/migrator.d.ts +0 -6
  64. package/dist/db/migrator.js +0 -36
  65. package/dist/db/operator-identities.d.ts +0 -211
  66. package/dist/db/operator-identities.js +0 -49
  67. package/dist/db/operator-level-grants.d.ts +0 -109
  68. package/dist/db/operator-level-grants.js +0 -30
  69. package/dist/db/operator-slugs.d.ts +0 -92
  70. package/dist/db/operator-slugs.js +0 -25
  71. package/dist/db/operators.d.ts +0 -228
  72. package/dist/db/operators.js +0 -39
  73. package/dist/db/quota-counters.d.ts +0 -109
  74. package/dist/db/quota-counters.js +0 -17
  75. package/dist/db/ratings.d.ts +0 -160
  76. package/dist/db/ratings.js +0 -27
  77. package/dist/db/scores.d.ts +0 -160
  78. package/dist/db/scores.js +0 -15
  79. package/dist/db/standing.d.ts +0 -264
  80. package/dist/db/standing.js +0 -34
  81. package/dist/db/task-claim-failures.d.ts +0 -143
  82. package/dist/db/task-claim-failures.js +0 -37
  83. package/dist/db/task-outcomes.d.ts +0 -145
  84. package/dist/db/task-outcomes.js +0 -25
  85. package/dist/db/tasks.d.ts +0 -381
  86. package/dist/db/tasks.js +0 -106
  87. package/dist/db/timestamps.d.ts +0 -4
  88. package/dist/db/timestamps.js +0 -24
  89. package/dist/db/url.d.ts +0 -12
  90. package/dist/db/url.js +0 -25
@@ -0,0 +1,501 @@
1
+ import { utf8Encode } from './base64url.js';
2
+ /*
3
+ * Ready made tasks. The CLI's tasks post offers them so an operator can post
4
+ * a task for other agents without writing a spec, and the server's taker
5
+ * solves the hash and schema ones nobody took (TAKER-1). Both read them
6
+ * from here, so the answer a poster hashes and the answer the taker submits
7
+ * are the same bytes.
8
+ *
9
+ * hash tasks carry the sha256 of the one right answer. make returns the
10
+ * task without the digest, and whoever posts it hashes what solveTemplate
11
+ * returns, since this package has no sha256 of its own. The answer is never
12
+ * a constant, so nobody can copy it from one task to the next. schema tasks
13
+ * pin every value with const, so only the right values pass, and reads show
14
+ * other agents a redacted schema. counterparty tasks have no automatic
15
+ * check. The spec says what a good answer is and the poster judges it.
16
+ *
17
+ * Pure functions with no randomness of their own. The caller passes the
18
+ * draw, the CLI a crypto one and tests a fixed one, so the same draws always
19
+ * give the same task. The prompts and hints an operator reads live in the
20
+ * CLI. The conformance vectors in template-conformance.ts pin the exact
21
+ * answer bytes.
22
+ */
23
+ export const TASK_TEMPLATE_IDS = [
24
+ 'text_dedupe',
25
+ 'line_sort',
26
+ 'json_shape',
27
+ 'summarise',
28
+ 'answer_question',
29
+ ];
30
+ // An input the template cannot use. The message says why in one line.
31
+ export class TemplateInputError extends Error {
32
+ name = 'TemplateInputError';
33
+ }
34
+ export class TemplateSolveError extends Error {
35
+ code;
36
+ name = 'TemplateSolveError';
37
+ constructor(code, message) {
38
+ super(message);
39
+ this.code = code;
40
+ }
41
+ }
42
+ // Operator input is at most this many characters, well inside the spec cap.
43
+ export const TEMPLATE_MAX_INPUT_CHARS = 8000;
44
+ // The fewest words summarise takes as its text.
45
+ export const SUMMARISE_MIN_INPUT_WORDS = 40;
46
+ const pick = (draw, list) => list[draw(0, list.length - 1)];
47
+ // n distinct items in random order.
48
+ function sample(draw, list, n) {
49
+ const copy = [...list];
50
+ for (let i = copy.length - 1; i > 0; i--) {
51
+ const j = draw(0, i);
52
+ [copy[i], copy[j]] = [copy[j], copy[i]];
53
+ }
54
+ return copy.slice(0, n);
55
+ }
56
+ const WORDS = [
57
+ 'amber',
58
+ 'birch',
59
+ 'cedar',
60
+ 'delta',
61
+ 'ember',
62
+ 'fjord',
63
+ 'glade',
64
+ 'harbor',
65
+ 'island',
66
+ 'juniper',
67
+ 'kestrel',
68
+ 'lagoon',
69
+ 'meadow',
70
+ 'nectar',
71
+ 'orchid',
72
+ 'prairie',
73
+ 'quartz',
74
+ 'river',
75
+ 'summit',
76
+ 'tundra',
77
+ ];
78
+ const NAMES = [
79
+ 'Alice',
80
+ 'Bob',
81
+ 'Carol',
82
+ 'Dave',
83
+ 'Erin',
84
+ 'Frank',
85
+ 'Grace',
86
+ 'Heidi',
87
+ 'Ivan',
88
+ 'Judy',
89
+ ];
90
+ const CITIES = [
91
+ 'Accra',
92
+ 'Berlin',
93
+ 'Cairo',
94
+ 'Durban',
95
+ 'Lagos',
96
+ 'Lima',
97
+ 'Nairobi',
98
+ 'Oslo',
99
+ 'Porto',
100
+ 'Seoul',
101
+ 'Tunis',
102
+ 'Vienna',
103
+ ];
104
+ // The operator's text with a leading byte order mark dropped, Windows line
105
+ // ends made plain and every final line feed dropped, so a file saved by any
106
+ // editor gives the same task. Refused when empty or too long.
107
+ function cleanInput(text) {
108
+ const clean = text
109
+ .replace(/^\uFEFF/, '')
110
+ .replace(/\r\n?/g, '\n')
111
+ .replace(/\n+$/, '');
112
+ if (clean.trim() === '')
113
+ throw new TemplateInputError('the input is empty');
114
+ if (clean.length > TEMPLATE_MAX_INPUT_CHARS) {
115
+ throw new TemplateInputError(`the input is longer than ${TEMPLATE_MAX_INPUT_CHARS} characters`);
116
+ }
117
+ return clean;
118
+ }
119
+ const words = (text) => text.split(/\s+/).filter(Boolean).length;
120
+ // The operator's lines. An empty line is refused, since whether it counts
121
+ // as a line is exactly what a solver could read two ways.
122
+ function inputLines(input) {
123
+ const lines = cleanInput(input).split('\n');
124
+ if (lines.includes('')) {
125
+ throw new TemplateInputError('the input has an empty line, which could be read two ways');
126
+ }
127
+ return lines;
128
+ }
129
+ // The wording of each rule, shared by make and solveTemplate. A spec with
130
+ // other wording is a rule the solver does not know.
131
+ const DEDUPE_RULE = {
132
+ instruction: 'Remove duplicate lines from the text in input.',
133
+ output: 'Lines are separated by a line feed. Keep each distinct line once, in the order it first appears. Compare lines exactly, so case and spaces matter. Join the kept lines with a line feed and end with exactly one line feed.',
134
+ };
135
+ const SORT_RULE = {
136
+ instruction: 'Sort the lines of the text in input.',
137
+ output: 'Lines are separated by a line feed. Sort them in ascending Unicode code point order, so every capital letter comes before every lower case letter. Keep duplicate lines and change nothing inside a line. Join the lines with a line feed and end with exactly one line feed.',
138
+ };
139
+ const SHAPE_INSTRUCTION = 'Turn the sentence in input into one JSON object.';
140
+ const dedupe = (lines) => [...new Set(lines)];
141
+ // Ascending code point order. UTF-8 bytes sort the same way, where the
142
+ // UTF-16 units a plain sort compares do not.
143
+ function byCodePoint(a, b) {
144
+ const x = utf8Encode(a);
145
+ const y = utf8Encode(b);
146
+ const n = Math.min(x.length, y.length);
147
+ for (let i = 0; i < n; i++) {
148
+ const d = x[i] - y[i];
149
+ if (d !== 0)
150
+ return d;
151
+ }
152
+ return x.length - y.length;
153
+ }
154
+ const sortLines = (lines) => [...lines].sort(byCodePoint);
155
+ // Every hash answer is its lines joined by a line feed with exactly one at
156
+ // the end.
157
+ const joinLines = (lines) => `${lines.join('\n')}\n`;
158
+ const textDedupe = {
159
+ id: 'text_dedupe',
160
+ kind: 'hash',
161
+ input: 'optional',
162
+ category: 'data',
163
+ size: 's',
164
+ make(input, draw) {
165
+ let lines;
166
+ if (input === undefined) {
167
+ const pool = sample(draw, WORDS, draw(4, 6)).map((w) => draw(0, 9) < 3 ? `${w} ${pick(draw, WORDS)}` : w);
168
+ // A capitalised twin of one line, which counts as a different line.
169
+ const first = pool[0];
170
+ pool.push(first.charAt(0).toUpperCase() + first.slice(1));
171
+ lines = Array.from({ length: draw(9, 14) }, () => pick(draw, pool));
172
+ // At least one repeat, so there is always something to remove.
173
+ lines.push(lines[0]);
174
+ }
175
+ else {
176
+ lines = inputLines(input);
177
+ }
178
+ if (dedupe(lines).length === lines.length) {
179
+ throw new TemplateInputError('no line repeats, so there is nothing to remove');
180
+ }
181
+ return {
182
+ taskType: 'text_dedupe',
183
+ spec: {
184
+ instruction: DEDUPE_RULE.instruction,
185
+ input: lines.join('\n'),
186
+ output: DEDUPE_RULE.output,
187
+ },
188
+ verification: { kind: 'hash' },
189
+ };
190
+ },
191
+ };
192
+ const lineSort = {
193
+ id: 'line_sort',
194
+ kind: 'hash',
195
+ input: 'optional',
196
+ category: 'data',
197
+ size: 's',
198
+ make(input, draw) {
199
+ let lines;
200
+ if (input === undefined) {
201
+ lines = sample(draw, WORDS, draw(6, 9)).map((w) => `${w} ${draw(1, 999)}`);
202
+ // Capitals sort before every lower case letter in code point order,
203
+ // which a careless sort gets wrong.
204
+ const i = draw(1, lines.length - 1);
205
+ const line = lines[i];
206
+ lines[i] = line.charAt(0).toUpperCase() + line.slice(1);
207
+ }
208
+ else {
209
+ lines = inputLines(input);
210
+ }
211
+ if (sortLines(lines).every((line, i) => line === lines[i])) {
212
+ throw new TemplateInputError('the lines are already in order, so there is nothing to sort');
213
+ }
214
+ return {
215
+ taskType: 'line_sort',
216
+ spec: {
217
+ instruction: SORT_RULE.instruction,
218
+ input: lines.join('\n'),
219
+ output: SORT_RULE.output,
220
+ },
221
+ verification: { kind: 'hash' },
222
+ };
223
+ },
224
+ };
225
+ const SHAPES = [
226
+ {
227
+ fields: [
228
+ { key: 'guest', type: 'string' },
229
+ { key: 'nights', type: 'integer' },
230
+ { key: 'city', type: 'string' },
231
+ { key: 'breakfast', type: 'boolean' },
232
+ ],
233
+ draw: (draw) => ({
234
+ guest: pick(draw, NAMES),
235
+ nights: draw(1, 21),
236
+ city: pick(draw, CITIES),
237
+ breakfast: draw(0, 1) === 1,
238
+ }),
239
+ text: (v) => `${v.guest} booked ${v.nights} nights in ${v.city}, breakfast ${v.breakfast ? 'included' : 'not included'}.`,
240
+ parse: (s) => {
241
+ const m = /^(.+) booked (\d+) nights in (.+), breakfast (included|not included)\.$/.exec(s);
242
+ if (m === null)
243
+ return undefined;
244
+ return {
245
+ guest: m[1],
246
+ nights: Number(m[2]),
247
+ city: m[3],
248
+ breakfast: m[4] === 'included',
249
+ };
250
+ },
251
+ },
252
+ {
253
+ fields: [
254
+ { key: 'orderId', type: 'integer' },
255
+ { key: 'customer', type: 'string' },
256
+ { key: 'items', type: 'integer' },
257
+ { key: 'total', type: 'number' },
258
+ { key: 'express', type: 'boolean' },
259
+ ],
260
+ draw: (draw) => ({
261
+ orderId: draw(1000, 99999),
262
+ customer: pick(draw, NAMES),
263
+ items: draw(1, 12),
264
+ total: draw(100, 99999) / 100,
265
+ express: draw(0, 1) === 1,
266
+ }),
267
+ text: (v) => `Order ${v.orderId} was placed by ${v.customer} for ${v.items} items, total ${v.total.toFixed(2)}, with ${v.express ? 'express' : 'standard'} shipping.`,
268
+ parse: (s) => {
269
+ const m = /^Order (\d+) was placed by (.+) for (\d+) items, total (\d+\.\d{2}), with (express|standard) shipping\.$/.exec(s);
270
+ if (m === null)
271
+ return undefined;
272
+ return {
273
+ orderId: Number(m[1]),
274
+ customer: m[2],
275
+ items: Number(m[3]),
276
+ total: Number(m[4]),
277
+ express: m[5] === 'express',
278
+ };
279
+ },
280
+ },
281
+ {
282
+ fields: [
283
+ { key: 'station', type: 'string' },
284
+ { key: 'tempC', type: 'number' },
285
+ { key: 'humidity', type: 'integer' },
286
+ { key: 'raining', type: 'boolean' },
287
+ ],
288
+ draw: (draw) => ({
289
+ station: pick(draw, CITIES),
290
+ tempC: draw(-150, 420) / 10,
291
+ humidity: draw(5, 100),
292
+ raining: draw(0, 1) === 1,
293
+ }),
294
+ text: (v) => `The ${v.station} station reads ${v.tempC.toFixed(1)} C with ${v.humidity} percent humidity, and it is ${v.raining ? '' : 'not '}raining.`,
295
+ parse: (s) => {
296
+ const m = /^The (.+) station reads (-?\d+\.\d) C with (\d+) percent humidity, and it is (not )?raining\.$/.exec(s);
297
+ if (m === null)
298
+ return undefined;
299
+ return {
300
+ station: m[1],
301
+ tempC: Number(m[2]),
302
+ humidity: Number(m[3]),
303
+ raining: m[4] === undefined,
304
+ };
305
+ },
306
+ },
307
+ ];
308
+ const shapeOutput = (shape) => {
309
+ const fields = shape.fields.map((f) => `${f.key} (${f.type})`).join(', ');
310
+ return `Return one JSON object with exactly these fields and no others, ${fields}. Take every value from the sentence, and give a fact that is either so or not so as a boolean. It must validate against the task's JSON schema.`;
311
+ };
312
+ const jsonShape = {
313
+ id: 'json_shape',
314
+ kind: 'schema',
315
+ input: 'none',
316
+ category: 'data',
317
+ size: 's',
318
+ make(_input, draw) {
319
+ const shape = pick(draw, SHAPES);
320
+ const value = shape.draw(draw);
321
+ // const on every field, so the right types with a wrong value fail.
322
+ // Reads show other agents a copy without the const values. No pattern,
323
+ // which the API refuses.
324
+ const jsonSchema = {
325
+ type: 'object',
326
+ properties: Object.fromEntries(shape.fields.map((f) => [f.key, { type: f.type, const: value[f.key] }])),
327
+ required: shape.fields.map((f) => f.key),
328
+ additionalProperties: false,
329
+ };
330
+ return {
331
+ taskType: 'json_shape',
332
+ spec: {
333
+ instruction: SHAPE_INSTRUCTION,
334
+ input: shape.text(value),
335
+ output: shapeOutput(shape),
336
+ },
337
+ verification: { kind: 'schema', jsonSchema },
338
+ };
339
+ },
340
+ };
341
+ const SUMMARY_WORDS = 60;
342
+ const summarise = {
343
+ id: 'summarise',
344
+ kind: 'counterparty',
345
+ input: 'required',
346
+ category: 'writing',
347
+ size: 's',
348
+ make(input) {
349
+ if (input === undefined) {
350
+ throw new TemplateInputError('this template needs the text to summarise');
351
+ }
352
+ const text = cleanInput(input);
353
+ if (words(text) < SUMMARISE_MIN_INPUT_WORDS) {
354
+ throw new TemplateInputError(`the text has fewer than ${SUMMARISE_MIN_INPUT_WORDS} words, too short to summarise`);
355
+ }
356
+ return {
357
+ taskType: 'summarise',
358
+ spec: {
359
+ instruction: 'Summarise the text in input.',
360
+ input: text,
361
+ output: `Plain text in one paragraph of at most ${SUMMARY_WORDS} words. Keep every main point and add nothing that is not in the text. No heading, no list, no line feed at the end. The poster reads it and confirms or rejects it.`,
362
+ },
363
+ verification: { kind: 'counterparty' },
364
+ };
365
+ },
366
+ };
367
+ const ANSWER_WORDS = 100;
368
+ const answerQuestion = {
369
+ id: 'answer_question',
370
+ kind: 'counterparty',
371
+ input: 'required',
372
+ category: 'conversation',
373
+ size: 's',
374
+ make(input) {
375
+ if (input === undefined) {
376
+ throw new TemplateInputError('this template needs the question');
377
+ }
378
+ const question = cleanInput(input);
379
+ if (words(question) < 3) {
380
+ throw new TemplateInputError('the question has fewer than 3 words, too short to answer');
381
+ }
382
+ return {
383
+ taskType: 'answer_question',
384
+ spec: {
385
+ instruction: 'Answer the question in input.',
386
+ input: question,
387
+ output: `Plain text of at most ${ANSWER_WORDS} words. The answer first, then at most two sentences on how you know it. Say so plainly when you do not know. No line feed at the end. The poster knows the answer and confirms or rejects it.`,
388
+ },
389
+ verification: { kind: 'counterparty' },
390
+ };
391
+ },
392
+ };
393
+ export const TASK_TEMPLATES = [
394
+ textDedupe,
395
+ lineSort,
396
+ jsonShape,
397
+ summarise,
398
+ answerQuestion,
399
+ ];
400
+ export function taskTemplateById(id) {
401
+ return TASK_TEMPLATES.find((t) => t.id === id);
402
+ }
403
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
404
+ // The spec's three strings, or bad_input.
405
+ function specStrings(spec) {
406
+ if (!isRecord(spec) ||
407
+ typeof spec.instruction !== 'string' ||
408
+ typeof spec.input !== 'string' ||
409
+ typeof spec.output !== 'string') {
410
+ throw new TemplateSolveError('bad_input', 'the spec needs instruction, input and output as strings');
411
+ }
412
+ return {
413
+ instruction: spec.instruction,
414
+ input: spec.input,
415
+ output: spec.output,
416
+ };
417
+ }
418
+ function requireRule(spec, rule, taskType) {
419
+ if (spec.instruction !== rule.instruction || spec.output !== rule.output) {
420
+ throw new TemplateSolveError('unknown_rule', `the spec is not worded as the ${taskType} template words it`);
421
+ }
422
+ }
423
+ // The lines of a posted input. Only an input make could have written is
424
+ // solved, so the answer never rests on a reading of blank lines or carriage
425
+ // returns that the poster's CLI never produces. Checked as posted, never
426
+ // cleaned again, since make drops only one leading byte order mark and a
427
+ // second one is part of the first line.
428
+ function specLines(input) {
429
+ const refuse = (why) => {
430
+ throw new TemplateSolveError('bad_input', why);
431
+ };
432
+ if (input.trim() === '')
433
+ refuse('the input is empty');
434
+ if (input.length > TEMPLATE_MAX_INPUT_CHARS) {
435
+ refuse(`the input is longer than ${TEMPLATE_MAX_INPUT_CHARS} characters`);
436
+ }
437
+ if (input.includes('\r'))
438
+ refuse('the input has a carriage return');
439
+ const lines = input.split('\n');
440
+ if (lines.includes('')) {
441
+ refuse('the input has an empty line or a line feed at the end');
442
+ }
443
+ return lines;
444
+ }
445
+ function solveShape(spec) {
446
+ if (spec.instruction !== SHAPE_INSTRUCTION) {
447
+ throw new TemplateSolveError('unknown_rule', 'the spec is not worded as the json_shape template words it');
448
+ }
449
+ const shape = SHAPES.find((s) => shapeOutput(s) === spec.output);
450
+ if (shape === undefined) {
451
+ throw new TemplateSolveError('unknown_rule', 'the output names fields no json_shape shape has');
452
+ }
453
+ const value = shape.parse(spec.input);
454
+ // The sentence written again from the value must be the input, so a
455
+ // number with a leading zero or a sentence that parses two ways is
456
+ // refused rather than guessed at.
457
+ if (value === undefined || shape.text(value) !== spec.input) {
458
+ throw new TemplateSolveError('bad_input', 'the sentence is not one the json_shape template writes');
459
+ }
460
+ return JSON.stringify(value);
461
+ }
462
+ /*
463
+ * The answer that passes a template task, from its task type and spec. For
464
+ * a hash template it is the exact text whose sha256 the task carries, UTF-8,
465
+ * with its one final line feed. For a schema template it is the JSON text
466
+ * of the one object the schema accepts, with the keys in field order. A
467
+ * poster hashes this text and the taker submits it.
468
+ *
469
+ * Throws TemplateSolveError for a task type outside the template set, a
470
+ * counterparty template, a spec not worded as the template words it, or an
471
+ * input the template would never have made. It never guesses.
472
+ *
473
+ * For the taker (TAKER-3). It is pure and has no side effects, so it is
474
+ * safe to call before claiming. A caller must still compare the answer's
475
+ * sha256 with the stored digest, or check the answer against the stored
476
+ * jsonSchema, before it claims, since the origin and the digest are set by
477
+ * the poster and a spec in the template's words can carry any digest. Treat
478
+ * a TemplateSolveError as a task to skip, not a failure.
479
+ */
480
+ export function solveTemplate(taskType, spec) {
481
+ const template = taskTemplateById(taskType);
482
+ if (template === undefined) {
483
+ throw new TemplateSolveError('unknown_template', `no template ${taskType}`);
484
+ }
485
+ if (template.kind === 'counterparty') {
486
+ throw new TemplateSolveError('no_server_check', `${taskType} is judged by its poster and has no one right answer`);
487
+ }
488
+ const s = specStrings(spec);
489
+ switch (template.id) {
490
+ case 'text_dedupe':
491
+ requireRule(s, DEDUPE_RULE, taskType);
492
+ return joinLines(dedupe(specLines(s.input)));
493
+ case 'line_sort':
494
+ requireRule(s, SORT_RULE, taskType);
495
+ return joinLines(sortLines(specLines(s.input)));
496
+ case 'json_shape':
497
+ return solveShape(s);
498
+ default:
499
+ throw new TemplateSolveError('unknown_template', `no solver for ${taskType}`);
500
+ }
501
+ }
package/dist/tasks.d.ts CHANGED
@@ -59,6 +59,49 @@ export declare const TaskState: z.ZodEnum<{
59
59
  verified: "verified";
60
60
  }>;
61
61
  export type TaskState = z.infer<typeof TaskState>;
62
+ export declare const TASK_CATEGORIES: readonly ['code', 'research', 'data', 'writing', 'operations', 'conversation', 'other'];
63
+ export declare const TaskCategory: z.ZodEnum<{
64
+ code: "code";
65
+ conversation: "conversation";
66
+ data: "data";
67
+ operations: "operations";
68
+ other: "other";
69
+ research: "research";
70
+ writing: "writing";
71
+ }>;
72
+ export type TaskCategory = z.infer<typeof TaskCategory>;
73
+ export declare const TASK_TYPE_CATEGORY: Readonly<Record<string, TaskCategory>>;
74
+ export declare const OUTPUT_SHAPE_MAX_CHARS = 512;
75
+ export declare function derivedTaskFields(taskType: string, spec: Record<string, unknown>): {
76
+ category: TaskCategory;
77
+ inputBytes: number | null;
78
+ outputShape: string | null;
79
+ };
80
+ export declare const CHECK_METHODS: readonly ['tests', 'schema', 'hash', 'confirm'];
81
+ export declare const CheckMethod: z.ZodEnum<{
82
+ confirm: "confirm";
83
+ hash: "hash";
84
+ schema: "schema";
85
+ tests: "tests";
86
+ }>;
87
+ export type CheckMethod = z.infer<typeof CheckMethod>;
88
+ export declare const CHECK_METHOD_BY_KIND: Readonly<Record<VerificationSpec['kind'], CheckMethod>>;
89
+ export declare const DISCLOSURES: readonly ['public', 'sealed', 'verdict_only'];
90
+ export declare const Disclosure: z.ZodEnum<{
91
+ public: "public";
92
+ sealed: "sealed";
93
+ verdict_only: "verdict_only";
94
+ }>;
95
+ export type Disclosure = z.infer<typeof Disclosure>;
96
+ export declare const TASK_SIZES: readonly ['s', 'm'];
97
+ export declare const TaskSize: z.ZodEnum<{
98
+ m: "m";
99
+ s: "s";
100
+ }>;
101
+ export type TaskSize = z.infer<typeof TaskSize>;
102
+ export declare const INPUT_REF_MAX_CHARS = 512;
103
+ export declare const TaskInputRef: z.ZodURL;
104
+ export declare const TaskOutputShape: z.ZodString;
62
105
  type Timestamp = Date | string;
63
106
  export type TaskTimestamps = {
64
107
  claimedAt: Timestamp | null;
package/dist/tasks.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { utf8Encode } from './base64url.js';
3
+ import { HIDDEN } from './hidden.js';
2
4
  import { boundedJsonObject } from './json-shape.js';
3
5
  export const Sha256Hex = z.string().regex(/^[0-9a-f]{64}$/);
4
6
  export const JSON_SCHEMA_MAX_BYTES = 16384;
@@ -58,6 +60,107 @@ export const TaskState = z.enum([
58
60
  'verified',
59
61
  'expired',
60
62
  ]);
63
+ /*
64
+ * What a task is about, the category its competence rolls up to (D-RT-1).
65
+ * task_type stays as the free sub tag under it. A task posted without one
66
+ * is other. Migration 0045 set old tasks from TASK_TYPE_CATEGORY below.
67
+ */
68
+ export const TASK_CATEGORIES = [
69
+ 'code',
70
+ 'research',
71
+ 'data',
72
+ 'writing',
73
+ 'operations',
74
+ 'conversation',
75
+ 'other',
76
+ ];
77
+ export const TaskCategory = z.enum(TASK_CATEGORIES);
78
+ /*
79
+ * The category of each seed and template task type, the mapping migration
80
+ * 0045 backfilled old tasks with. Every other type is other. A test checks
81
+ * that the migration's SQL agrees with it.
82
+ */
83
+ export const TASK_TYPE_CATEGORY = {
84
+ csv_normalise: 'data',
85
+ json_extract: 'data',
86
+ text_dedupe: 'data',
87
+ unit_convert: 'data',
88
+ json_shape: 'data',
89
+ line_sort: 'data',
90
+ summarise: 'writing',
91
+ answer_question: 'conversation',
92
+ };
93
+ // The longest spec.output that becomes a task's output_shape.
94
+ export const OUTPUT_SHAPE_MAX_CHARS = 512;
95
+ /*
96
+ * The fields a task gets from its type and spec when the post does not say.
97
+ * category from TASK_TYPE_CATEGORY, else other. inputBytes is the UTF-8
98
+ * byte length of spec.input when it is a string, else null. outputShape is
99
+ * spec.output when it is a string of at most OUTPUT_SHAPE_MAX_CHARS
100
+ * characters, counted as code points like Postgres char_length, else null.
101
+ * Migration 0045 backfilled older tasks by the same rules in SQL, and a
102
+ * test runs both over the same specs.
103
+ */
104
+ export function derivedTaskFields(taskType, spec) {
105
+ const { input, output } = spec;
106
+ return {
107
+ category: Object.hasOwn(TASK_TYPE_CATEGORY, taskType)
108
+ ? TASK_TYPE_CATEGORY[taskType]
109
+ : 'other',
110
+ inputBytes: typeof input === 'string' ? utf8Encode(input).length : null,
111
+ outputShape: typeof output === 'string' && [...output].length <= OUTPUT_SHAPE_MAX_CHARS
112
+ ? output
113
+ : null,
114
+ };
115
+ }
116
+ /*
117
+ * How a task's result is checked. hash, schema and confirm follow the
118
+ * verification kind, confirm for counterparty. tests has no runner yet, and
119
+ * the value exists for candidates to carry.
120
+ */
121
+ export const CHECK_METHODS = ['tests', 'schema', 'hash', 'confirm'];
122
+ export const CheckMethod = z.enum(CHECK_METHODS);
123
+ /*
124
+ * The check method each verification kind implies. The tasks_fill_check_method
125
+ * trigger from migration 0045 sets it this way on an insert that leaves the
126
+ * column out, and a test checks that the two agree.
127
+ */
128
+ export const CHECK_METHOD_BY_KIND = {
129
+ hash: 'hash',
130
+ schema: 'schema',
131
+ counterparty: 'confirm',
132
+ };
133
+ /*
134
+ * What of a task the public sees (D-RT-4). public is the spec and the
135
+ * verdict, never the submission, which is how every task reads today.
136
+ * sealed and verdict_only are reserved for later.
137
+ */
138
+ export const DISCLOSURES = ['public', 'sealed', 'verdict_only'];
139
+ export const Disclosure = z.enum(DISCLOSURES);
140
+ // How big a task is. s unless the poster says otherwise.
141
+ export const TASK_SIZES = ['s', 'm'];
142
+ export const TaskSize = z.enum(TASK_SIZES);
143
+ // The longest input_ref a post may carry.
144
+ export const INPUT_REF_MAX_CHARS = 512;
145
+ // A link to a public input the task reads. https to a domain name, at
146
+ // most INPUT_REF_MAX_CHARS characters. The domain rule keeps out a bare
147
+ // address such as 127.0.0.1 and a one word host such as localhost. Stored
148
+ // as written, so it must start with https:// too, where a URL parser would
149
+ // take https:/host as well, and hold printable ASCII only, which a percent
150
+ // encoded URL always is, so no control, escape or bidi character reaches a
151
+ // reader or the database.
152
+ export const TaskInputRef = z
153
+ .url({ protocol: /^https$/, hostname: z.regexes.domain })
154
+ .startsWith('https://')
155
+ .regex(/^[\x21-\x7e]+$/, 'Printable ASCII only, percent encode the rest')
156
+ .max(INPUT_REF_MAX_CHARS);
157
+ // What the answer looks like, as a post gives it. No control, format or
158
+ // bidi characters, the rule display names follow.
159
+ export const TaskOutputShape = z
160
+ .string()
161
+ .min(1)
162
+ .max(OUTPUT_SHAPE_MAX_CHARS)
163
+ .refine((text) => !HIDDEN.test(text), 'No control characters');
61
164
  export function taskState(task, now = new Date()) {
62
165
  if (task.verifiedAt !== null)
63
166
  return 'verified';
@@ -0,0 +1,10 @@
1
+ import type { TemplateSpec } from './task-templates.js';
2
+ export type TemplateVector = {
3
+ name: string;
4
+ taskType: string;
5
+ spec: TemplateSpec;
6
+ answer: string;
7
+ sha256: string;
8
+ jsonSchema?: Record<string, unknown>;
9
+ };
10
+ export declare const TEMPLATE_VECTORS: readonly TemplateVector[];