@crewhaus/tool-flow 0.7.0 → 0.8.1

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/lib/score.js CHANGED
@@ -8,8 +8,23 @@
8
8
  * be re-run identically next quarter. This returns the contributors, and the
9
9
  * version of the rules that produced them.
10
10
  */
11
- import { runChecks } from "@crewhaus/tool-schema";
12
- export function scoreValue(value, model) {
11
+ import { decimalKernel } from "@crewhaus/tool-math";
12
+ import { checksVerdict, runChecks } from "@crewhaus/tool-schema";
13
+ const { align, decimalAdd, decimalToNumber, parseDecimal } = decimalKernel;
14
+ /**
15
+ * `a` compared with `b`, exactly: -1, 0 or 1. A bound of -Infinity or
16
+ * Infinity (a library caller's, since JSON cannot spell one) compares as
17
+ * what it is rather than failing the parse.
18
+ */
19
+ function compareExact(a, b) {
20
+ if (b === Number.NEGATIVE_INFINITY)
21
+ return 1;
22
+ if (b === Number.POSITIVE_INFINITY)
23
+ return -1;
24
+ const aligned = align(a, parseDecimal(b));
25
+ return aligned.a < aligned.b ? -1 : aligned.a > aligned.b ? 1 : 0;
26
+ }
27
+ export function scoreValue(value, model, options = {}) {
13
28
  if (model.rules.length === 0)
14
29
  throw new Error("the scoring model has no rules");
15
30
  const seen = new Set();
@@ -29,28 +44,57 @@ export function scoreValue(value, model) {
29
44
  }
30
45
  const contributors = [];
31
46
  const missed = [];
32
- let rawScore = 0;
47
+ // Summed as exact decimals, not doubles. Points are decimal literals, and
48
+ // String(number) gives that literal back, so the sum is the one a person
49
+ // adds up by hand: 0.7 + 0.1 is 0.8 and lands in a band at 0.8, where the
50
+ // float sum 0.7999999999999999 missed it. Exact addition is also
51
+ // associative, so the band no longer depends on the order the rules were
52
+ // declared in (0.1 + 0.1 + 0.6 and 0.6 + 0.1 + 0.1 used to band apart).
53
+ let exact = { unscaled: 0n, scale: 0 };
54
+ const undecided = [];
33
55
  for (const rule of model.rules) {
34
- const report = runChecks(value, rule.when);
35
- if (report.ok) {
36
- rawScore += rule.points;
56
+ const report = runChecks(value, rule.when, options.regex === undefined ? {} : { regex: options.regex });
57
+ const verdict = checksVerdict(report, "all");
58
+ if (verdict === "pass") {
59
+ exact = decimalAdd(exact, parseDecimal(rule.points));
37
60
  contributors.push({ id: rule.id, label: rule.label ?? rule.id, points: rule.points });
38
61
  }
62
+ else if (verdict === "undetermined") {
63
+ const why = report.failures.find((f) => f.undetermined === true)?.reason ?? "";
64
+ undecided.push({ id: rule.id, reason: why });
65
+ }
39
66
  else {
40
67
  missed.push({ id: rule.id, reason: report.failures[0]?.reason ?? "" });
41
68
  }
42
69
  }
70
+ if (undecided.length > 0) {
71
+ return {
72
+ score: null,
73
+ rawScore: null,
74
+ band: null,
75
+ version: model.version ?? null,
76
+ contributors,
77
+ missed,
78
+ undetermined: undecided,
79
+ };
80
+ }
81
+ const rawScore = decimalToNumber(exact);
43
82
  let score = rawScore;
44
- if (model.min !== undefined)
45
- score = Math.max(model.min, score);
46
- if (model.max !== undefined)
47
- score = Math.min(model.max, score);
83
+ let clamped = exact;
84
+ if (model.min !== undefined && compareExact(clamped, model.min) < 0) {
85
+ score = model.min;
86
+ clamped = parseDecimal(model.min);
87
+ }
88
+ if (model.max !== undefined && compareExact(clamped, model.max) > 0) {
89
+ score = model.max;
90
+ clamped = parseDecimal(model.max);
91
+ }
48
92
  let band = null;
49
93
  if (model.bands && model.bands.length > 0) {
50
94
  // Highest qualifying band wins, whatever order they were declared in, so
51
95
  // a table written low-to-high and one written high-to-low agree.
52
96
  const sorted = [...model.bands].sort((a, b) => b.min - a.min);
53
- band = sorted.find((b) => score >= b.min)?.name ?? null;
97
+ band = sorted.find((b) => compareExact(clamped, b.min) >= 0)?.name ?? null;
54
98
  }
55
99
  return { score, rawScore, band, version: model.version ?? null, contributors, missed };
56
100
  }
@@ -27,7 +27,7 @@
27
27
  * a step that died three turns ago.
28
28
  * - **skipped** — the step's own conditions did not hold. A settled no.
29
29
  */
30
- import { type Check } from "@crewhaus/tool-schema";
30
+ import { type Check, type RegexAnswers } from "@crewhaus/tool-schema";
31
31
  import type { MatchMode } from "./branch";
32
32
  /**
33
33
  * Where a flow stands.
@@ -90,6 +90,12 @@ export type SequencePlan = {
90
90
  readonly waiting: ReadonlyArray<HeldStep>;
91
91
  readonly unreachable: ReadonlyArray<HeldStep>;
92
92
  readonly skipped: ReadonlyArray<HeldStep>;
93
+ /**
94
+ * Steps whose conditions could not be decided (a pattern had no answer):
95
+ * neither ready nor skipped, so the flow is `blocked` on them rather than
96
+ * running or ruling out a step on a guess. Present only when there are any.
97
+ */
98
+ readonly undetermined?: ReadonlyArray<HeldStep>;
93
99
  readonly completed: ReadonlyArray<string>;
94
100
  readonly failed: ReadonlyArray<string>;
95
101
  readonly version: string | null;
@@ -106,4 +112,6 @@ export type SequencePlan = {
106
112
  * a leftover from an older revision of the flow — would silently re-run a
107
113
  * step the caller believes is finished.
108
114
  */
109
- export declare function planSequence(value: unknown, spec: SequenceSpec, state: SequenceState): SequencePlan;
115
+ export declare function planSequence(value: unknown, spec: SequenceSpec, state: SequenceState, options?: {
116
+ readonly regex?: RegexAnswers;
117
+ }): SequencePlan;
@@ -27,7 +27,7 @@
27
27
  * a step that died three turns ago.
28
28
  * - **skipped** — the step's own conditions did not hold. A settled no.
29
29
  */
30
- import { runChecks } from "@crewhaus/tool-schema";
30
+ import { checksVerdict, runChecks } from "@crewhaus/tool-schema";
31
31
  /**
32
32
  * Where a flow stands.
33
33
  *
@@ -47,7 +47,7 @@ export const PLAN_STATES = ["ready", "blocked", "finished", "halted"];
47
47
  * a leftover from an older revision of the flow — would silently re-run a
48
48
  * step the caller believes is finished.
49
49
  */
50
- export function planSequence(value, spec, state) {
50
+ export function planSequence(value, spec, state, options = {}) {
51
51
  const steps = spec.steps;
52
52
  if (steps.length === 0)
53
53
  throw new Error("the sequence has no steps");
@@ -132,16 +132,28 @@ export function planSequence(value, spec, state) {
132
132
  });
133
133
  continue;
134
134
  }
135
- // The shared evaluator reports a check it could not evaluate — a regex
136
- // that will not compile, a malformed path — as a check that did not hold,
137
- // with the reason as its text and no flag to tell the two apart. That is
138
- // `Branch` and `RuleScore`'s behaviour too, and inventing a different one
139
- // here would mean two answers to the same question. It bites harder in a
140
- // sequence, because the skip cascades into `unreachable` dependents, so
141
- // the reason travels with every one of them.
135
+ // The shared evaluator reports a malformed check (a regex that will not
136
+ // compile, a bad path) as a check that did not hold, with the reason as
137
+ // its text. That is `Branch` and `RuleScore`'s behaviour too, and
138
+ // inventing a different one here would mean two answers to the same
139
+ // question. It bites harder in a sequence, because the skip cascades
140
+ // into `unreachable` dependents, so the reason travels with every one of
141
+ // them. A pattern that could not be run to an answer is different: it
142
+ // is not a verdict, so the step is held as undetermined instead.
142
143
  if (step.when && step.when.length > 0) {
143
- const report = runChecks(value, step.when);
144
- const holds = (step.match ?? "all") === "all" ? report.ok : report.passed > 0;
144
+ const report = runChecks(value, step.when, options.regex === undefined ? {} : { regex: options.regex });
145
+ const verdict = checksVerdict(report, step.match ?? "all");
146
+ if (verdict === "undetermined") {
147
+ // Neither due nor ruled out: skipping it would cascade `unreachable`
148
+ // through its dependents on an answer nobody got.
149
+ states.set(step.id, "undetermined");
150
+ reasons.set(step.id, {
151
+ id: step.id,
152
+ reason: report.failures.find((f) => f.undetermined === true)?.reason ?? "",
153
+ });
154
+ continue;
155
+ }
156
+ const holds = verdict === "pass";
145
157
  if (!holds) {
146
158
  states.set(step.id, "skipped");
147
159
  reasons.set(step.id, {
@@ -160,6 +172,7 @@ export function planSequence(value, spec, state) {
160
172
  const waiting = [];
161
173
  const unreachable = [];
162
174
  const skipped = [];
175
+ const undecided = [];
163
176
  const doneIds = [];
164
177
  const failedIds = [];
165
178
  for (const [index, step] of steps.entries()) {
@@ -179,6 +192,9 @@ export function planSequence(value, spec, state) {
179
192
  case "skipped":
180
193
  skipped.push(held);
181
194
  break;
195
+ case "undetermined":
196
+ undecided.push(held);
197
+ break;
182
198
  case "done":
183
199
  doneIds.push(step.id);
184
200
  break;
@@ -189,7 +205,7 @@ export function planSequence(value, spec, state) {
189
205
  }
190
206
  const planState = ready.length > 0
191
207
  ? "ready"
192
- : waiting.length > 0
208
+ : waiting.length > 0 || undecided.length > 0
193
209
  ? "blocked"
194
210
  : failedIds.length > 0 || unreachable.length > 0
195
211
  ? "halted"
@@ -201,6 +217,7 @@ export function planSequence(value, spec, state) {
201
217
  waiting,
202
218
  unreachable,
203
219
  skipped,
220
+ ...(undecided.length > 0 ? { undetermined: undecided } : {}),
204
221
  completed: doneIds,
205
222
  failed: failedIds,
206
223
  version: spec.version ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-flow",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic control-flow tools: branching, decision tables, error classification, deadlines, consensus, stall detection, rule scoring, record routing and sequence planning",
6
6
  "main": "dist/index.js",
@@ -8,17 +8,19 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/errors": "0.7.0",
19
- "@crewhaus/tool-builder": "0.7.0",
20
- "@crewhaus/tool-catalog": "0.7.0",
21
- "@crewhaus/tool-schema": "0.7.0",
19
+ "@crewhaus/errors": "0.8.1",
20
+ "@crewhaus/tool-builder": "0.8.1",
21
+ "@crewhaus/tool-catalog": "0.8.1",
22
+ "@crewhaus/tool-math": "0.8.1",
23
+ "@crewhaus/tool-schema": "0.8.1",
22
24
  "zod": "^3.23.8"
23
25
  },
24
26
  "devDependencies": {
@@ -42,5 +44,8 @@
42
44
  "publishConfig": {
43
45
  "access": "public"
44
46
  },
45
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
47
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
48
+ "engines": {
49
+ "bun": ">=1.3.14"
50
+ }
46
51
  }