@crewhaus/tool-money 0.7.0 → 0.8.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.
@@ -9,6 +9,7 @@
9
9
  * Largest-remainder allocation fixes that by construction: the shares always
10
10
  * sum to the total, exactly, whatever the weights.
11
11
  */
12
+ import { big, toNumber } from "./exact";
12
13
  /**
13
14
  * Split `totalMinor` across `weights` so the parts sum to it exactly.
14
15
  *
@@ -20,33 +21,43 @@
20
21
  export function allocateProportional(totalMinor, weights) {
21
22
  if (!Number.isInteger(totalMinor))
22
23
  throw new Error("totalMinor must be an integer");
24
+ if (weights.some((w) => w < 0))
25
+ throw new Error("weights must not be negative");
26
+ return allocateExact(big(totalMinor, "totalMinor"), weights.map((w, i) => big(w, `weight ${i}`))).map((part, i) => toNumber(part, `share ${i}`));
27
+ }
28
+ /**
29
+ * {@link allocateProportional} in exact integers. Each share's exact value is
30
+ * a quotient and a remainder over the weights' sum, so the largest remainders
31
+ * are compared exactly: a double's `magnitude × weight` passes 2^53 on an
32
+ * ordinary invoice's cents times its lines' values.
33
+ */
34
+ export function allocateExact(total, weights) {
23
35
  if (weights.length === 0)
24
36
  return [];
25
- if (weights.some((w) => w < 0))
37
+ if (weights.some((w) => w < 0n))
26
38
  throw new Error("weights must not be negative");
27
- const sum = weights.reduce((a, b) => a + b, 0);
28
- const negative = totalMinor < 0;
29
- const magnitude = Math.abs(totalMinor);
30
- if (sum === 0) {
39
+ const sum = weights.reduce((a, b) => a + b, 0n);
40
+ const negative = total < 0n;
41
+ const magnitude = negative ? -total : total;
42
+ const count = BigInt(weights.length);
43
+ if (sum === 0n) {
31
44
  // Nothing to weigh by. Spread evenly rather than returning zeros, which
32
45
  // would drop the amount entirely.
33
- const base = Math.floor(magnitude / weights.length);
34
- const parts = weights.map(() => base);
35
- let leftover = magnitude - base * weights.length;
36
- for (let i = 0; leftover > 0; i = (i + 1) % weights.length, leftover--) {
37
- parts[i] = parts[i] + 1;
38
- }
46
+ const base = magnitude / count;
47
+ const leftover = magnitude - base * count;
48
+ const parts = weights.map((_, i) => (BigInt(i) < leftover ? base + 1n : base));
39
49
  return negative ? parts.map((p) => -p) : parts;
40
50
  }
41
- const exact = weights.map((w) => (magnitude * w) / sum);
42
- const parts = exact.map((value) => Math.floor(value));
43
- let leftover = magnitude - parts.reduce((a, b) => a + b, 0);
44
- const order = exact
45
- .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
46
- .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
47
- for (let i = 0; leftover > 0; i++, leftover--) {
48
- const target = order[i % order.length]?.index;
49
- parts[target] = parts[target] + 1;
51
+ const parts = weights.map((w) => (magnitude * w) / sum);
52
+ const leftover = magnitude - parts.reduce((a, b) => a + b, 0n);
53
+ // The leftover is smaller than the number of shares, so it goes one unit
54
+ // each to the largest remainders, ties broken by position.
55
+ const order = weights
56
+ .map((w, index) => ({ index, remainder: (magnitude * w) % sum }))
57
+ .sort((a, b) => a.remainder === b.remainder ? a.index - b.index : a.remainder > b.remainder ? -1 : 1);
58
+ for (let i = 0n; i < leftover; i++) {
59
+ const target = order[Number(i) % order.length]?.index;
60
+ parts[target] = parts[target] + 1n;
50
61
  }
51
62
  return negative ? parts.map((p) => -p) : parts;
52
63
  }
@@ -72,36 +83,55 @@ export function computeRefund(lines, returned, options = {}) {
72
83
  throw new Error(`returned line "${item.lineId}" returns ${item.quantity} of ${line.quantity} ordered`);
73
84
  }
74
85
  }
86
+ // Exact throughout (see ./exact): a line's value is quantity × price, and
87
+ // the allocations multiply amounts by values again.
88
+ const unitPrice = (l) => big(l.unitPriceMinor, `line "${l.id}" unitPriceMinor`);
89
+ const lineGross = (l) => big(l.quantity, `line "${l.id}" quantity`) * unitPrice(l);
75
90
  // The order discount is spread over ALL lines by their net value, then the
76
91
  // returned fraction of each line's share comes back. Spreading it over the
77
92
  // returned lines only would refund the whole discount on a partial return.
78
- const lineNet = lines.map((l) => l.quantity * l.unitPriceMinor - (l.discountMinor ?? 0));
79
- const orderDiscountShares = allocateProportional(options.orderDiscountMinor ?? 0, lineNet);
93
+ const lineNet = lines.map((l) => lineGross(l) - big(l.discountMinor ?? 0, `line "${l.id}" discountMinor`));
94
+ if (lineNet.some((net) => net < 0n))
95
+ throw new Error("weights must not be negative");
96
+ const orderDiscountShares = allocateExact(big(options.orderDiscountMinor ?? 0, "orderDiscountMinor"), lineNet);
80
97
  const shareByLine = new Map(lines.map((l, i) => [l.id, orderDiscountShares[i]]));
81
98
  const results = [];
99
+ let subtotal = 0n;
100
+ let taxTotal = 0n;
82
101
  for (const item of returned) {
83
102
  const line = index.get(item.lineId);
84
- const fraction = [item.quantity, line.quantity];
85
- const gross = line.unitPriceMinor * item.quantity;
86
- const lineDiscount = splitByQuantity(line.discountMinor ?? 0, fraction);
87
- const orderDiscount = splitByQuantity(shareByLine.get(line.id) ?? 0, fraction);
88
- const tax = splitByQuantity(line.taxMinor ?? 0, fraction);
103
+ const fraction = [BigInt(item.quantity), BigInt(line.quantity)];
104
+ const gross = unitPrice(line) * BigInt(item.quantity);
105
+ const lineDiscount = splitByQuantity(big(line.discountMinor ?? 0, "discountMinor"), fraction);
106
+ const orderDiscount = splitByQuantity(shareByLine.get(line.id) ?? 0n, fraction);
107
+ const tax = splitByQuantity(big(line.taxMinor ?? 0, "taxMinor"), fraction);
108
+ subtotal += gross - lineDiscount - orderDiscount;
109
+ taxTotal += tax;
110
+ const what = `returned line "${line.id}"`;
89
111
  results.push({
90
112
  lineId: line.id,
91
113
  quantity: item.quantity,
92
- grossMinor: gross,
93
- lineDiscountMinor: lineDiscount,
94
- orderDiscountMinor: orderDiscount,
95
- taxMinor: tax,
96
- refundMinor: gross - lineDiscount - orderDiscount + tax,
114
+ grossMinor: toNumber(gross, `${what} gross`),
115
+ lineDiscountMinor: toNumber(lineDiscount, `${what} line discount`),
116
+ orderDiscountMinor: toNumber(orderDiscount, `${what} order discount`),
117
+ taxMinor: toNumber(tax, `${what} tax`),
118
+ refundMinor: toNumber(gross - lineDiscount - orderDiscount + tax, `${what} refund`),
97
119
  });
98
120
  }
99
- const fullReturn = lines.every((l) => (returned.find((r) => r.lineId === l.id)?.quantity ?? 0) === l.quantity);
121
+ // The quantity returned of each line, by id: the first entry naming it, as
122
+ // a search of the returned lines finds. Searching them once per order line
123
+ // cost the lines times the returned lines.
124
+ const returnedOf = new Map();
125
+ for (const item of returned) {
126
+ if (!returnedOf.has(item.lineId))
127
+ returnedOf.set(item.lineId, item.quantity);
128
+ }
129
+ const fullReturn = lines.every((l) => (returnedOf.get(l.id) ?? 0) === l.quantity);
100
130
  const policy = options.shippingPolicy ?? "none";
101
- const shippingTotal = options.shippingMinor ?? 0;
102
- let shippingMinor = 0;
131
+ const shippingTotal = big(options.shippingMinor ?? 0, "shippingMinor");
132
+ let shipping = 0n;
103
133
  if (policy === "full" || (policy === "proportional" && fullReturn)) {
104
- shippingMinor = shippingTotal;
134
+ shipping = shippingTotal;
105
135
  }
106
136
  else if (policy === "proportional") {
107
137
  // Spread the shipping over EVERY line by its value, then take only the
@@ -109,24 +139,25 @@ export function computeRefund(lines, returned, options = {}) {
109
139
  // and summing the parts gives back the whole amount every time — an
110
140
  // allocation's parts always sum to what was allocated — so a one-item
111
141
  // return refunded the entire shipping charge.
112
- const shares = allocateProportional(shippingTotal, lines.map((l) => l.quantity * l.unitPriceMinor));
113
- shippingMinor = lines.reduce((total, line, i) => {
114
- const returnedQuantity = returned.find((r) => r.lineId === line.id)?.quantity ?? 0;
142
+ const shares = allocateExact(shippingTotal, lines.map(lineGross));
143
+ lines.forEach((line, i) => {
144
+ const returnedQuantity = returnedOf.get(line.id) ?? 0;
115
145
  if (returnedQuantity === 0)
116
- return total;
117
- return total + splitByQuantity(shares[i], [returnedQuantity, line.quantity]);
118
- }, 0);
146
+ return;
147
+ shipping += splitByQuantity(shares[i], [
148
+ BigInt(returnedQuantity),
149
+ BigInt(line.quantity),
150
+ ]);
151
+ });
119
152
  }
120
- const subtotalMinor = results.reduce((s, r) => s + r.grossMinor - r.lineDiscountMinor - r.orderDiscountMinor, 0);
121
- const taxMinor = results.reduce((s, r) => s + r.taxMinor, 0);
122
- const restockingFeeMinor = options.restockingFeeMinor ?? 0;
153
+ const restocking = big(options.restockingFeeMinor ?? 0, "restockingFeeMinor");
123
154
  return {
124
155
  lines: results,
125
- shippingMinor,
126
- restockingFeeMinor,
127
- subtotalMinor,
128
- taxMinor,
129
- totalMinor: subtotalMinor + taxMinor + shippingMinor - restockingFeeMinor,
156
+ shippingMinor: toNumber(shipping, "the refunded shipping"),
157
+ restockingFeeMinor: toNumber(restocking, "the restocking fee"),
158
+ subtotalMinor: toNumber(subtotal, "the refund subtotal"),
159
+ taxMinor: toNumber(taxTotal, "the refunded tax"),
160
+ totalMinor: toNumber(subtotal + taxTotal + shipping - restocking, "the refund total"),
130
161
  fullReturn,
131
162
  };
132
163
  }
@@ -137,9 +168,18 @@ export function computeRefund(lines, returned, options = {}) {
137
168
  * than multiplying by a fraction and rounding — is what makes the parts of a
138
169
  * fully returned line add back up to the whole.
139
170
  */
140
- function splitByQuantity(amountMinor, [returned, total]) {
141
- if (total <= 0 || amountMinor === 0)
142
- return 0;
143
- const perUnit = allocateProportional(amountMinor, new Array(total).fill(1));
144
- return perUnit.slice(0, returned).reduce((a, b) => a + b, 0);
171
+ function splitByQuantity(amount, [returned, total]) {
172
+ if (total <= 0n || amount === 0n)
173
+ return 0n;
174
+ // The per-unit allocation of `amount` over `total` equal units, summed over
175
+ // the first `returned` of them — in closed form. Every unit gets the same
176
+ // floor share and the leftover goes one unit each to the first ones (equal
177
+ // remainders break ties by position). 0.7.0 built that array of `total`
178
+ // units, so a line with a quantity in the millions allocated millions.
179
+ const negative = amount < 0n;
180
+ const magnitude = negative ? -amount : amount;
181
+ const base = magnitude / total;
182
+ const leftover = magnitude - base * total;
183
+ const part = returned * base + (returned < leftover ? returned : leftover);
184
+ return negative ? -part : part;
145
185
  }
@@ -10,7 +10,7 @@
10
10
  * Conditions are `@crewhaus/tool-schema`'s check grammar, the same one
11
11
  * `Assert` and `Branch` use.
12
12
  */
13
- import { type Check } from "@crewhaus/tool-schema";
13
+ import { type Check, RegexAnswers, type RegexRunContext } from "@crewhaus/tool-schema";
14
14
  export type CodingRule = {
15
15
  readonly id: string;
16
16
  readonly when: ReadonlyArray<Check>;
@@ -31,6 +31,13 @@ export type CodedLine = {
31
31
  readonly ambiguous: boolean;
32
32
  readonly needsReview: boolean;
33
33
  readonly reason: string;
34
+ /**
35
+ * Rules whose conditions could not be evaluated on this line (a pattern
36
+ * that could not be run to an answer, or one refused as invalid), when
37
+ * any could have decided it. The line then goes to review uncoded: a
38
+ * rule that might have matched is not a rule that did not.
39
+ */
40
+ readonly undetermined?: ReadonlyArray<string>;
34
41
  };
35
42
  export type CodingResult = {
36
43
  readonly lines: ReadonlyArray<CodedLine>;
@@ -42,9 +49,44 @@ export type CodingResult = {
42
49
  readonly lines: number;
43
50
  }>;
44
51
  };
45
- export declare function codeLines(lines: ReadonlyArray<{
46
- readonly id: string;
47
- } & Record<string, unknown>>, rules: ReadonlyArray<CodingRule>, options?: {
52
+ type CodeOptions = {
48
53
  readonly version?: string;
49
54
  readonly defaultAccount?: string;
55
+ };
56
+ export declare function codeLines(lines: ReadonlyArray<{
57
+ readonly id: string;
58
+ } & Record<string, unknown>>, rules: ReadonlyArray<CodingRule>, options?: CodeOptions & {
59
+ /**
60
+ * The rules' `matches` patterns, answered in the regex worker (see
61
+ * tool-schema's `askCheckPatterns`). Without it they run bounded on this
62
+ * thread. {@link codeLinesAnsweringPatterns} asks and answers them.
63
+ */
64
+ readonly regex?: RegexAnswers;
50
65
  }): CodingResult;
66
+ /**
67
+ * {@link codeLines} with the rules' patterns answered in the regex worker, a
68
+ * chunk of lines at a time.
69
+ *
70
+ * One `RegexAnswers` takes at most tool-schema's `MAX_PATTERN_QUESTIONS`
71
+ * distinct pattern questions and answers any past that as undetermined. A
72
+ * line asks one per pattern condition, so 5 000 lines under 201 one-pattern
73
+ * rules — well inside the schema's 5 000 lines and 1 000 rules — asked more,
74
+ * and every line past the cap went to review uncoded where 0.7.0 coded it
75
+ * (bounds review). So the lines are cut into chunks that cannot ask more
76
+ * than the cap (`maxQuestions`, for tests), each asked, answered and coded
77
+ * in turn.
78
+ *
79
+ * The patterns share one budget, counted only while they run: tool-schema's
80
+ * `CALLER_PATTERN_DEADLINE_MS` per chunk, for at most
81
+ * {@link MAX_BUDGETED_CHUNKS} chunks (`deadlineMs` overrides it). Lines
82
+ * reached after it ran out, or after the call was cancelled, are not asked
83
+ * about: their pattern conditions are undetermined, and they go to review.
84
+ */
85
+ export declare function codeLinesAnsweringPatterns(lines: ReadonlyArray<{
86
+ readonly id: string;
87
+ } & Record<string, unknown>>, rules: ReadonlyArray<CodingRule>, options?: CodeOptions & {
88
+ readonly run?: RegexRunContext;
89
+ readonly deadlineMs?: number;
90
+ readonly maxQuestions?: number;
91
+ }): Promise<CodingResult>;
92
+ export {};
@@ -10,8 +10,54 @@
10
10
  * Conditions are `@crewhaus/tool-schema`'s check grammar, the same one
11
11
  * `Assert` and `Branch` use.
12
12
  */
13
- import { runChecks } from "@crewhaus/tool-schema";
14
- export function codeLines(lines, rules, options = {}) {
13
+ import { CALLER_PATTERN_DEADLINE_MS, MAX_PATTERN_QUESTIONS, RegexAnswers, askCheckPatterns, runChecks, testPatternSync, } from "@crewhaus/tool-schema";
14
+ /**
15
+ * Whether `check` gets as far as running its pattern on `line`: its path
16
+ * resolves to a string. Asked of the evaluator itself (tool-schema's
17
+ * `askCheckPatterns` asks exactly the questions `runChecks` will), so the
18
+ * two cannot disagree.
19
+ */
20
+ function reachesPattern(line, check) {
21
+ const probe = new RegexAnswers();
22
+ askCheckPatterns(line, [check], probe);
23
+ return probe.pending > 0;
24
+ }
25
+ /**
26
+ * Whether `rule` holds for `line`, three ways. A condition whose pattern
27
+ * could not be run to an answer is undetermined, and so is one whose
28
+ * pattern the screen refused: the rule is malformed, and reading it as "did
29
+ * not match" let a lower-priority rule code the line (C073). A condition
30
+ * that definitely failed still decides the rule, and so does one that fails
31
+ * before its pattern would run — the field is absent, or not a string — as
32
+ * it did on 0.7.0, whatever the pattern (bounds review).
33
+ */
34
+ function ruleVerdict(line, rule, regex, refused) {
35
+ const report = runChecks(line, rule.when, regex === undefined ? {} : { regex });
36
+ if (report.ok)
37
+ return { verdict: "pass" };
38
+ const open = [];
39
+ for (const failure of report.failures) {
40
+ const check = rule.when[failure.index];
41
+ if (failure.undetermined === true) {
42
+ open.push(failure.reason);
43
+ continue;
44
+ }
45
+ const refusal = refused(check);
46
+ if (refusal !== undefined && reachesPattern(line, check))
47
+ open.push(refusal);
48
+ else
49
+ return { verdict: "fail" };
50
+ }
51
+ return { verdict: "undetermined", reason: open.join("; ") };
52
+ }
53
+ /** Blocking rules a review reason names with their reasons; the rest are counted. */
54
+ const NAMED_IN_REASON = 3;
55
+ /**
56
+ * Validate the rules once, and return the screen's verdict per pattern: a
57
+ * refused pattern is refused whatever it is run on, so each is screened
58
+ * once, on the empty string.
59
+ */
60
+ function prepareRules(rules) {
15
61
  const seen = new Set();
16
62
  for (const rule of rules) {
17
63
  if (rule.when.length === 0) {
@@ -21,47 +67,182 @@ export function codeLines(lines, rules, options = {}) {
21
67
  throw new Error(`two rules share the id "${rule.id}"`);
22
68
  seen.add(rule.id);
23
69
  }
24
- const results = [];
25
- for (const line of lines) {
26
- const matches = rules
27
- .filter((rule) => runChecks(line, rule.when).ok)
28
- .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
29
- if (matches.length === 0) {
30
- results.push({
31
- lineId: line.id,
32
- account: options.defaultAccount ?? null,
33
- costCenter: null,
34
- taxCode: null,
35
- matched: [],
36
- ambiguous: false,
37
- // A default account is a place to put it, not a coding decision, so
38
- // the line still goes to review rather than quietly landing in a
39
- // suspense account nobody looks at.
40
- needsReview: true,
41
- reason: "no rule matched",
42
- });
43
- continue;
70
+ const refusals = new Map();
71
+ return (check) => {
72
+ if (check.op !== "matches" && check.op !== "notMatches")
73
+ return undefined;
74
+ if (typeof check.expected !== "string")
75
+ return undefined;
76
+ const flags = check.flags ?? "";
77
+ const key = `${flags}\u0000${check.expected}`;
78
+ if (!refusals.has(key)) {
79
+ const answer = testPatternSync(check.expected, flags, "");
80
+ refusals.set(key, typeof answer === "object" && "refused" in answer
81
+ ? `invalid regex /${check.expected}/${flags}: ${answer.refused}`
82
+ : undefined);
83
+ }
84
+ return refusals.get(key);
85
+ };
86
+ }
87
+ export function codeLines(lines, rules, options = {}) {
88
+ const refused = prepareRules(rules);
89
+ return summarize(lines.map((line) => codeLine(line, rules, options.regex, refused, options.defaultAccount)), options.version);
90
+ }
91
+ /** Answers every question as undetermined, for lines reached after the pattern budget ran out. */
92
+ class Unanswered extends RegexAnswers {
93
+ why;
94
+ constructor(why) {
95
+ super();
96
+ this.why = why;
97
+ }
98
+ lookup() {
99
+ return { undetermined: this.why };
100
+ }
101
+ }
102
+ /**
103
+ * Chunks whose patterns get tool-schema's per-call budget each, at most: a
104
+ * batch of benign patterns is answered in full up to the schema's limits,
105
+ * and a runaway pattern costs a worker thirty seconds at worst (0.7.0 ran
106
+ * the same batch on the caller's thread with no bound at all).
107
+ */
108
+ const MAX_BUDGETED_CHUNKS = 6;
109
+ /**
110
+ * {@link codeLines} with the rules' patterns answered in the regex worker, a
111
+ * chunk of lines at a time.
112
+ *
113
+ * One `RegexAnswers` takes at most tool-schema's `MAX_PATTERN_QUESTIONS`
114
+ * distinct pattern questions and answers any past that as undetermined. A
115
+ * line asks one per pattern condition, so 5 000 lines under 201 one-pattern
116
+ * rules — well inside the schema's 5 000 lines and 1 000 rules — asked more,
117
+ * and every line past the cap went to review uncoded where 0.7.0 coded it
118
+ * (bounds review). So the lines are cut into chunks that cannot ask more
119
+ * than the cap (`maxQuestions`, for tests), each asked, answered and coded
120
+ * in turn.
121
+ *
122
+ * The patterns share one budget, counted only while they run: tool-schema's
123
+ * `CALLER_PATTERN_DEADLINE_MS` per chunk, for at most
124
+ * {@link MAX_BUDGETED_CHUNKS} chunks (`deadlineMs` overrides it). Lines
125
+ * reached after it ran out, or after the call was cancelled, are not asked
126
+ * about: their pattern conditions are undetermined, and they go to review.
127
+ */
128
+ export async function codeLinesAnsweringPatterns(lines, rules, options = {}) {
129
+ const refused = prepareRules(rules);
130
+ let perLine = 0;
131
+ for (const rule of rules) {
132
+ for (const check of rule.when) {
133
+ if ((check.op === "matches" || check.op === "notMatches") &&
134
+ typeof check.expected === "string") {
135
+ perLine += 1;
136
+ }
137
+ }
138
+ }
139
+ const cap = Math.max(1, options.maxQuestions ?? MAX_PATTERN_QUESTIONS);
140
+ const perChunk = perLine === 0 ? lines.length : Math.max(1, Math.floor(cap / perLine));
141
+ const chunks = Math.max(1, Math.ceil(lines.length / Math.max(1, perChunk)));
142
+ const size = Math.max(1, Math.ceil(lines.length / chunks));
143
+ const budgetMs = options.deadlineMs ?? CALLER_PATTERN_DEADLINE_MS * Math.min(chunks, MAX_BUDGETED_CHUNKS);
144
+ const run = options.run ?? {};
145
+ let spent = 0;
146
+ const coded = [];
147
+ for (let from = 0; from < lines.length; from += size) {
148
+ const chunk = lines.slice(from, from + size);
149
+ const left = Math.floor(budgetMs - spent);
150
+ let regex;
151
+ if (perLine > 0 && run.signal?.aborted === true) {
152
+ regex = new Unanswered("the call was cancelled before this line's patterns could run");
44
153
  }
45
- const top = matches[0];
46
- const topPriority = top.priority ?? 0;
47
- const tied = matches.filter((r) => (r.priority ?? 0) === topPriority);
48
- // Two rules at the same priority disagreeing is a rule-set bug. Picking
49
- // one would hide it, and the wrong account is found in an audit, not in
50
- // a test.
51
- const conflicting = tied.some((r) => r.account !== top.account);
52
- results.push({
154
+ else if (perLine > 0 && left < 1) {
155
+ regex = new Unanswered(`the call's ${budgetMs} ms for running patterns ran out before this line's could run`);
156
+ }
157
+ else {
158
+ regex = new RegexAnswers();
159
+ if (perLine > 0) {
160
+ for (const line of chunk) {
161
+ for (const rule of rules)
162
+ askCheckPatterns(line, rule.when, regex);
163
+ }
164
+ const began = performance.now();
165
+ await regex.resolve(run, { deadlineMs: left });
166
+ spent += performance.now() - began;
167
+ }
168
+ }
169
+ for (const line of chunk) {
170
+ coded.push(codeLine(line, rules, regex, refused, options.defaultAccount));
171
+ }
172
+ }
173
+ return summarize(coded, options.version);
174
+ }
175
+ function codeLine(line, rules, regex, refused, defaultAccount) {
176
+ const verdicts = rules.map((rule) => ({
177
+ rule,
178
+ ...ruleVerdict(line, rule, regex, refused),
179
+ }));
180
+ const matches = verdicts
181
+ .filter((v) => v.verdict === "pass")
182
+ .map((v) => v.rule)
183
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
184
+ // A rule that could not be evaluated decides nothing, but it could have:
185
+ // when it would outrank (or tie) whatever did match, or when nothing
186
+ // did, the line is not coded.
187
+ const bar = matches.length === 0 ? Number.NEGATIVE_INFINITY : (matches[0]?.priority ?? 0);
188
+ const blocking = verdicts.filter((v) => v.verdict === "undetermined" && (v.rule.priority ?? 0) >= bar);
189
+ if (blocking.length > 0) {
190
+ // A few named with their reasons; every one is in `undetermined`. A
191
+ // batch whose patterns all went unanswered would otherwise repeat a
192
+ // reason per rule on every line.
193
+ const shown = blocking.slice(0, NAMED_IN_REASON).map((v) => `"${v.rule.id}" (${v.reason})`);
194
+ const more = blocking.length - shown.length;
195
+ const named = more > 0 ? `${shown.join(", ")} and ${more} more (see undetermined)` : shown.join(", ");
196
+ return {
53
197
  lineId: line.id,
54
- account: conflicting ? null : top.account,
55
- costCenter: conflicting ? null : (top.costCenter ?? null),
56
- taxCode: conflicting ? null : (top.taxCode ?? null),
198
+ account: null,
199
+ costCenter: null,
200
+ taxCode: null,
57
201
  matched: matches.map((r) => r.id),
58
- ambiguous: conflicting,
59
- needsReview: conflicting,
60
- reason: conflicting
61
- ? `rules ${tied.map((r) => `"${r.id}"`).join(", ")} match at the same priority and disagree on the account`
62
- : "",
63
- });
202
+ ambiguous: false,
203
+ needsReview: true,
204
+ reason: matches.length === 0
205
+ ? `no rule definitely matched, and these could not be evaluated: ${named}`
206
+ : `rule "${matches[0].id}" matched, but these could not be evaluated and would outrank or tie it: ${named}`,
207
+ undetermined: blocking.map((v) => v.rule.id),
208
+ };
64
209
  }
210
+ if (matches.length === 0) {
211
+ return {
212
+ lineId: line.id,
213
+ account: defaultAccount ?? null,
214
+ costCenter: null,
215
+ taxCode: null,
216
+ matched: [],
217
+ ambiguous: false,
218
+ // A default account is a place to put it, not a coding decision, so
219
+ // the line still goes to review rather than quietly landing in a
220
+ // suspense account nobody looks at.
221
+ needsReview: true,
222
+ reason: "no rule matched",
223
+ };
224
+ }
225
+ const top = matches[0];
226
+ const topPriority = top.priority ?? 0;
227
+ const tied = matches.filter((r) => (r.priority ?? 0) === topPriority);
228
+ // Two rules at the same priority disagreeing is a rule-set bug. Picking
229
+ // one would hide it, and the wrong account is found in an audit, not in
230
+ // a test.
231
+ const conflicting = tied.some((r) => r.account !== top.account);
232
+ return {
233
+ lineId: line.id,
234
+ account: conflicting ? null : top.account,
235
+ costCenter: conflicting ? null : (top.costCenter ?? null),
236
+ taxCode: conflicting ? null : (top.taxCode ?? null),
237
+ matched: matches.map((r) => r.id),
238
+ ambiguous: conflicting,
239
+ needsReview: conflicting,
240
+ reason: conflicting
241
+ ? `rules ${tied.map((r) => `"${r.id}"`).join(", ")} match at the same priority and disagree on the account`
242
+ : "",
243
+ };
244
+ }
245
+ function summarize(results, version) {
65
246
  const tally = new Map();
66
247
  for (const line of results) {
67
248
  if (line.account === null || line.needsReview)
@@ -72,7 +253,7 @@ export function codeLines(lines, rules, options = {}) {
72
253
  lines: results,
73
254
  coded: results.filter((l) => !l.needsReview).length,
74
255
  needsReview: results.filter((l) => l.needsReview).length,
75
- version: options.version ?? null,
256
+ version: version ?? null,
76
257
  byAccount: [...tally.entries()]
77
258
  .map(([account, count]) => ({ account, lines: count }))
78
259
  .sort((a, b) => b.lines - a.lines || (a.account < b.account ? -1 : 1)),
@@ -2,8 +2,11 @@
2
2
  * Spending controls and refund-abuse signals.
3
3
  *
4
4
  * An unattended harness that can move money needs a limit it cannot talk
5
- * itself past. A limit a model is asked to respect is a suggestion; a limit
6
- * computed from the record of what has already been spent is a limit.
5
+ * itself past, and a function cannot be that limit on its own: it computes
6
+ * over the history, limits and clock it is handed. When a model hands them
7
+ * over, the verdict is advisory. It is a control only where those inputs come
8
+ * from a record the model cannot edit, and a limit that must hold belongs in
9
+ * the policy of whatever actually moves the money.
7
10
  *
8
11
  * Both functions take the history as an argument and the clock as a
9
12
  * parameter. Nothing here reads a database or the time, so the same facts