@crewhaus/tool-flow 0.7.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Route a record to an owner by a declared roster.
3
+ *
4
+ * This is {@link ./score}'s sibling. "Which rep gets this lead?" is the same
5
+ * shape of question as "what does this lead score?": a set of declared rules
6
+ * against one record, answered the same way every time and explainable
7
+ * afterwards to the rep who did or did not get it. Eligibility is expressed
8
+ * in the same `when` the `Assert` tool evaluates, through the same
9
+ * evaluator — there is no second vocabulary for territories.
10
+ *
11
+ * Two things this deliberately does not do, because the package does not:
12
+ *
13
+ * - **It does not persist the assignment.** The load figures come in as an
14
+ * argument, the way {@link ./stall} takes its history, and the advanced
15
+ * rotation cursor goes back out. Durable counters are
16
+ * `@crewhaus/tool-state`; a rotation that this tool stored itself would be
17
+ * a hidden write in a tool marked read-only.
18
+ * - **It does not fall back when it cannot tell.** A roster missing a fact
19
+ * the strategy needs is a configuration bug, and routing it to the
20
+ * catch-all owner would bury that bug in somebody's inbox for a quarter.
21
+ * "Nobody is eligible" and "I could not work out who is" are different
22
+ * answers, and only the first one uses `fallback`.
23
+ */
24
+ import { type Check } from "@crewhaus/tool-schema";
25
+ import type { MatchMode } from "./branch";
26
+ /** How the winner is picked from the owners that are eligible. */
27
+ export declare const ASSIGN_STRATEGIES: readonly ["first", "specific", "least_loaded", "round_robin"];
28
+ export type AssignStrategy = (typeof ASSIGN_STRATEGIES)[number];
29
+ export type Owner = {
30
+ readonly id: string;
31
+ /** Eligibility. Never empty — see {@link assignOwner}. */
32
+ readonly when: ReadonlyArray<Check>;
33
+ readonly match?: MatchMode;
34
+ /** Assignments this owner already holds, as the caller counts them. */
35
+ readonly load?: number;
36
+ /** The most this owner takes; at or above it they are skipped. */
37
+ readonly capacity?: number;
38
+ /**
39
+ * Availability, as some other chain wrote it. Absent means no
40
+ * out-of-office record exists, which is not the same as being out — the
41
+ * flag is only ever set to take somebody out of the rotation.
42
+ */
43
+ readonly available?: boolean;
44
+ };
45
+ export type Roster = {
46
+ /** Echoed into the answer, so an assignment is attributable to a revision. */
47
+ readonly version?: string;
48
+ readonly strategy: AssignStrategy;
49
+ readonly owners: ReadonlyArray<Owner>;
50
+ /**
51
+ * `round_robin` only: where the last rotation left off. Counted over the
52
+ * declared roster, not over whoever happens to be eligible today.
53
+ */
54
+ readonly cursor?: number;
55
+ /** Used when no owner is eligible. Without it, a miss is `assigned: false`. */
56
+ readonly fallback?: {
57
+ readonly id: string;
58
+ };
59
+ };
60
+ /** Why one owner was in or out of the running, for the rep who asks. */
61
+ export type OwnerReport = {
62
+ readonly id: string;
63
+ /**
64
+ * True in the running, false ruled out, and **null when it could not be
65
+ * worked out** — a rep whose capacity has no load to measure against it has
66
+ * not been shown to have room, and saying `true` there states a fact the
67
+ * roster did not supply. This report is what the rep is shown when they
68
+ * ask why a lead went elsewhere, so it has to be able to say "I don't
69
+ * know" as well as "no".
70
+ */
71
+ readonly eligible: boolean | null;
72
+ /** Why they were ruled out, or which fact is missing; empty when eligible. */
73
+ readonly reason: string;
74
+ /**
75
+ * How many of this owner's conditions held. Not the `specific` ranking —
76
+ * see {@link specificityOf}, which counts the conditions the territory
77
+ * *requires* rather than the ones this record happened to satisfy.
78
+ */
79
+ readonly matched: number;
80
+ };
81
+ export type AssignResult = {
82
+ /** False only when the roster could not answer; see `conflict`. */
83
+ readonly ok: boolean;
84
+ readonly assigned: boolean;
85
+ readonly owner: string | null;
86
+ /** True when `owner` came from `fallback` rather than from the roster. */
87
+ readonly fallback: boolean;
88
+ readonly strategy: AssignStrategy;
89
+ readonly version: string | null;
90
+ /**
91
+ * Owners shown to be in the running. An owner the roster could not settle
92
+ * — see `conflict` — is not one of them, and appears in `considered` with
93
+ * `eligible: null`.
94
+ */
95
+ readonly eligible: ReadonlyArray<string>;
96
+ /**
97
+ * Where the next `round_robin` call should resume. Null under any other
98
+ * strategy, and null when nothing was picked out of the roster — a
99
+ * fallback, a miss and a refusal all leave the rotation where it was, so a
100
+ * caller that stores this should keep its old cursor rather than clear it.
101
+ */
102
+ readonly cursor: number | null;
103
+ /** Set when the roster could not answer: a tie, or a fact the strategy needs. */
104
+ readonly conflict: string | null;
105
+ readonly considered: ReadonlyArray<OwnerReport>;
106
+ };
107
+ /**
108
+ * Pick the owner for `value` from `roster`.
109
+ *
110
+ * Three shapes are rejected rather than accommodated, for the reasons the
111
+ * rest of this package rejects them:
112
+ *
113
+ * - **An owner with no conditions**, which is eligible for everything and
114
+ * would win every `first` assignment while looking like a territory. A
115
+ * catch-all owner is spelled `fallback`.
116
+ * - **Two owners with one id**, because the id is what the caller writes on
117
+ * the record and what the rotation cursor lands on.
118
+ * - **A non-finite load or capacity**, which makes every comparison against
119
+ * it false and so reads as the lowest load in the roster. A cursor that is
120
+ * not a whole position goes the same way.
121
+ */
122
+ export declare function assignOwner(value: unknown, roster: Roster): AssignResult;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Route a record to an owner by a declared roster.
3
+ *
4
+ * This is {@link ./score}'s sibling. "Which rep gets this lead?" is the same
5
+ * shape of question as "what does this lead score?": a set of declared rules
6
+ * against one record, answered the same way every time and explainable
7
+ * afterwards to the rep who did or did not get it. Eligibility is expressed
8
+ * in the same `when` the `Assert` tool evaluates, through the same
9
+ * evaluator — there is no second vocabulary for territories.
10
+ *
11
+ * Two things this deliberately does not do, because the package does not:
12
+ *
13
+ * - **It does not persist the assignment.** The load figures come in as an
14
+ * argument, the way {@link ./stall} takes its history, and the advanced
15
+ * rotation cursor goes back out. Durable counters are
16
+ * `@crewhaus/tool-state`; a rotation that this tool stored itself would be
17
+ * a hidden write in a tool marked read-only.
18
+ * - **It does not fall back when it cannot tell.** A roster missing a fact
19
+ * the strategy needs is a configuration bug, and routing it to the
20
+ * catch-all owner would bury that bug in somebody's inbox for a quarter.
21
+ * "Nobody is eligible" and "I could not work out who is" are different
22
+ * answers, and only the first one uses `fallback`.
23
+ */
24
+ import { runChecks } from "@crewhaus/tool-schema";
25
+ /** How the winner is picked from the owners that are eligible. */
26
+ export const ASSIGN_STRATEGIES = ["first", "specific", "least_loaded", "round_robin"];
27
+ /**
28
+ * How narrowly this owner's territory is drawn, which is what `specific`
29
+ * ranks on.
30
+ *
31
+ * Under `all` that is every condition, and for an eligible owner it is also
32
+ * how many held. Under `any` exactly one is required however many happen to
33
+ * hold, so counting the holders would rank the *record* rather than the
34
+ * roster: `[country=DE OR language=de OR tier=smb]` would beat
35
+ * `[country=DE AND state=BY]` for one Bavarian lead and lose to it for the
36
+ * next, over fields neither territory asked for. An `any` territory is as
37
+ * narrow as the one condition it insists on, so it ranks 1 — and a tie
38
+ * against a one-condition `all` owner is reported as the overlap it is.
39
+ */
40
+ const specificityOf = (owner) => (owner.match ?? "all") === "all" ? owner.when.length : 1;
41
+ /**
42
+ * Pick the owner for `value` from `roster`.
43
+ *
44
+ * Three shapes are rejected rather than accommodated, for the reasons the
45
+ * rest of this package rejects them:
46
+ *
47
+ * - **An owner with no conditions**, which is eligible for everything and
48
+ * would win every `first` assignment while looking like a territory. A
49
+ * catch-all owner is spelled `fallback`.
50
+ * - **Two owners with one id**, because the id is what the caller writes on
51
+ * the record and what the rotation cursor lands on.
52
+ * - **A non-finite load or capacity**, which makes every comparison against
53
+ * it false and so reads as the lowest load in the roster. A cursor that is
54
+ * not a whole position goes the same way.
55
+ */
56
+ export function assignOwner(value, roster) {
57
+ if (roster.owners.length === 0)
58
+ throw new Error("the roster has no owners");
59
+ const seen = new Set();
60
+ for (const owner of roster.owners) {
61
+ if (owner.when.length === 0) {
62
+ throw new Error(`owner "${owner.id}" has no conditions — an owner with no conditions is eligible for everything; use "fallback" for a catch-all`);
63
+ }
64
+ if (seen.has(owner.id))
65
+ throw new Error(`two owners share the id "${owner.id}"`);
66
+ seen.add(owner.id);
67
+ for (const [field, n] of [
68
+ ["load", owner.load],
69
+ ["capacity", owner.capacity],
70
+ ]) {
71
+ if (n !== undefined && !Number.isFinite(n)) {
72
+ throw new Error(`owner "${owner.id}" has a non-finite ${field}`);
73
+ }
74
+ }
75
+ }
76
+ if (roster.cursor !== undefined && !Number.isInteger(roster.cursor)) {
77
+ throw new Error(`cursor (${roster.cursor}) must be a whole number of positions`);
78
+ }
79
+ if (roster.cursor !== undefined && roster.cursor < 0) {
80
+ throw new Error(`cursor (${roster.cursor}) must not be negative`);
81
+ }
82
+ const considered = [];
83
+ const candidates = [];
84
+ /** Facts the strategy needs and the roster did not supply; see below. */
85
+ const undetermined = [];
86
+ for (const [index, owner] of roster.owners.entries()) {
87
+ const report = runChecks(value, owner.when);
88
+ const passes = (owner.match ?? "all") === "all" ? report.ok : report.passed > 0;
89
+ const out = (reason) => {
90
+ considered.push({ id: owner.id, eligible: false, reason, matched: report.passed });
91
+ };
92
+ if (!passes) {
93
+ out(report.failures[0]?.reason ?? "no condition held");
94
+ continue;
95
+ }
96
+ if (owner.available === false) {
97
+ out("unavailable");
98
+ continue;
99
+ }
100
+ // A fact the strategy needs and the roster did not supply. A capacity
101
+ // with nothing to measure against it has not been shown to have room —
102
+ // reading the absent load as zero is how a rep who is already full gets
103
+ // the next twenty leads — and an absent load under least_loaded is not a
104
+ // load of zero, which would win every rotation.
105
+ //
106
+ // Only owners that are otherwise eligible get here: a rep already ruled
107
+ // out by territory does not need a load, and refusing over their missing
108
+ // one would be a false alarm.
109
+ //
110
+ // Such an owner is recorded as neither in nor out. `eligible: true` here
111
+ // would be the same mistake one level down: the answer as a whole
112
+ // refuses, while the per-owner report a rep is shown would state, with
113
+ // no reason, that the one owner the roster could not rank was in the
114
+ // running.
115
+ const missing = owner.capacity !== undefined && owner.load === undefined
116
+ ? `owner "${owner.id}" declares a capacity but no load`
117
+ : roster.strategy === "least_loaded" && owner.load === undefined
118
+ ? `owner "${owner.id}" has no load, which least_loaded ranks on`
119
+ : null;
120
+ if (missing !== null) {
121
+ undetermined.push(missing);
122
+ considered.push({ id: owner.id, eligible: null, reason: missing, matched: report.passed });
123
+ continue;
124
+ }
125
+ if (owner.capacity !== undefined && owner.load >= owner.capacity) {
126
+ out(`at capacity (${owner.load} of ${owner.capacity})`);
127
+ continue;
128
+ }
129
+ considered.push({ id: owner.id, eligible: true, reason: "", matched: report.passed });
130
+ candidates.push({ owner, index, specificity: specificityOf(owner) });
131
+ }
132
+ const base = {
133
+ strategy: roster.strategy,
134
+ version: roster.version ?? null,
135
+ eligible: candidates.map((c) => c.owner.id),
136
+ considered,
137
+ };
138
+ const refuse = (conflict) => ({
139
+ ...base,
140
+ ok: false,
141
+ assigned: false,
142
+ owner: null,
143
+ fallback: false,
144
+ cursor: null,
145
+ conflict,
146
+ });
147
+ // A missing fact is reported before anything is picked, even when only one
148
+ // candidate is left: a roster where a rep has no load is broken now, and
149
+ // finding that out only on the day a second rep becomes eligible is how
150
+ // this kind of bug survives a quarter.
151
+ if (undetermined.length > 0)
152
+ return refuse(undetermined.join("; "));
153
+ if (candidates.length === 0) {
154
+ if (roster.fallback) {
155
+ return {
156
+ ...base,
157
+ ok: true,
158
+ assigned: true,
159
+ owner: roster.fallback.id,
160
+ fallback: true,
161
+ cursor: null,
162
+ conflict: null,
163
+ };
164
+ }
165
+ return {
166
+ ...base,
167
+ ok: true,
168
+ assigned: false,
169
+ owner: null,
170
+ fallback: false,
171
+ cursor: null,
172
+ conflict: null,
173
+ };
174
+ }
175
+ const won = (id, cursor) => ({
176
+ ...base,
177
+ ok: true,
178
+ assigned: true,
179
+ owner: id,
180
+ fallback: false,
181
+ cursor,
182
+ conflict: null,
183
+ });
184
+ const first = candidates[0];
185
+ switch (roster.strategy) {
186
+ case "first":
187
+ return won(first.owner.id, null);
188
+ case "specific": {
189
+ // The narrowest territory wins, so a rep insisting on country+state
190
+ // beats one insisting on country — and the answer depends on neither
191
+ // the order the roster was written in nor on fields the territories
192
+ // did not ask for (see {@link specificityOf}). A tie means two
193
+ // territories are equally narrow about the same lead, which is the
194
+ // overlap this strategy exists to surface, not something to break by
195
+ // position.
196
+ let best = first;
197
+ let tied = [first];
198
+ for (const candidate of candidates.slice(1)) {
199
+ if (candidate.specificity > best.specificity) {
200
+ best = candidate;
201
+ tied = [candidate];
202
+ }
203
+ else if (candidate.specificity === best.specificity) {
204
+ tied.push(candidate);
205
+ }
206
+ }
207
+ return tied.length === 1
208
+ ? won(best.owner.id, null)
209
+ : refuse(`owners ${tied.map((c) => `"${c.owner.id}"`).join(", ")} are equally specific (${best.specificity} conditions required each)`);
210
+ }
211
+ case "least_loaded": {
212
+ // Every candidate has a load by here, checked above. Ties go to the
213
+ // earlier declaration rather than being refused, because a tie is the
214
+ // normal state of this strategy — on the first morning every rep is on
215
+ // zero — and the caller incrementing the winner's load is what makes
216
+ // the next call pick somebody else.
217
+ let best = first;
218
+ for (const candidate of candidates.slice(1)) {
219
+ if (candidate.owner.load < best.owner.load)
220
+ best = candidate;
221
+ }
222
+ return won(best.owner.id, null);
223
+ }
224
+ case "round_robin": {
225
+ // The cursor counts positions in the declared roster, not in today's
226
+ // eligible subset. Indexing the subset would mean one rep going out of
227
+ // office silently re-deals everybody else's turn, and two leads with
228
+ // different eligible sets would fight over the same counter.
229
+ const size = roster.owners.length;
230
+ const start = (roster.cursor ?? 0) % size;
231
+ const winner = candidates.find((c) => c.index >= start) ?? candidates[0];
232
+ return won(winner.owner.id, (winner.index + 1) % size);
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Ordered branching over a value.
3
+ *
4
+ * The question "does this value satisfy this condition?" is answered by
5
+ * `runChecks` from `@crewhaus/tool-schema`, which is the same evaluator the
6
+ * `Assert` tool uses. There is deliberately no second condition vocabulary
7
+ * here: an operator who learns `startsWith` from Assert gets the same
8
+ * operator, with the same semantics, in a branch.
9
+ */
10
+ import { type Check } from "@crewhaus/tool-schema";
11
+ /** Whether every check must hold, or any one of them. */
12
+ export type MatchMode = "all" | "any";
13
+ /** One arm of the branch. `when` is never empty — see {@link evaluateBranches}. */
14
+ export type BranchRule = {
15
+ readonly name: string;
16
+ readonly when: ReadonlyArray<Check>;
17
+ readonly match?: MatchMode;
18
+ /** Handed back verbatim when this arm wins. Any JSON value. */
19
+ readonly result?: unknown;
20
+ };
21
+ /** Why one arm did or did not match, for the caller that has to debug it. */
22
+ export type ArmReport = {
23
+ readonly name: string;
24
+ readonly index: number;
25
+ readonly ok: boolean;
26
+ readonly passed: number;
27
+ readonly failed: number;
28
+ /** The first failing check's reason; empty when the arm matched. */
29
+ readonly reason: string;
30
+ };
31
+ export type BranchOutcome = {
32
+ readonly matched: boolean;
33
+ /** The winning arm's name, or the fallback's, or null when nothing matched. */
34
+ readonly name: string | null;
35
+ /** Index into the declared arms; null for the fallback and for no match. */
36
+ readonly index: number | null;
37
+ readonly result: unknown;
38
+ /** True when `name` came from `otherwise` rather than from an arm. */
39
+ readonly fallback: boolean;
40
+ /** Every arm evaluated, in order, up to and including the winner. */
41
+ readonly evaluated: ReadonlyArray<ArmReport>;
42
+ };
43
+ export type BranchOptions = {
44
+ /** Used when no arm matches. Without it, a miss returns `matched: false`. */
45
+ readonly otherwise?: {
46
+ readonly name: string;
47
+ readonly result?: unknown;
48
+ };
49
+ };
50
+ /**
51
+ * Evaluate `rules` against `value` in order and return the first match.
52
+ *
53
+ * Arms after the winner are not evaluated, so a later arm's malformed check
54
+ * cannot affect an earlier arm's decision — and the report says exactly how
55
+ * far evaluation got.
56
+ *
57
+ * Two shapes are rejected rather than accommodated, because both read as
58
+ * working and do not:
59
+ *
60
+ * - **An arm with no checks.** Under `all` an empty check list is vacuously
61
+ * true, so such an arm swallows everything after it while looking like a
62
+ * rule. A catch-all is spelled `otherwise`, which cannot be mistaken for a
63
+ * condition.
64
+ * - **Two arms with one name.** The name is what the caller routes on. If
65
+ * two arms share one, the route is ambiguous at the point it is used,
66
+ * which is far from the table that caused it.
67
+ */
68
+ export declare function evaluateBranches(value: unknown, rules: ReadonlyArray<BranchRule>, options?: BranchOptions): BranchOutcome;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Ordered branching over a value.
3
+ *
4
+ * The question "does this value satisfy this condition?" is answered by
5
+ * `runChecks` from `@crewhaus/tool-schema`, which is the same evaluator the
6
+ * `Assert` tool uses. There is deliberately no second condition vocabulary
7
+ * here: an operator who learns `startsWith` from Assert gets the same
8
+ * operator, with the same semantics, in a branch.
9
+ */
10
+ import { runChecks } from "@crewhaus/tool-schema";
11
+ /**
12
+ * Evaluate `rules` against `value` in order and return the first match.
13
+ *
14
+ * Arms after the winner are not evaluated, so a later arm's malformed check
15
+ * cannot affect an earlier arm's decision — and the report says exactly how
16
+ * far evaluation got.
17
+ *
18
+ * Two shapes are rejected rather than accommodated, because both read as
19
+ * working and do not:
20
+ *
21
+ * - **An arm with no checks.** Under `all` an empty check list is vacuously
22
+ * true, so such an arm swallows everything after it while looking like a
23
+ * rule. A catch-all is spelled `otherwise`, which cannot be mistaken for a
24
+ * condition.
25
+ * - **Two arms with one name.** The name is what the caller routes on. If
26
+ * two arms share one, the route is ambiguous at the point it is used,
27
+ * which is far from the table that caused it.
28
+ */
29
+ export function evaluateBranches(value, rules, options = {}) {
30
+ if (rules.length === 0)
31
+ throw new Error("no branch arms were given");
32
+ const seen = new Set();
33
+ for (const rule of rules) {
34
+ if (rule.when.length === 0) {
35
+ throw new Error(`branch "${rule.name}" has no checks — an arm with no checks always matches; use "otherwise" for a catch-all`);
36
+ }
37
+ if (seen.has(rule.name))
38
+ throw new Error(`two branch arms are both named "${rule.name}"`);
39
+ seen.add(rule.name);
40
+ }
41
+ const evaluated = [];
42
+ for (const [index, rule] of rules.entries()) {
43
+ const mode = rule.match ?? "all";
44
+ const report = runChecks(value, rule.when);
45
+ const ok = mode === "all" ? report.ok : report.passed > 0;
46
+ evaluated.push({
47
+ name: rule.name,
48
+ index,
49
+ ok,
50
+ passed: report.passed,
51
+ failed: report.failed,
52
+ reason: ok ? "" : (report.failures[0]?.reason ?? ""),
53
+ });
54
+ if (ok) {
55
+ return {
56
+ matched: true,
57
+ name: rule.name,
58
+ index,
59
+ result: rule.result,
60
+ fallback: false,
61
+ evaluated,
62
+ };
63
+ }
64
+ }
65
+ if (options.otherwise) {
66
+ return {
67
+ matched: true,
68
+ name: options.otherwise.name,
69
+ index: null,
70
+ result: options.otherwise.result,
71
+ fallback: true,
72
+ evaluated,
73
+ };
74
+ }
75
+ return { matched: false, name: null, index: null, result: undefined, fallback: false, evaluated };
76
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A canonical string for a JSON value.
3
+ *
4
+ * Object keys are emitted in sorted order, so two values that differ only in
5
+ * the order their keys were written compare equal. `JSON.stringify` does not
6
+ * do this — it preserves insertion order — and using it for equality means
7
+ * `{label,confidence}` and `{confidence,label}` are different answers. Two
8
+ * models that agreed would be reported as dissenting, and a decision table
9
+ * would look like it changed when it had only been reformatted.
10
+ *
11
+ * Arrays keep their order, because in an array order is content.
12
+ */
13
+ export declare function canonicalize(value: unknown): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A canonical string for a JSON value.
3
+ *
4
+ * Object keys are emitted in sorted order, so two values that differ only in
5
+ * the order their keys were written compare equal. `JSON.stringify` does not
6
+ * do this — it preserves insertion order — and using it for equality means
7
+ * `{label,confidence}` and `{confidence,label}` are different answers. Two
8
+ * models that agreed would be reported as dissenting, and a decision table
9
+ * would look like it changed when it had only been reformatted.
10
+ *
11
+ * Arrays keep their order, because in an array order is content.
12
+ */
13
+ export function canonicalize(value) {
14
+ if (value === null || typeof value !== "object")
15
+ return JSON.stringify(value) ?? "null";
16
+ if (Array.isArray(value))
17
+ return `[${value.map(canonicalize).join(",")}]`;
18
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
19
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
20
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Settle N answers into one, and say how much they actually agreed.
3
+ *
4
+ * The agreement number is the point. A plurality winner with 40% support is
5
+ * not the same as a unanimous one, and a caller that only reads the winner
6
+ * cannot tell them apart — which is exactly when a judge or a human should
7
+ * be brought in.
8
+ */
9
+ export declare const VOTE_MODES: readonly ["exact", "numeric", "set"];
10
+ export type VoteMode = (typeof VOTE_MODES)[number];
11
+ export type Vote = {
12
+ readonly value: unknown;
13
+ /** Defaults to 1. Negative weights are rejected. */
14
+ readonly weight?: number;
15
+ /** For the dissent report. */
16
+ readonly voter?: string;
17
+ };
18
+ export type NormalizeOptions = {
19
+ readonly trim?: boolean;
20
+ readonly lowercase?: boolean;
21
+ /** Collapse runs of whitespace to one space. */
22
+ readonly collapseWhitespace?: boolean;
23
+ /** Strip everything that is not a letter, digit or space. */
24
+ readonly stripPunctuation?: boolean;
25
+ };
26
+ export type ConsensusOptions = {
27
+ readonly mode?: VoteMode;
28
+ readonly normalize?: NormalizeOptions;
29
+ /** `numeric`: values within this absolute distance are the same answer. */
30
+ readonly tolerance?: number;
31
+ /** `set`: Jaccard similarity at or above this counts as the same answer. */
32
+ readonly overlap?: number;
33
+ /** Support fraction the winner must reach to be `decided`. Default 0.5. */
34
+ readonly threshold?: number;
35
+ };
36
+ export type ConsensusResult = {
37
+ readonly decided: boolean;
38
+ readonly winner: unknown;
39
+ /** Weight behind the winner, and the total weight cast. */
40
+ readonly support: number;
41
+ readonly total: number;
42
+ /** support/total, 0..1. */
43
+ readonly agreement: number;
44
+ readonly unanimous: boolean;
45
+ readonly tie: boolean;
46
+ /** Every distinct answer, heaviest first, ties broken by first appearance. */
47
+ readonly groups: ReadonlyArray<{
48
+ readonly value: unknown;
49
+ readonly weight: number;
50
+ readonly voters: ReadonlyArray<string>;
51
+ }>;
52
+ /** Voters who did not back the winner. */
53
+ readonly dissenters: ReadonlyArray<string>;
54
+ };
55
+ export declare function normalizeValue(value: unknown, options?: NormalizeOptions): unknown;
56
+ /** Jaccard similarity of two arrays treated as sets of stringified members. */
57
+ export declare function jaccard(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): number;
58
+ export declare function tallyVotes(votes: ReadonlyArray<Vote>, options?: ConsensusOptions): ConsensusResult;