@littlebigbrain/create 0.5.0 → 0.6.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.
@@ -1,24 +1,45 @@
1
1
  // values-extract on its samples, with mock models whose rules are fixed:
2
- // the reasoning model reads the first size (`60x40 cm`, or `Breite 60 cm`),
3
- // the first material and the water supply of each text, and the decision
2
+ // the reasoning model reads the first size (`60x40 cm`, or `Breite 60 cm`)
3
+ // with its unit, the first material and the water supply of each text, and the decision
4
4
  // model says a quote from a sentence about an accessory (`Zubehör`) or a
5
5
  // conditional variant (`Bei Einsatz`) does not support its value. The
6
6
  // expected values come from samples/expected.json.
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
9
  import { join } from "node:path";
10
- import { ENTITY_IRI_NAMESPACE, type DecideAnswer } from "@littlebigbrain/client";
10
+ import {
11
+ ENTITY_IRI_NAMESPACE,
12
+ entityIdFromIri,
13
+ entityRef,
14
+ stableHashHex,
15
+ type DecideAnswer,
16
+ type Schemas,
17
+ } from "@littlebigbrain/client";
11
18
  import {
12
19
  MockActivities,
13
20
  mockId,
14
21
  phases,
15
22
  readJson,
16
23
  runMessage,
24
+ runTurn,
17
25
  samplesDir,
18
26
  triggerMessage,
19
27
  type MockRules,
20
28
  } from "../_shared/testing.js";
21
- import { createWorkflow, defaults, quoteHolds, typed } from "./workflow.js";
29
+ import {
30
+ check,
31
+ createWorkflow,
32
+ defaults,
33
+ evidenceOf,
34
+ legacyValueKey,
35
+ ontology,
36
+ quoteHolds,
37
+ typed,
38
+ unitOf,
39
+ unitProblem,
40
+ valueItemKey,
41
+ } from "./workflow.js";
42
+ import { report, testExamples } from "./test-examples.js";
22
43
 
23
44
  const SAMPLES = samplesDir(import.meta.url, "values-extract");
24
45
  const RECORDS = readJson<{ name: string; text: string }[]>(join(SAMPLES, "records.json"));
@@ -46,27 +67,30 @@ const MATERIALS = [
46
67
 
47
68
  /** The mock reasoning model: the first size, material and water supply of a text. */
48
69
  function readValues(text: string) {
49
- const values: { property: string; value: string; quote: string }[] = [];
70
+ const values: { property: string; value: string; unit: string; quote: string }[] = [];
50
71
  const size = text.match(/(?:ca\. )?(\d+)x(\d+) cm/);
51
72
  const width = text.match(/Breite (\d+) cm/);
52
73
  if (size)
53
74
  values.push(
54
- { property: "width_cm", value: size[1]!, quote: size[0] },
55
- { property: "depth_cm", value: size[2]!, quote: size[0] },
75
+ { property: "width_cm", value: size[1]!, unit: "cm", quote: size[0] },
76
+ { property: "depth_cm", value: size[2]!, unit: "cm", quote: size[0] },
56
77
  );
57
- else if (width) values.push({ property: "width_cm", value: width[1]!, quote: width[0] });
78
+ else if (width)
79
+ values.push({ property: "width_cm", value: width[1]!, unit: "cm", quote: width[0] });
58
80
  const material = MATERIALS.map((m) => ({
59
81
  m,
60
82
  at: text.search(new RegExp(`(?<![\\p{L}])${m}(?![\\p{L}])`, "u")),
61
83
  }))
62
84
  .filter((found) => found.at >= 0)
63
85
  .sort((a, b) => a.at - b.at)[0];
64
- if (material) values.push({ property: "material", value: material.m, quote: material.m });
86
+ if (material)
87
+ values.push({ property: "material", value: material.m, unit: "", quote: material.m });
65
88
  const water = text.match(/Anschluss[^.]*?(Kalt- und Warmwasser|Kaltwasser)/);
66
89
  if (water)
67
90
  values.push({
68
91
  property: "water_supply",
69
92
  value: water[1] === "Kaltwasser" ? "kalt" : "kalt und warm",
93
+ unit: "",
70
94
  quote: water[0],
71
95
  });
72
96
  return values;
@@ -85,7 +109,13 @@ function support(text: string, instructions: string, options: string[]): DecideA
85
109
  };
86
110
  }
87
111
 
