@wrenautomation/vireo 0.11.0 → 0.13.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/dist/store.js CHANGED
@@ -7,12 +7,15 @@ import { PgTransaction } from "drizzle-orm/pg-core";
7
7
  import { collect } from "./collect.js";
8
8
  import { atomic, VireoError } from "./db.js";
9
9
  import { live } from "./live.js";
10
- import { batches, guides, lineage, notes, samples, ships, steers, subjects, TAP, VERDICTS, verdictOf, } from "./schema.js";
10
+ import { batches, calls, guides, lineage, notes, samples, ships, steers, subjects, TAP, VERDICTS, verdictOf, } from "./schema.js";
11
11
  import { fitPlackettLuce, rateSplit, tiersToChoices } from "./stats.js";
12
12
  import { isSource } from "./sync.js";
13
13
  /** `s3://` (the bucket signs it) or `https://` (served as is: a live page on its own origin). */
14
14
  export const isMediaUri = (uri) => /^(s3|https):\/\/[^\s/]+/i.test(uri);
15
15
  const parentOf = (p) => typeof p === "number" ? { id: p, took: null } : { id: p.id, took: p.took?.trim() || null };
16
+ /** `by` of calls made before calls had a judge (migration 0008); a host may name them after. */
17
+ export const EARLIER_JUDGE = "person";
18
+ export const BATCH_SORTS = ["waiting", "newest", "oldest", "subject", "good"];
16
19
  /** `roots` and every sample that grew from them, over any parent edge. */
