@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.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +26 -5
- package/dist/index.js +75 -22
- package/dist/lib/allocate.d.ts +7 -0
- package/dist/lib/allocate.js +95 -55
- package/dist/lib/coding.d.ts +46 -4
- package/dist/lib/coding.js +221 -40
- package/dist/lib/controls.d.ts +5 -2
- package/dist/lib/controls.js +21 -14
- package/dist/lib/exact.d.ts +68 -0
- package/dist/lib/exact.js +122 -0
- package/dist/lib/lots.d.ts +7 -0
- package/dist/lib/lots.js +141 -45
- package/dist/lib/match.d.ts +5 -1
- package/dist/lib/match.js +122 -20
- package/dist/lib/statement.d.ts +44 -5
- package/dist/lib/statement.js +205 -106
- package/dist/lib/tax.d.ts +7 -1
- package/dist/lib/tax.js +60 -42
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/package.json +12 -7
package/dist/lib/allocate.js
CHANGED
|
@@ -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 <
|
|
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,
|
|
28
|
-
const negative =
|
|
29
|
-
const magnitude =
|
|
30
|
-
|
|
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 =
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
86
|
-
const lineDiscount = splitByQuantity(line.discountMinor ?? 0, fraction);
|
|
87
|
-
const orderDiscount = splitByQuantity(shareByLine.get(line.id) ??
|
|
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
|
-
|
|
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
|
|
131
|
+
const shippingTotal = big(options.shippingMinor ?? 0, "shippingMinor");
|
|
132
|
+
let shipping = 0n;
|
|
103
133
|
if (policy === "full" || (policy === "proportional" && fullReturn)) {
|
|
104
|
-
|
|
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 =
|
|
113
|
-
|
|
114
|
-
const returnedQuantity =
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
146
|
+
return;
|
|
147
|
+
shipping += splitByQuantity(shares[i], [
|
|
148
|
+
BigInt(returnedQuantity),
|
|
149
|
+
BigInt(line.quantity),
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
119
152
|
}
|
|
120
|
-
const
|
|
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:
|
|
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(
|
|
141
|
-
if (total <=
|
|
142
|
-
return
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|
package/dist/lib/coding.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|
package/dist/lib/coding.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
55
|
-
costCenter:
|
|
56
|
-
taxCode:
|
|
198
|
+
account: null,
|
|
199
|
+
costCenter: null,
|
|
200
|
+
taxCode: null,
|
|
57
201
|
matched: matches.map((r) => r.id),
|
|
58
|
-
ambiguous:
|
|
59
|
-
needsReview:
|
|
60
|
-
reason:
|
|
61
|
-
? `
|
|
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:
|
|
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)),
|
package/dist/lib/controls.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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
|