88
- function rules(records: { name: string; text: string }[], more: MockRules = {}): MockRules {
112
+ interface Row {
113
+ name: string;
114
+ text?: string;
115
+ box?: Record<string, number>;
116
+ }
117
+
118
+ function rules(records: Row[], more: MockRules = {}): MockRules {
89
119
  return {
90
120
  reason: (request) => ({
91
121
  values: readValues((JSON.parse(request.prompt) as { text: string }).text),
@@ -105,7 +135,12 @@ function rules(records: { name: string; text: string }[], more: MockRules = {}):
105
135
  assert.ok(query.startsWith("# values-extract: texts"));
106
136
  return records
107
137
  .filter((r) => query.includes(iriOf(r.name)))
108
- .map((r) => ({ s: iriOf(r.name), label: r.name, text: r.text }));
138
+ .map((r) => ({
139
+ s: iriOf(r.name),
140
+ label: r.name,
141
+ ...(r.text !== undefined ? { text: r.text } : {}),
142
+ ...Object.fromEntries(Object.entries(r.box ?? {}).map(([k, v]) => [k, String(v)])),
143
+ }));
109
144
  },
110
145
  ...more,
111
146
  };
@@ -150,7 +185,21 @@ test("the samples give the values of the answer key and none it leaves out", asy
150
185
  record.name,
151
186
  );
152
187
  }
153
- assert.deepEqual(state, { instances: 6, written: 20, review: 0, dropped: 2 });
188
+ assert.deepEqual(state, {
189
+ instances: 6,
190
+ values_proposed: 22,
191
+ written: 20,
192
+ review: 0,
193
+ weak_support: 2,
194
+ quote_not_found: 0,
195
+ invalid_value: 0,
196
+ unknown_property: 0,
197
+ duplicate: 0,
198
+ failed: 0,
199
+ commits: 1,
200
+ removed: 0,
201
+ });
202
+ assert.equal(lbb.questions.size, 0);
154
203
  assert.equal(lbb.reviews.size, 0);
155
204
  assert.equal(turns.length, 1);
156
205
  });
@@ -195,16 +244,20 @@ test("each step reports its phase as the LBB-run values.extract words it", async
195
244
  );
196
245
  });
197
246
 