17
20
  function grownFrom(roots, edges) {
18
21
  const kids = new Map();
@@ -47,26 +50,72 @@ const shuffled = (xs) => {
47
50
  return a;
48
51
  };
49
52
  /**
50
- * Strength per judged sample: each judged batch is a partial ranking by score, 10 down to 1, ties
51
- * unordered. Wins from 9 and up count double.
53
+ * One sheet per (batch, person) who called in it: each sample of the batch with that person's
54
+ * call, or none (unmarked). The unit of every rank and rate (D11).
52
55
  */
53
- export function strengths(rows) {
54
- const byBatch = new Map();
56
+ export function sheets(rows, cs) {
57
+ const inBatch = new Map();
55
58
  for (const r of rows)
56
- byBatch.set(r.batchId, [...(byBatch.get(r.batchId) ?? []), r]);
57
- const judged = [...byBatch.values()].filter((b) => b.some((r) => r.score != null));
58
- const ids = judged.flat().map((r) => String(r.id));
59
+ inBatch.set(r.batchId, [...(inBatch.get(r.batchId) ?? []), r.id]);
60
+ const batchOf = new Map(rows.map((r) => [r.id, r.batchId]));
61
+ const out = new Map();
62
+ for (const c of cs) {
63
+ const b = batchOf.get(c.sampleId);
64
+ if (b == null)
65
+ continue;
66
+ const k = `${b}\u0000${c.by}`;
67
+ const sheet = out.get(k) ?? { batchId: b, by: c.by, got: new Map() };
68
+ sheet.got.set(c.sampleId, c);
69
+ out.set(k, sheet);
70
+ }
71
+ return [...out.values()].map((x) => ({
72
+ batchId: x.batchId,
73
+ by: x.by,
74
+ marks: (inBatch.get(x.batchId) ?? []).map((id) => ({ id, call: x.got.get(id) ?? null })),
75
+ }));
76
+ }
77
+ /**
78
+ * Strength per judged sample: each sheet is a partial ranking by score, 10 down to 1, ties
79
+ * unordered; unmarked reads as 5. Wins from 9 and up count double.
80
+ */
81
+ export function strengths(rows, cs) {
82
+ const ss = sheets(rows, cs);
83
+ const ids = [...new Set(ss.flatMap((x) => x.marks.map((m) => String(m.id))))];
59
84
  const scores = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
60
- const choices = judged.flatMap((b) => tiersToChoices(scores.map((n) => b.filter((r) => (r.score ?? UNMARKED) === n).map((r) => String(r.id))), scores.map((n) => (n >= TAP.amazing ? 2 : 1))));
85
+ const choices = ss.flatMap((x) => tiersToChoices(scores.map((n) => x.marks.filter((m) => (m.call?.score ?? UNMARKED) === n).map((m) => String(m.id))), scores.map((n) => (n >= TAP.amazing ? 2 : 1))));
61
86
  return fitPlackettLuce(ids, choices);
62
87
  }
63
- /** With-vs-without good rate per guide, over judged samples. */
64
- export function guideStats(guideRows, judged) {
88
+ /** Good or better over every (sample, person) mark of these sheets; null with none. */
89
+ export function goodRateOf(ss) {
90
+ const marks = ss.flatMap((x) => x.marks);
91
+ return marks.length
92
+ ? marks.filter((m) => GOOD.includes(m.call?.verdict ?? null)).length / marks.length
93
+ : null;
94
+ }
95
+ /** With-vs-without good rate per guide, over every mark in judged batches. */
96
+ export function guideStats(guideRows, judged, cs) {
97
+ const guidesOf = new Map(judged.map((r) => [r.id, r.guides]));
98
+ const marks = sheets(judged, cs).flatMap((x) => x.marks);
65
99
  return guideRows.map((g) => ({
66
100
  ...g,
67
- effect: rateSplit(judged.map((s) => ({ has: s.guides.includes(g.id), good: GOOD.includes(s.verdict) }))),
101
+ effect: rateSplit(marks.map((m) => ({
102
+ has: (guidesOf.get(m.id) ?? []).includes(g.id),
103
+ good: GOOD.includes(m.call?.verdict ?? null),
104
+ }))),
68
105
  }));
69
106
  }
107
+ /** Mean of the calls a reader may see, and its zone; null with none. */
108
+ const pooled = (cs) => {
109
+ if (!cs.length)
110
+ return { score: null, verdict: null };
111
+ const mean = cs.reduce((a, c) => a + c.score, 0) / cs.length;
112
+ return { score: Math.round(mean * 10) / 10, verdict: verdictOf(Math.round(mean)) };
113
+ };
114
+ /**
115
+ * What a reader may see of an open batch (D11). `viewer` = a person: while open, only their own
116
+ * calls and notes, and none of the agent's words. Null = the agent: everything.
117
+ */
118
+ const blindFor = (viewer, open) => viewer != null && open;
70
119
  /** Waits on the person: open and its subject's newest. A newer batch replaces an unmarked one. */
71
120
  const WAITS = and(eq(batches.state, "open"), sql `${batches.n} = (select max(b.n) from vireo.batches b where b.subject_id = ${batches.subjectId})`);
72
121
  /** How people and agents name a batch: its number within the subject. The id is secondary. */
@@ -243,6 +292,12 @@ export class Vireo {
243
292
  async addBatch(ref, b) {
244
293
  if (!b.why.trim())
245
294
  throw new VireoError("say why: what changed and what it tests");
295
+ const title = b.title?.trim() || null;
296
+ // appending keeps the batch's title
297
+ if (b.into == null && !title)
298
+ throw new VireoError("a title: one line, what this batch is");
299
+ if (title && title.length > 120)
300
+ throw new VireoError("a title is 120 characters at most");
246
301
  if (!b.samples.length)
247
302
  throw new VireoError("a batch needs samples");
248
303
  for (const s of b.samples)
@@ -319,6 +374,7 @@ export class Vireo {
319
374
  .values({
320
375
  subjectId: subject.id,
321
376
  n: (top?.n ?? 0) + 1,
377
+ title,
322
378
  why: b.why,
323
379
  agent: b.agent ?? null,
324
380
  })
@@ -493,8 +549,11 @@ export class Vireo {
493
549
  const pruned = await this.prunedIn(this.db, s.id, live);
494
550
  return { steers: live, pruned: [...pruned.keys()].sort((a, b) => a - b) };
495
551
  }
496
- /** Batches that wait on the person, oldest first, with samples and notes: what they have to pick. */
497
- async queue(ref) {
552
+ /**
553
+ * Batches that wait on the person, oldest first, with samples and notes: what they have to pick.
554
+ * `viewer`: a person sees only their own calls and notes (D11).
555
+ */
556
+ async queue(ref, viewer = null) {
498
557
  // a subject, or one batch as `<subject>:<n>`
499
558
  const one = typeof ref === "string" && /:\d+$/.test(ref) ? await this.batchOf(ref) : null;
500
559
  const subjectId = ref == null || one ? null : (await this.subjectOf(ref)).id;
@@ -511,15 +570,30 @@ export class Vireo {
511
570
  .from(samples)
512
571
  .where(inArray(samples.batchId, open.map((o) => o.batch.id)))
513
572
  .orderBy(asc(samples.position));
514
- const ns = await this.notesOf(rows.map((r) => r.id));
573
+ const ids = rows.map((r) => r.id);
574
+ const mine = (x) => viewer == null || x.by === viewer;
575
+ const ns = (await this.notesOf(ids)).filter(mine);
576
+ const cs = (await this.callsOf(ids)).filter(mine);
515
577
  return open.map((o) => ({
516
578
  batch: { ref: refOf(o.subject.slug, o.batch.n), ...o.batch },
517
579
  subject: o.subject,
518
580
  samples: rows
519
581
  .filter((r) => r.batchId === o.batch.id)
520
- .map((r) => ({ ...r, notes: ns.filter((n) => n.sampleId === r.id) })),
582
+ .map((r) => {
583
+ const own = cs.filter((c) => c.sampleId === r.id);
584
+ return { ...r, ...pooled(own), calls: own, notes: ns.filter((n) => n.sampleId === r.id) };
585
+ }),
521
586
  }));
522
587
  }
588
+ async callsOf(sampleIds) {
589
+ if (!sampleIds.length)
590
+ return [];
591
+ return this.db
592
+ .select()
593
+ .from(calls)
594
+ .where(inArray(calls.sampleId, sampleIds))
595
+ .orderBy(asc(calls.at));
596
+ }
523
597
  async notesOf(sampleIds) {
524
598
  if (!sampleIds.length)
525
599
  return [];
@@ -539,20 +613,37 @@ export class Vireo {
539
613
  .where(inArray(lineage.childId, childIds))
540
614
  .orderBy(asc(lineage.childId), asc(lineage.position));
541
615
  }
542
- /** A call: a 1-10 score, a verdict (its tap score) or null to clear. The verdict follows. */
616
+ /**
617
+ * `by`'s call: a 1-10 score, a verdict (its tap score) or null to clear. The verdict follows.
618
+ * One call per person per sample (D11).
619
+ */
543
620
  async judge(sampleId, call, by) {
621
+ if (!by)
622
+ throw new VireoError("a call needs who made it", 400);
544
623
  const score = typeof call === "string" ? TAP[call] : call;
545
624
  if (score != null && !(Number.isInteger(score) && score >= 1 && score <= 10))
546
625
  throw new VireoError(`score ${score}: 1-10, or ${VERDICTS.join("|")}`, 400);
547
626
  return this.write(by, async (tx) => {
548
627
  const [row] = await tx
549
- .update(samples)
550
- .set({ score, verdict: score == null ? null : verdictOf(score) })
551
- .where(eq(samples.id, sampleId))
552
- .returning({ id: samples.id, score: samples.score, verdict: samples.verdict });
628
+ .select({ id: samples.id })
629
+ .from(samples)
630
+ .where(eq(samples.id, sampleId));
553
631
  if (!row)
554
632
  throw new VireoError(`no sample ${sampleId}`, 404);
555
- return row;
633
+ const mine = and(eq(calls.sampleId, sampleId), eq(calls.by, by));
634
+ if (score == null) {
635
+ await tx.delete(calls).where(mine);
636
+ return { id: sampleId, by, score: null, verdict: null };
637
+ }
638
+ const verdict = verdictOf(score);
639
+ await tx
640
+ .insert(calls)
641
+ .values({ sampleId, by, score, verdict })
642
+ .onConflictDoUpdate({
643
+ target: [calls.sampleId, calls.by],
644
+ set: { score, verdict, at: sql `now()` },
645
+ });
646
+ return { id: sampleId, by, score, verdict };
556
647
  });
557
648
  }
558
649
  async addNote(sampleId, n, by) {
@@ -620,6 +711,25 @@ export class Vireo {
620
711
  return row;
621
712
  });
622
713
  }
714
+ /** A batch's title (id or `<subject>:<n>`); blank refused. */
715
+ async renameBatch(ref, title, by) {
716
+ const to = title.trim();
717
+ if (!to)
718
+ throw new VireoError("a title, not blank");
719
+ if (to.length > 120)
720
+ throw new VireoError("a title is 120 characters at most");
721
+ return this.write(by, async (tx) => {
722
+ const id = await this.batchOf(ref, tx);
723
+ const [row] = await tx
724
+ .update(batches)
725
+ .set({ title: to })
726
+ .where(eq(batches.id, id))
727
+ .returning({ id: batches.id, n: batches.n, title: batches.title });
728
+ if (!row)
729
+ throw new VireoError(`no batch ${ref}`, 404);
730
+ return row;
731
+ });
732
+ }
623
733
  /** A sample's short name; blank clears it. */
624
734
  async renameSample(sampleId, name, by) {
625
735
  const to = name?.trim() || null;
@@ -690,10 +800,11 @@ export class Vireo {
690
800
  .orderBy(asc(samples.batchId), asc(samples.position));
691
801
  const ns = await this.notesOf(ss.map((r) => r.id));
692
802
  const es = await this.lineageOf(ss.map((r) => r.id));
803
+ const cs = await this.callsOf(ss.map((r) => r.id));
693
804
  const judgedBatches = new Set(bs.filter((b) => b.state === "closed").map((b) => b.id));
694
805
  const judged = ss.filter((r) => judgedBatches.has(r.batchId));
695
- const strength = new Map(strengths(ss).map((x) => [Number(x.id), x]));
696
- const gs = guideStats(await this.guidesFor(s), judged);
806
+ const strength = new Map(strengths(ss, cs).map((x) => [Number(x.id), x]));
807
+ const gs = guideStats(await this.guidesFor(s), judged, cs);
697
808
  return {
698
809
  subject: s,
699
810
  guides: gs,
@@ -702,11 +813,14 @@ export class Vireo {
702
813
  return {
703
814
  ref: refOf(s.slug, b.n),
704
815
  ...b,
705
- goodRate: mine.length
706
- ? mine.filter((r) => GOOD.includes(r.verdict)).length / mine.length
707
- : null,
816
+ goodRate: goodRateOf(sheets(mine, cs)),
817
+ judges: [
818
+ ...new Set(cs.filter((c) => mine.some((r) => r.id === c.sampleId)).map((c) => c.by)),
819
+ ],
708
820
  samples: mine.map((r) => ({
709
821
  ...r,
822
+ ...pooled(cs.filter((c) => c.sampleId === r.id)),
823
+ calls: cs.filter((c) => c.sampleId === r.id),
710
824
  strength: strength.get(r.id) ?? null,
711
825
  notes: ns.filter((n) => n.sampleId === r.id),
712
826
  parents: es
@@ -779,6 +893,165 @@ export class Vireo {
779
893
  return out;
780
894
  }
781
895
  /** Kept samples, newest first, with their subject. */
896
+ /**
897
+ * Batches as a page: filters, a sort and an offset (D11). `viewer` = the person reading: an open
898
+ * batch shows only their own marks, and none of the agent's words.
899
+ */
900
+ async batches(q = {}, viewer = null) {
901
+ const limit = Math.min(Math.max(q.limit ?? 30, 1), 100);
902
+ const after = Math.max(q.after ?? 0, 0);
903
+ const subjectId = q.subject == null ? null : (await this.subjectOf(q.subject)).id;
904
+ const good = sql `${calls.verdict} in ('good', 'amazing')`;
905
+ const n = sql `count(distinct ${samples.id})::int`;
906
+ const judges = sql `count(distinct ${calls.by})::int`;
907
+ // an open batch has no rate: partial, and a sort on it would show others' marks
908
+ const rate = sql `case when ${batches.state} = 'closed' then (count(${calls.id}) filter (where ${good}))::float / nullif((${judges}) * (${n}), 0) end`;
909
+ const waits = sql `(${WAITS})`;
910
+ const tally = (v) => sql `(count(${calls.id}) filter (where ${calls.verdict} = ${v}))::int`;
911
+ const where = and(q.subjects
912
+ ? inArray(batches.subjectId, q.subjects.length ? [...q.subjects] : [-1])
913
+ : undefined, subjectId ? eq(batches.subjectId, subjectId) : undefined, q.kind ? eq(subjects.kind, q.kind) : undefined, q.state === "waits" ? WAITS : q.state ? eq(batches.state, q.state) : undefined, q.who
914
+ ? sql `exists (select 1 from vireo.calls c join vireo.samples x on x.id = c.sample_id where x.batch_id = ${batches.id} and c.by = ${q.who})`
915
+ : undefined);
916
+ const order = {
917
+ newest: [desc(batches.createdAt), desc(batches.id)],
918
+ oldest: [asc(batches.createdAt), asc(batches.id)],
919
+ waiting: [desc(waits), desc(batches.createdAt), desc(batches.id)],
920
+ subject: [asc(subjects.name), desc(batches.n)],
921
+ good: [sql `${rate} desc nulls last`, desc(batches.createdAt), desc(batches.id)],
922
+ }[q.sort ?? "waiting"];
923
+ const got = await this.db
924
+ .select({
925
+ batch: batches,
926
+ subject: subjects,
927
+ samples: n,
928
+ kept: sql `(count(distinct ${samples.id}) filter (where ${samples.keptAt} is not null))::int`,
929
+ judges,
930
+ mine: viewer
931
+ ? sql `(count(${calls.id}) filter (where ${calls.by} = ${viewer}))::int`
932
+ : sql `0`,
933
+ rate,
934
+ waits,
935
+ amazing: tally("amazing"),
936
+ good: tally("good"),
937
+ okay: tally("okay"),
938
+ bad: tally("bad"),
939
+ })
940
+ .from(batches)
941
+ .innerJoin(subjects, eq(subjects.id, batches.subjectId))
942
+ .leftJoin(samples, eq(samples.batchId, batches.id))
943
+ .leftJoin(calls, eq(calls.sampleId, samples.id))
944
+ .where(where)
945
+ .groupBy(batches.id, subjects.id)
946
+ .orderBy(...order)
947
+ .limit(limit + 1)
948
+ .offset(after);
949
+ const page = got.slice(0, limit);
950
+ const [count] = await this.db
951
+ .select({ waiting: sql `(count(*) filter (where ${WAITS}))::int` })
952
+ .from(batches)
953
+ .innerJoin(subjects, eq(subjects.id, batches.subjectId))
954
+ .where(where);
955
+ // thumbs: each batch's posters in made order, with the calls a reader may see
956
+ const thumbs = page.length
957
+ ? await this.db
958
+ .select({ id: samples.id, batchId: samples.batchId, poster: samples.poster })
959
+ .from(samples)
960
+ .where(and(inArray(samples.batchId, page.map((r) => r.batch.id)), isNotNull(samples.poster)))
961
+ .orderBy(asc(samples.id))
962
+ : [];
963
+ const cs = await this.callsOf(thumbs.map((t) => t.id));
964
+ return {
965
+ rows: page.map((r) => {
966
+ const blind = blindFor(viewer, r.batch.state === "open");
967
+ const tallied = r.judges * r.samples;
968
+ return {
969
+ ref: refOf(r.subject.slug, r.batch.n),
970
+ id: r.batch.id,
971
+ n: r.batch.n,
972
+ title: r.batch.title,
973
+ state: r.batch.state,
974
+ note: r.batch.note,
975
+ why: blind ? null : r.batch.why,
976
+ createdAt: r.batch.createdAt,
977
+ subject: r.subject,
978
+ waits: r.waits,
979
+ samples: r.samples,
980
+ kept: r.kept,
981
+ // while open a person sees only their own count
982
+ marked: blind ? r.mine : r.amazing + r.good + r.okay + r.bad,
983
+ judges: blind ? null : r.judges,
984
+ goodRate: blind ? null : r.rate,
985
+ tally: blind
986
+ ? null
987
+ : {
988
+ amazing: r.amazing,
989
+ good: r.good,
990
+ okay: r.okay,
991
+ bad: r.bad,
992
+ unmarked: tallied ? tallied - (r.amazing + r.good + r.okay + r.bad) : r.samples,
993
+ },
994
+ thumbs: thumbs
995
+ .filter((t) => t.batchId === r.batch.id)
996
+ .map((t) => ({
997
+ id: t.id,
998
+ poster: t.poster,
999
+ verdict: blind ? null : pooled(cs.filter((c) => c.sampleId === t.id)).verdict,
1000
+ })),
1001
+ };
1002
+ }),
1003
+ next: got.length > limit ? after + limit : null,
1004
+ waiting: count?.waiting ?? 0,
1005
+ };
1006
+ }
1007
+ /** One batch whole, for `viewer` (see `batches`); `newer` = the subject's newer open batch. */
1008
+ async batch(ref, viewer = null) {
1009
+ const id = await this.batchOf(ref);
1010
+ const [o] = await this.db
1011
+ .select({ batch: batches, subject: subjects })
1012
+ .from(batches)
1013
+ .innerJoin(subjects, eq(subjects.id, batches.subjectId))
1014
+ .where(eq(batches.id, id));
1015
+ if (!o)
1016
+ throw new VireoError(`no batch ${ref}`, 404);
1017
+ const blind = blindFor(viewer, o.batch.state === "open");
1018
+ const rows = await this.db
1019
+ .select()
1020
+ .from(samples)
1021
+ .where(eq(samples.batchId, id))
1022
+ .orderBy(asc(samples.id));
1023
+ const ids = rows.map((r) => r.id);
1024
+ const seen = (x) => !blind || x.by === viewer;
1025
+ const ns = (await this.notesOf(ids)).filter(seen);
1026
+ const cs = (await this.callsOf(ids)).filter(seen);
1027
+ const es = blind ? [] : await this.lineageOf(ids);
1028
+ const [newer] = await this.db
1029
+ .select({ n: batches.n })
1030
+ .from(batches)
1031
+ .where(and(eq(batches.subjectId, o.batch.subjectId), eq(batches.state, "open"), sql `${batches.n} > ${o.batch.n}`))
1032
+ .orderBy(desc(batches.n))
1033
+ .limit(1);
1034
+ return {
1035
+ ref: refOf(o.subject.slug, o.batch.n),
1036
+ ...o.batch,
1037
+ why: blind ? null : o.batch.why,
1038
+ subject: o.subject,
1039
+ goodRate: blind ? null : goodRateOf(sheets(rows, cs)),
1040
+ judges: blind ? null : [...new Set(cs.map((c) => c.by))],
1041
+ newer: newer ? { n: newer.n, ref: refOf(o.subject.slug, newer.n) } : null,
1042
+ samples: rows.map((r) => ({
1043
+ ...r,
1044
+ name: blind ? null : r.name,
1045
+ change: blind ? null : r.change,
1046
+ ...pooled(cs.filter((c) => c.sampleId === r.id)),
1047
+ calls: cs.filter((c) => c.sampleId === r.id),
1048
+ notes: ns.filter((x) => x.sampleId === r.id),
1049
+ parents: es
1050
+ .filter((e) => e.childId === r.id)
1051
+ .map((e) => ({ id: e.parentId, took: e.took })),
1052
+ })),
1053
+ };
1054
+ }
782
1055
  async catalog(opts = {}) {
783
1056
  const subjectId = opts.subject == null ? null : (await this.subjectOf(opts.subject)).id;
784
1057
  return this.db