198
- test("a value below the gate goes to review, and the checks drop wrong types and quotes", async () => {
247
+ test("a value below the gate goes to review, and the checks run in LBB's order", async () => {
199
248
  const record = {
200
249
  name: "S 9.1",
201
250
  text: "S 9.1 Becken aus Kunststoff. Breite etwa 70 cm, Tiefe 40 cm.",
202
251
  };
252
+ // The checks of values.extract: the property, then the first value of
253
+ // it, then the type, then the quote. A second material is a duplicate
254
+ // even when the first one fails a later check.
203
255
  const proposals = [
204
- { property: "width_cm", value: "70", quote: "Breite etwa 70 cm" },
205
- { property: "depth_cm", value: "vierzig", quote: "Tiefe 40 cm" },
206
- { property: "material", value: "Holz", quote: "aus Holz gefertigt" },
207
- { property: "material", value: "Kunststoff", quote: "Becken aus Kunststoff" },
256
+ { property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" },
257
+ { property: "depth_cm", value: "vierzig", unit: "cm", quote: "Tiefe 40 cm" },
258
+ { property: "material", value: "Kunststoff", unit: "", quote: "Becken aus Kunststoff" },
259
+ { property: "material", value: "Holz", unit: "", quote: "aus Holz gefertigt" },
260
+ { property: "height_cm", value: "80", unit: "", quote: "Becken aus Kunststoff" },
208
261
  ];
209
262
  const lbb = new MockActivities(rules([record], { reason: () => ({ values: proposals }) }));
210
263
  const { state, turns } = await runMessage(
@@ -217,38 +270,393 @@ test("a value below the gate goes to review, and the checks drop wrong types and
217
270
  );
218
271
 
219
272
  assert.deepEqual(written(lbb, record.name), { material: "Kunststoff" });
220
- assert.deepEqual(state, { instances: 1, written: 1, review: 1, dropped: 0 });
221
- const [item] = [...lbb.reviews.values()];
273
+ assert.equal(state.values_proposed, 5);
274
+ assert.equal(state.written, 1);
275
+ assert.equal(state.review, 1);
276
+ assert.equal(state.invalid_value, 1);
277
+ assert.equal(state.duplicate, 1);
278
+ assert.equal(state.unknown_property, 1);
279
+ const [item] = [...lbb.questions.values()];
280
+ assert.equal(item!.kind, "value");
281
+ // The key of LBB's values.extract: its instance and property.
282
+ assert.equal(item!.key, valueItemKey("values-extract", iriOf(record.name), "width_cm"));
283
+ assert.equal(
284
+ item!.key,
285
+ `starter:values-extract:value:${stableHashHex(["value_item_v2", entityIdFromIri(iriOf(record.name)), "width_cm"]).slice(0, 24)}`,
286
+ );
222
287
  assert.equal(item!.title, "Set width_cm of S 9.1 to 70");
223
- assert.equal(item!.workflow, "values-extract");
288
+ assert.equal(item!.rationale, "The text supports the value with 0.60, below the gate 0.8.");
289
+ assert.deepEqual(item!.workflow, { name: "values-extract" });
290
+ assert.deepEqual(item!.subject, {
291
+ iri: iriOf(record.name),
292
+ type: "EquipmentItem",
293
+ name: record.name,
294
+ });
295
+ assert.deepEqual(item!.question, {
296
+ property: "width_cm",
297
+ value: 70,
298
+ unit: "cm",
299
+ confidence: item!.question.confidence,
300
+ });
301
+ assert.equal(item!.evidence![0]!.quote, "Breite etwa 70 cm");
224
302
  assert.deepEqual(item!.facts!.entity_properties![0]!.properties, { width_cm: 70 });
225
303
 
226
304
  // Accepted, the value is written with the others the next time the instance is read.
227
305
  const accepting = new MockActivities(
228
- rules([record], { reason: () => ({ values: proposals }), reviewStatus: () => "accepted" }),
306
+ rules([record], {
307
+ reason: () => ({ values: proposals }),
308
+ reviewAnswer: () => ({ decision: "accept" }),
309
+ }),
229
310
  );
230
311
  await runMessage(
231
312
  createWorkflow(accepting),
232
313
  triggerMessage("values-extract", [iriOf(record.name)]),
233
314
  );
234
315
  assert.deepEqual(written(accepting, record.name), { width_cm: 70, material: "Kunststoff" });
316
+
317
+ // Corrected, the person's number is written as given, in the property's unit.
318
+ const correcting = new MockActivities(
319
+ rules([record], {
320
+ reason: () => ({ values: proposals }),
321
+ reviewAnswer: () => ({ decision: "correct", value: 72 }),
322
+ }),
323
+ );
324
+ await runMessage(
325
+ createWorkflow(correcting),
326
+ triggerMessage("values-extract", [iriOf(record.name)]),
327
+ );
328
+ assert.deepEqual(written(correcting, record.name), { width_cm: 72, material: "Kunststoff" });
235
329
  });
236
330
 
237
- test("an instance that leaves the class loses its values and their sources", async () => {
238
- const lbb = new MockActivities(rules(RECORDS));
331
+ /** A second message to the same workflow, at a later commit (its own step effect ids). */
332
+ const later = (iri: string) => ({
333
+ ...triggerMessage("values-extract", [iri]),
334
+ as_of_commit_seq: 8,
335
+ });
336
+
337
+ test("a person's correction stays when new evidence reads another value, which asks again", async () => {
338
+ const records = [
339
+ { name: "S 9.3", text: "S 9.3 Becken aus Kunststoff. Breite etwa 70 cm, Tiefe 40 cm." },
340
+ ];
341
+ let proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" }];
342
+ const lbb = new MockActivities(
343
+ rules(records, {
344
+ reason: () => ({ values: proposals }),
345
+ reviewAnswer: (request) =>
346
+ request.question.property === "width_cm" ? { decision: "correct", value: 72 } : undefined,
347
+ }),
348
+ );
349
+ const workflow = createWorkflow(lbb);
350
+ await runMessage(workflow, triggerMessage("values-extract", [iriOf("S 9.3")]));
351
+ assert.deepEqual(written(lbb, "S 9.3"), { width_cm: 72 });
352
+
353
+ // A new version of the text, and the model reads 80 above the gate: the
354
+ // person is asked again, and the corrected 72 stays until they answer.
355
+ records[0]!.text = "S 9.3 Becken aus Kunststoff. Breite 80 cm.";
356
+ proposals = [{ property: "width_cm", value: "80", unit: "cm", quote: "Breite 80 cm" }];
357
+ await runMessage(workflow, later(iriOf("S 9.3")));
358
+ assert.deepEqual(written(lbb, "S 9.3"), { width_cm: 72 });
359
+ const [item] = await lbb.reviewItems([
360
+ valueItemKey("values-extract", iriOf("S 9.3"), "width_cm"),
361
+ ]);
362
+ assert.equal(item!.status, "open");
363
+ assert.deepEqual(item!.question?.value, 80);
364
+ assert.deepEqual(item!.previous_answer, { decision: "correct", value: 72 });
365
+ });
366
+
367
+ test("an accepted value stays when new evidence reads the same value, and asks nothing", async () => {
368
+ const records = [{ name: "S 9.5", text: "S 9.5 Becken. Breite etwa 70 cm." }];
369
+ let proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" }];
370
+ const lbb = new MockActivities(
371
+ rules(records, {
372
+ reason: () => ({ values: proposals }),
373
+ reviewAnswer: () => ({ decision: "accept" }),
374
+ }),
375
+ );
376
+ const workflow = createWorkflow(lbb);
377
+ await runMessage(workflow, triggerMessage("values-extract", [iriOf("S 9.5")]));
378
+ assert.deepEqual(written(lbb, "S 9.5"), { width_cm: 70 });
379
+ records[0]!.text = "S 9.5 Becken. Breite 70 cm.";
380
+ proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite 70 cm" }];
381
+ await runMessage(workflow, later(iriOf("S 9.5")));
382
+ assert.deepEqual(written(lbb, "S 9.5"), { width_cm: 70 });
383
+ assert.equal(lbb.previous.size, 0, "nothing is asked again");
384
+ });
385
+
386
+ test("a rejected value is not written again, even above the gate", async () => {
387
+ const records = [{ name: "S 9.6", text: "S 9.6 Becken. Breite etwa 70 cm." }];
388
+ let proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" }];
389
+ const lbb = new MockActivities(
390
+ rules(records, {
391
+ reason: () => ({ values: proposals }),
392
+ reviewAnswer: () => ({ decision: "reject" }),
393
+ }),
394
+ );
239
395
  const workflow = createWorkflow(lbb);
396
+ await runMessage(workflow, triggerMessage("values-extract", [iriOf("S 9.6")]));
397
+ assert.deepEqual(written(lbb, "S 9.6"), {});
398
+ records[0]!.text = "S 9.6 Becken. Breite 70 cm.";
399
+ proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite 70 cm" }];
400
+ await runMessage(workflow, later(iriOf("S 9.6")));
401
+ assert.deepEqual(written(lbb, "S 9.6"), {});
402
+ });
403
+
404
+ test("an answer the graph refused is not written, and the batch's other values are", async () => {
405
+ const records = [
406
+ { name: "S 9.7", text: "S 9.7 Becken. Breite etwa 70 cm." },
407
+ { name: "S 9.8", text: "S 9.8 Becken. Breite 60 cm." },
408
+ ];
409
+ const lbb = new MockActivities(
410
+ rules(records, {
411
+ reason: (request) =>
412
+ (JSON.parse(request.prompt) as { text: string }).text.includes("S 9.7")
413
+ ? {
414
+ values: [
415
+ { property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" },
416
+ ],
417
+ }
418
+ : { values: [{ property: "width_cm", value: "60", unit: "cm", quote: "Breite 60 cm" }] },
419
+ reviewAnswer: () => ({ decision: "correct", value: 72 }),
420
+ reviewRefused: () => "the shape MaxWidth rejects 72",
421
+ }),
422
+ );
240
423
  await runMessage(
424
+ createWorkflow(lbb),
425
+ triggerMessage(
426
+ "values-extract",
427
+ records.map((r) => iriOf(r.name)),
428
+ ),
429
+ );
430
+ assert.deepEqual(written(lbb, "S 9.7"), {});
431
+ assert.deepEqual(written(lbb, "S 9.8"), { width_cm: 60 });
432
+ });
433
+
434
+ test("a value the graph refused is not written when a later text states it", async () => {
435
+ const records = [{ name: "S 9.6", text: "S 9.6 Becken. Breite etwa 70 cm." }];
436
+ let proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" }];
437
+ const lbb = new MockActivities(
438
+ rules(records, {
439
+ reason: () => ({ values: proposals }),
440
+ reviewAnswer: () => ({ decision: "correct", value: 72 }),
441
+ reviewRefused: () => "the shape MaxWidth rejects 72",
442
+ }),
443
+ );
444
+ const workflow = createWorkflow(lbb);
445
+ await runMessage(workflow, triggerMessage("values-extract", [iriOf("S 9.6")]));
446
+ assert.deepEqual(written(lbb, "S 9.6"), {});
447
+ // The text now states 72 above the gate: the graph refused it, so it is
448
+ // not written; the item stays open for the person.
449
+ records[0]!.text = "S 9.6 Becken. Breite 72 cm.";
450
+ proposals = [{ property: "width_cm", value: "72", unit: "cm", quote: "Breite 72 cm" }];
451
+ await runMessage(workflow, later(iriOf("S 9.6")));
452
+ assert.deepEqual(written(lbb, "S 9.6"), {});
453
+ });
454
+
455
+ test("the value a person decided on, quoted from another span, asks nothing", async () => {
456
+ const records = [{ name: "S 9.2", text: "S 9.2 Becken. Breite etwa 70 cm." }];
457
+ let proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" }];
458
+ const lbb = new MockActivities(
459
+ rules(records, {
460
+ reason: () => ({ values: proposals }),
461
+ reviewAnswer: () => ({ decision: "correct", value: 72 }),
462
+ }),
463
+ );
464
+ const workflow = createWorkflow(lbb);
465
+ await runMessage(workflow, triggerMessage("values-extract", [iriOf("S 9.2")]));
466
+ assert.deepEqual(written(lbb, "S 9.2"), { width_cm: 72 });
467
+ // The same text; the model quotes another span of it, and spells the unit
468
+ // otherwise. The person decided on 70 already.
469
+ proposals = [{ property: "width_cm", value: "70", unit: "CM", quote: "etwa 70 cm" }];
470
+ await runMessage(workflow, later(iriOf("S 9.2")));
471
+ assert.deepEqual(written(lbb, "S 9.2"), { width_cm: 72 });
472
+ assert.equal(lbb.previous.size, 0, "nothing is asked again");
473
+ });
474
+
475
+ test("the evidence of a value is its quote and the unit the text states, as LBB marks it", () => {
476
+ const [checked] = check(
477
+ [{ property: "width_cm", value: "60", unit: "CM", quote: "Breite 60 cm" }],
478
+ defaults.properties,
479
+ "Waschtisch, Breite 60 cm",
480
+ { width_cm: "cm" },
481
+ );
482
+ // LBB's golden: values_evidence_hash("Breite 60 cm", "cm").
483
+ assert.equal(evidenceOf(checked!), "952222f8bb95037753aab03b96bab676");
484
+ });
485
+
486
+ test("a held value keeps its source link", async () => {
487
+ const records = [{ name: "S 9.9", text: "S 9.9 Becken. Breite etwa 70 cm." }];
488
+ let proposals = [{ property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" }];
489
+ const lbb = new MockActivities(
490
+ rules(records, {
491
+ reason: () => ({ values: proposals }),
492
+ reviewAnswer: () => ({ decision: "accept" }),
493
+ }),
494
+ );
495
+ const workflow = createWorkflow(lbb);
496
+ await runMessage(workflow, triggerMessage("values-extract", [iriOf("S 9.9")]));
497
+ const links = () =>
498
+ lbb.graph.edgesOf("HAS_VALUE_SOURCE").filter((edge) => edge.source === iriOf("S 9.9")).length;
499
+ assert.equal(links(), 1);
500
+ // New evidence with another value: asked again, the accepted 70 and its
501
+ // source link stay.
502
+ records[0]!.text = "S 9.9 Becken. Breite 85 cm.";
503
+ proposals = [{ property: "width_cm", value: "85", unit: "cm", quote: "Breite 85 cm" }];
504
+ await runMessage(workflow, later(iriOf("S 9.9")));
505
+ assert.deepEqual(written(lbb, "S 9.9"), { width_cm: 70 });
506
+ assert.equal(links(), 1);
507
+ assert.equal(lbb.previous.size, 1);
508
+ });
509
+
510
+ for (const [status, writes] of [
511
+ ["accepted", { width_cm: 70 }],
512
+ ["open", {}],
513
+ ["dismissed", {}],
514
+ ] as const) {
515
+ test(`a value an earlier version filed as a suggestion keeps working (${status})`, async () => {
516
+ const record = { name: "S 9.4", text: "S 9.4 Becken. Breite etwa 70 cm." };
517
+ const proposals = [
518
+ { property: "width_cm", value: "70", unit: "cm", quote: "Breite etwa 70 cm" },
519
+ ];
520
+ const lbb = new MockActivities(rules([record], { reason: () => ({ values: proposals }) }));
521
+ // The key of the suggestion: one per value.
522
+ const key = legacyValueKey("values-extract", iriOf(record.name), {
523
+ property: "width_cm",
524
+ value: "70",
525
+ quote: "",
526
+ outcome: "",
527
+ });
528
+ const facts: Schemas["SuggestedFacts"] = {
529
+ entity_properties: [
530
+ {
531
+ ...entityRef(iriOf(record.name), "EquipmentItem", record.name),
532
+ properties: { width_cm: 70 },
533
+ },
534
+ ],
535
+ triplets: [],
536
+ };
537
+ lbb.reviews.set(key, { key, title: "Set width_cm of S 9.4 to 70", status, facts });
538
+ await runMessage(createWorkflow(lbb), triggerMessage("values-extract", [iriOf(record.name)]));
539
+ // An accepted one is written again, an open one stays the question (no
540
+ // value item), and a dismissed one is a reject of its value.
541
+ assert.equal(lbb.questions.size, 0);
542
+ assert.equal(lbb.reviews.get(key)?.status, status);
543
+ assert.deepEqual(written(lbb, record.name), writes);
544
+ });
545
+ }
546
+
547
+ test("an instance that leaves the class keeps its values, as in LBB's values.extract", async () => {
548
+ const lbb = new MockActivities(rules(RECORDS));
549
+ const workflow = createWorkflow(lbb);
550
+ const first = await runMessage(
241
551
  workflow,
242
552
  triggerMessage(
243
553
  "values-extract",
244
554
  RECORDS.map((r) => iriOf(r.name)),
245
555
  ),
246
556
  );
247
- await runMessage(workflow, triggerMessage("values-extract", [], [iriOf("S 4.1")]));
557
+ const commits = lbb.graph.commits;
558
+ const { state } = await runMessage(
559
+ workflow,
560
+ triggerMessage("values-extract", [], [iriOf("S 4.1")]),
561
+ first.state,
562
+ );
563
+
564
+ // Its values go with its fields when it is retracted; the turn writes nothing.
565
+ assert.equal(written(lbb, "S 4.1").width_cm, 60);
566
+ assert.equal(lbb.graph.ofType("ValueSource").length, 20);
567
+ assert.equal(lbb.graph.commits, commits);
568
+ assert.equal(state.removed, 1);
569
+ assert.equal(state.instances, 6);
570
+ });
571
+
572
+ test("a value that changes back is written again: each write's id is its turn's effect id", async () => {
573
+ // The finding of workflows-dogfooding.md 1.5: an id made from what a
574
+ // write holds repeats when the value goes A, B, A, and the commit path
575
+ // answers the repeat as a replay. An effect id is new for each message.
576
+ const texts = [
577
+ "S 7.1 Waschtisch, Breite 55 cm.",
578
+ "S 7.1 Waschtisch, Breite 60 cm.",
579
+ "S 7.1 Waschtisch, Breite 55 cm.",
580
+ ];
581
+ let text = texts[0]!;
582
+ const lbb = new MockActivities(
583
+ rules([
584
+ {
585
+ name: "S 7.1",
586
+ get text() {
587
+ return text;
588
+ },
589
+ },
590
+ ]),
591
+ );
592
+ const workflow = createWorkflow(lbb);
593
+ const widths: unknown[] = [];
594
+ const keys: string[] = [];
595
+ let state: unknown = {};
596
+ for (const [n, next] of texts.entries()) {
597
+ text = next;
598
+ const message = {
599
+ ...triggerMessage("values-extract", [iriOf("S 7.1")]),
600
+ as_of_commit_seq: 7 + n,
601
+ };
602
+ const { result, turn } = await runTurn(workflow, state, message);
603
+ state = result.state;
604
+ widths.push(written(lbb, "S 7.1").width_cm);
605
+ keys.push(
606
+ (turn.outputs[turn.keys.indexOf("write")] as { idempotency_key: string }).idempotency_key,
607
+ );
608
+ }
609
+ assert.deepEqual(widths, [55, 60, 55]);
610
+ assert.equal(new Set(keys).size, 3, keys.join(" "));
611
+ assert.equal(lbb.graph.commits, 3);
612
+ });
613
+
614
+ test("an instance whose model call is refused goes back to the watch and keeps its values", async () => {
615
+ const lbb = new MockActivities(rules(RECORDS));
616
+ const workflow = createWorkflow(lbb);
617
+ const iris = RECORDS.map((r) => iriOf(r.name));
618
+ const first = await runMessage(workflow, triggerMessage("values-extract", iris));
619
+ const refusing = new MockActivities({
620
+ ...rules(RECORDS),
621
+ refuses: (kind, request) =>
622
+ kind === "reason" && (request as { prompt: string }).prompt.includes("S 4.2"),
623
+ });
624
+ // The graph of the first run, read by the second.
625
+ for (const [iri, entity] of lbb.graph.entities) refusing.graph.entities.set(iri, entity);
626
+ refusing.graph.edges = [...lbb.graph.edges];
627
+ const { state, turns } = await runMessage(
628
+ createWorkflow(refusing),
629
+ { ...triggerMessage("values-extract", iris), as_of_commit_seq: 8 },
630
+ first.state,
631
+ );
632
+
633
+ assert.equal(turns.length, 1);
634
+ assert.deepEqual(
635
+ refusing.requeued.map((r) => r.failed.map((f) => f.instance)),
636
+ [[iriOf("S 4.2")]],
637
+ );
638
+ assert.equal(written(refusing, "S 4.2").width_cm, 50);
639
+ assert.equal(state.failed, 1);
640
+ assert.equal(state.instances, 12);
641
+ });
248
642
 
249
- assert.deepEqual(written(lbb, "S 4.1"), {});
250
- assert.ok(!lbb.graph.edgesOf("HAS_VALUE_SOURCE").some((e) => e.source === iriOf("S 4.1")));
251
- assert.equal(lbb.graph.ofType("ValueSource").length, 20 - 4);
643
+ test("a value's evidence has the instance's page box as its region", async () => {
644
+ const record = {
645
+ name: "S 9.2",
646
+ text: "S 9.2 Handtuchspender, Edelstahl, 30x10 cm.",
647
+ box: { page: 3, x0: 0.1, y0: 0.2, x1: 0.6, y1: 0.3 },
648
+ };
649
+ const lbb = new MockActivities(rules([record]));
650
+ await runMessage(createWorkflow(lbb), triggerMessage("values-extract", [iriOf(record.name)]));
651
+ const edge = lbb.graph.edgesOf("HAS_VALUE_SOURCE")[0]!;
652
+ assert.deepEqual((edge.evidence as { region: unknown }).region, {
653
+ page: 3,
654
+ x0: 0.1,
655
+ y0: 0.2,
656
+ x1: 0.6,
657
+ y1: 0.3,
658
+ confidence: 0.92,
659
+ });
252
660
  });
253
661
 
254
662
  test("a message of more instances than a turn reads continues in a new turn", async () => {
@@ -273,7 +681,104 @@ test("values take their property's type, and a quote is in the text", () => {
273
681
  assert.equal(typed("ja", "bool"), true);
274
682
  assert.equal(typed("2026-09-16", "date_time"), "2026-09-16T00:00:00Z");
275
683
  assert.equal(typed("16.09.2026", "date_time"), null);
684
+ assert.equal(typed("2026-09-16T08:30:00Z", "datetime"), "2026-09-16T08:30:00Z");
685
+ assert.equal(typed("0x10", "i64"), null);
686
+ assert.equal(typed("1,5", "f64"), 1.5);
687
+ assert.deepEqual(
688
+ check(
689
+ [
690
+ { property: "width_cm", value: "60", quote: "60x40" },
691
+ { property: "width_cm", value: "61", quote: "61x40" },
692
+ ],
693
+ defaults.properties,
694
+ "text: Waschtisch 60x40",
695
+ ).map((v) => v.outcome),
696
+ ["", "duplicate"],
697
+ );
276
698
  assert.ok(quoteHolds("ca. 60x40 cm", "60", "Waschtisch, ca. 60x40 cm, weiß"));
277
699
  assert.ok(!quoteHolds("weiß", "Keramik", "Waschtisch, weiß"));
278
700
  assert.ok(!quoteHolds("aus Holz gefertigt", "Holz", "Waschtisch aus Keramik"));
279
701
  });
702
+
703
+ test("a number in another unit, or with none, goes to review and is not written", async () => {
704
+ const record = {
705
+ name: "S 9.2",
706
+ text: "S 9.2 Waschtisch, Breite 600 mm, Tiefe 45, aus Keramik.",
707
+ };
708
+ const proposals = [
709
+ { property: "width_cm", value: "600", unit: "mm", quote: "Breite 600 mm" },
710
+ { property: "depth_cm", value: "45", unit: "", quote: "Waschtisch, Breite 600 mm, Tiefe 45" },
711
+ { property: "material", value: "Keramik", unit: "", quote: "aus Keramik" },
712
+ ];
713
+ const lbb = new MockActivities(rules([record], { reason: () => ({ values: proposals }) }));
714
+ const { state } = await runMessage(
715
+ createWorkflow(lbb),
716
+ triggerMessage("values-extract", [iriOf(record.name)]),
717
+ );
718
+ assert.deepEqual(written(lbb, record.name), { material: "Keramik" });
719
+ assert.equal(state.instances, 1);
720
+ assert.equal(state.values_proposed, 3);
721
+ assert.equal(state.written, 1);
722
+ assert.equal(state.review, 2);
723
+ const rationales = [...lbb.questions.values()].map((item) => item.rationale ?? "").sort();
724
+ assert.match(
725
+ rationales[0]!,
726
+ /^The unit is not confirmed: the text states mm; the property holds cm\./,
727
+ );
728
+ assert.match(
729
+ rationales[1]!,
730
+ /^The unit is not confirmed: the text states no unit; the property holds cm\./,
731
+ );
732
+ });
733
+
734
+ test("the ontology declares the unit of each number property", () => {
735
+ const ops = ontology();
736
+ assert.deepEqual(
737
+ ops.flatMap((op) => (op.op === "add_property" && op.unit ? [[op.name, op.unit]] : [])),
738
+ [
739
+ ["width_cm", "cm"],
740
+ ["depth_cm", "cm"],
741
+ ],
742
+ );
743
+ assert.equal(unitOf(defaults, "material"), undefined);
744
+ assert.equal(unitOf({ ...defaults, units: { material: "cm" } }, "material"), undefined);
745
+ assert.equal(unitProblem("cm", "cm", "ca. 60x40cm"), undefined);
746
+ assert.equal(unitProblem("m2", "m²", "20 m²"), undefined);
747
+ assert.equal(unitProblem("cm", "cm", "Breite 60"), "the quote does not state the unit cm");
748
+ assert.equal(unitProblem("m", "m", "600 mm"), "the quote does not state the unit m");
749
+ // A number with its unit in `value`, against the rules: LBB reads the two
750
+ // apart, and so does the template.
751
+ const [width] = check(
752
+ [{ property: "width_cm", value: "60 cm", unit: "", quote: "Breite 60 cm" }],
753
+ defaults.properties,
754
+ "Waschtisch, Breite 60 cm",
755
+ { width_cm: "cm" },
756
+ );
757
+ assert.equal(width!.typed, 60);
758
+ assert.equal(width!.unconfirmed, undefined);
759
+ });
760
+
761
+ test("test:examples runs the workflow on chosen inputs and writes nothing", async () => {
762
+ const lbb = new MockActivities(rules(RECORDS));
763
+ const iris = RECORDS.slice(0, 2).map((r) => iriOf(r.name));
764
+ const results = await testExamples(lbb, iris);
765
+ assert.equal(lbb.graph.edgesOf("HAS_VALUE_SOURCE").length, 0, "nothing is written");
766
+ assert.deepEqual(
767
+ results.map((r) => r.iri),
768
+ iris,
769
+ );
770
+ const first = results[0]!;
771
+ assert.deepEqual(first.errors, []);
772
+ assert.deepEqual(first.review, []);
773
+ const width = first.written.find((v) => v.property === "width_cm");
774
+ assert.equal(width?.value, "60");
775
+ assert.equal(width?.quote, "ca. 60x40 cm");
776
+ assert.equal(width?.support, 0.92);
777
+ assert.deepEqual(
778
+ first.written.map((v) => v.property).sort(),
779
+ EXPECTED.records[0]!.values.map((v) => v.property).sort(),
780
+ );
781
+ const lines = report(results);
782
+ assert.equal(lines[0], iris[0]);
783
+ assert.ok(lines.some((line) => line.startsWith(" write width_cm = 60 (support 0.92)")));
784
+ });