@nmnmcc/ability 2.0.5 → 2.0.6

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.
Files changed (49) hide show
  1. package/dist/Condition.d.ts +4 -4
  2. package/dist/Condition.d.ts.map +1 -1
  3. package/dist/Condition.js.map +1 -1
  4. package/dist/Domain.d.ts +1 -1
  5. package/dist/Policy.d.ts +2 -2
  6. package/dist/Policy.d.ts.map +1 -1
  7. package/dist/Policy.js.map +1 -1
  8. package/dist/internal/algebra.js +28 -6
  9. package/dist/internal/algebra.js.map +1 -1
  10. package/dist/internal/authorizer.d.ts.map +1 -1
  11. package/dist/internal/authorizer.js +37 -14
  12. package/dist/internal/authorizer.js.map +1 -1
  13. package/dist/internal/bind.js +7 -8
  14. package/dist/internal/bind.js.map +1 -1
  15. package/dist/internal/catalog.d.ts +6 -0
  16. package/dist/internal/catalog.d.ts.map +1 -1
  17. package/dist/internal/catalog.js +12 -9
  18. package/dist/internal/catalog.js.map +1 -1
  19. package/dist/internal/decide.js +1 -1
  20. package/dist/internal/domain.js +1 -1
  21. package/dist/internal/json.js +2 -10
  22. package/dist/internal/json.js.map +1 -1
  23. package/dist/internal/nodes.d.ts +4 -1
  24. package/dist/internal/nodes.d.ts.map +1 -1
  25. package/dist/internal/nodes.js +17 -7
  26. package/dist/internal/nodes.js.map +1 -1
  27. package/dist/internal/patterns.js +1 -1
  28. package/dist/internal/patterns.js.map +1 -1
  29. package/dist/internal/policy-json.js +2 -8
  30. package/dist/internal/policy-json.js.map +1 -1
  31. package/dist/internal/policy-nodes.d.ts +4 -1
  32. package/dist/internal/policy-nodes.d.ts.map +1 -1
  33. package/dist/internal/policy-nodes.js +13 -4
  34. package/dist/internal/policy-nodes.js.map +1 -1
  35. package/dist/internal/search.js +4 -4
  36. package/dist/internal/search.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/Condition.ts +5 -2
  39. package/src/Policy.ts +1 -2
  40. package/src/internal/algebra.ts +44 -8
  41. package/src/internal/authorizer.ts +56 -14
  42. package/src/internal/bind.ts +18 -14
  43. package/src/internal/catalog.ts +40 -27
  44. package/src/internal/json.ts +2 -11
  45. package/src/internal/nodes.ts +21 -11
  46. package/src/internal/patterns.ts +7 -3
  47. package/src/internal/policy-json.ts +2 -9
  48. package/src/internal/policy-nodes.ts +14 -6
  49. package/src/internal/search.ts +13 -10
@@ -7,11 +7,14 @@
7
7
  // language service check for `unknown` in an Effect is off here.
8
8
  // @effect-diagnostics anyUnknownInErrorContext:off
9
9
  import * as Cache from "effect/Cache"
10
+ import * as Chunk from "effect/Chunk"
10
11
  import * as Context from "effect/Context"
11
12
  import type * as Duration from "effect/Duration"
12
13
  import * as Effect from "effect/Effect"
13
14
  import * as Equal from "effect/Equal"
14
15
  import * as Hash from "effect/Hash"
16
+ import * as HashMap from "effect/HashMap"
17
+ import * as HashSet from "effect/HashSet"
15
18
  import * as Layer from "effect/Layer"
16
19
  import * as Result from "effect/Result"
17
20
  import * as Schema from "effect/Schema"
@@ -103,18 +106,24 @@ type AnyTarget = {
103
106
  class PrincipalKey implements Equal.Equal {
104
107
  readonly principal: object
105
108
  readonly text: string
106
- /** The key is the JSON form, so the decoded principals must be equal too. */
107
- readonly exact: boolean
108
- constructor(principal: object, text: string, exact: boolean) {
109
+ /**
110
+ * The order of the parts of the decoded principal, from {@link orderOf},
111
+ * when the key is the JSON form. The decoded principals must be equal
112
+ * too, with their parts in the same order. `undefined` for a key from
113
+ * `cache.key`.
114
+ */
115
+ readonly order: string | undefined
116
+ constructor(principal: object, text: string, order: string | undefined) {
109
117
  this.principal = principal
110
118
  this.text = text
111
- this.exact = exact
119
+ this.order = order
112
120
  }
113
121
  [Equal.symbol](that: unknown): boolean {
114
122
  return (
115
123
  that instanceof PrincipalKey &&
116
124
  that.text === this.text &&
117
- (!this.exact || Equal.equals(that.principal, this.principal))
125
+ that.order === this.order &&
126
+ (this.order === undefined || Equal.equals(that.principal, this.principal))
118
127
  )
119
128
  }
120
129
  [Hash.symbol](): number {
@@ -123,14 +132,36 @@ class PrincipalKey implements Equal.Equal {
123
132
  }
124
133
 
125
134
  /**
126
- * Makes the cache key of a principal from its JSON form. The key keeps the
127
- * principal that the Schema decodes from that form, so the loader sees only
128
- * the data that the key shows. A principal gets no key when it does not
129
- * encode, or when the decoded value is not `Equal` to it. Two classes, or a
130
- * string and a number, may have one JSON form, as in a union, and `Equal`
131
- * tells them apart. So two principals with one key are equal, and the cache
132
- * can not give one the policy of the other.
135
+ * The text of the order of the parts of a value: the names of the fields of
136
+ * each object, and the items of each list, set and map, in the order that a
137
+ * loop sees them. `Equal` does not see this order, but a loader may read it,
138
+ * as `Object.values` does. So two keys need the same order too. A scalar has
139
+ * no parts, and `Equal` compares it.
133
140
  */
141
+ const orderOf = (value: unknown): string => {
142
+ if (typeof value !== "object" || value === null) {
143
+ return ""
144
+ }
145
+ if (Array.isArray(value)) {
146
+ return `[${value.map(orderOf).join(",")}]`
147
+ }
148
+ if (
149
+ value instanceof Map ||
150
+ value instanceof Set ||
151
+ HashMap.isHashMap(value) ||
152
+ HashSet.isHashSet(value) ||
153
+ Chunk.isChunk(value)
154
+ ) {
155
+ return `<${Array.from(value as Iterable<unknown>, orderOf).join(",")}>`
156
+ }
157
+ return `{${Object.keys(value)
158
+ .map(
159
+ (name) =>
160
+ `${JSON.stringify(name)}:${orderOf((value as Record<string, unknown>)[name])}`,
161
+ )
162
+ .join(",")}}`
163
+ }
164
+
134
165
  /**
135
166
  * Gives the JSON codec of a Schema, or `undefined` when the Schema has no JSON
136
167
  * form, as a struct with a symbol key has none.
@@ -148,6 +179,17 @@ const jsonCodecOf = (
148
179
  }
149
180
  }
150
181
 
182
+ /**
183
+ * Makes the cache key of a principal from its JSON form. The key keeps the
184
+ * principal that the Schema decodes from that form, so the loader sees only
185
+ * the data that the key shows. A principal gets no key when it does not
186
+ * encode, or when the decoded value is not `Equal` to it. Two classes, or a
187
+ * string and a number, may have one JSON form, as in a union, and `Equal`
188
+ * tells them apart. A Schema may make data when it decodes, as the order of
189
+ * the fields of a record, so the key keeps that order too. So two
190
+ * principals with one key are equal, and the cache can not give one the
191
+ * policy of the other.
192
+ */
151
193
  const jsonKey = (
152
194
  schema: Schema.Top,
153
195
  ): ((principal: object) => PrincipalKey | undefined) => {
@@ -170,7 +212,7 @@ const jsonKey = (
170
212
  onFailure: () => undefined,
171
213
  onSuccess: (decoded) =>
172
214
  Equal.equals(decoded, principal)
173
- ? new PrincipalKey(decoded as object, text, true)
215
+ ? new PrincipalKey(decoded as object, text, orderOf(decoded))
174
216
  : undefined,
175
217
  })
176
218
  },
@@ -236,7 +278,7 @@ export const makeLayer = (
236
278
  const text = custom(principal)
237
279
  return text === undefined
238
280
  ? undefined
239
- : new PrincipalKey(canonical?.principal ?? principal, text, false)
281
+ : new PrincipalKey(canonical?.principal ?? principal, text, undefined)
240
282
  }
241
283
  return Layer.effect(
242
284
  service,
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import * as Arr from "effect/Array"
9
9
  import * as HashMap from "effect/HashMap"
10
- import * as MutableHashMap from "effect/MutableHashMap"
11
10
  import * as Option from "effect/Option"
12
11
  import * as Result from "effect/Result"
13
12
  import type * as Domain from "../Domain.ts"
@@ -39,8 +38,11 @@ export interface RuleData {
39
38
  * time. It goes away when the binding ends.
40
39
  */
41
40
  interface Memo {
42
- /** The suggestion for each name, by the place where it was looked for. */
43
- readonly suggestions: MutableHashMap.MutableHashMap<string, string>
41
+ /**
42
+ * The suggestion for each name, by the place where it was looked for. The
43
+ * names come from a policy, so the map is native, as `fits` is.
44
+ */
45
+ readonly suggestions: Map<string, string>
44
46
  /**
45
47
  * If a pattern matches a field of a kind, by the kind and the pattern. The
46
48
  * patterns come from a policy, so the map is native: the hash of a string
@@ -55,7 +57,7 @@ interface Memo {
55
57
  }
56
58
 
57
59
  const makeMemo = (): Memo => ({
58
- suggestions: MutableHashMap.empty(),
60
+ suggestions: new Map(),
59
61
  fits: new Map(),
60
62
  issues: { count: 0, noted: false },
61
63
  work: { steps: 0, noted: false },
@@ -77,15 +79,15 @@ const suggestIn = (
77
79
  names: () => ReadonlyArray<string>,
78
80
  ): string => {
79
81
  const key = `${scope}\u0000${name}`
80
- const known = MutableHashMap.get(memo.suggestions, key)
81
- if (Option.isSome(known)) {
82
- return known.value
82
+ const known = memo.suggestions.get(key)
83
+ if (known !== undefined) {
84
+ return known
83
85
  }
84
- if (MutableHashMap.size(memo.suggestions) >= maxSuggestions) {
86
+ if (memo.suggestions.size >= maxSuggestions) {
85
87
  return message
86
88
  }
87
89
  const made = withSuggestion(message, name, names())
88
- MutableHashMap.set(memo.suggestions, key, made)
90
+ memo.suggestions.set(key, made)
89
91
  return made
90
92
  }
91
93
 
@@ -590,9 +592,10 @@ export const ruleIssues = (
590
592
  memo: Memo = makeMemo(),
591
593
  ): ReadonlyArray<Issue> => {
592
594
  const all = rule.resources.includes("*")
593
- // Each kind is checked one time, also when the list names it again. A
594
- // native `Set` finds the copies in one pass: `Arr.dedupe` compares each
595
- // name with each name before it, and a stored rule may name very many.
595
+ // Each kind is checked one time, also when the list names it again. The
596
+ // names come from a policy, so a native `Set` finds the copies:
597
+ // `Arr.dedupe` puts the names in groups by the hash of Effect, which has
598
+ // no random seed, and very many names with one hash make one long group.
596
599
  const names = Array.from(
597
600
  new Set(Arr.filter(rule.resources, (kind) => kind !== "*")),
598
601
  )
@@ -616,8 +619,9 @@ export const ruleIssues = (
616
619
  ? Arr.filter(known, (descriptor) => hasAction(descriptor, rule.actions))
617
620
  : applicable,
618
621
  }
619
- const actions = Arr.dedupe(
620
- Arr.flatMap(known, (descriptor) => descriptor.actions),
622
+ // A native `Set` finds copies about ten times as fast as `Arr.dedupe`.
623
+ const actions = Array.from(
624
+ new Set(Arr.flatMap(known, (descriptor) => descriptor.actions)),
621
625
  )
622
626
  return [
623
627
  ...Arr.map(
@@ -69,7 +69,9 @@ export const joinKinds = (left: Kinds, right: Kinds): Kinds => ({
69
69
  ? left.number
70
70
  : right.number,
71
71
  object: left.object || right.object,
72
- literals: Arr.dedupe([...left.literals, ...right.literals]),
72
+ // A native `Set` finds copies about ten times as fast as `Arr.dedupe`. It
73
+ // takes `0` and `-0` as one, as `includes` does when a test reads them.
74
+ literals: Array.from(new Set([...left.literals, ...right.literals])),
73
75
  })
74
76
 
75
77
  /** Tests if a number fits the numbers of a field. */
@@ -146,6 +148,12 @@ export interface Branch {
146
148
  readonly field: Field | undefined
147
149
  /** The places below, by name, in the order of the Schema. */
148
150
  readonly children: ReadonlyArray<readonly [string, Branch]>
151
+ /**
152
+ * The places below, by name. A lookup gets each name from `split`, as a
153
+ * new string, and a `HashMap` finds a new string about 20% faster than a
154
+ * native `Map`. The names come from the Schema, so a name from a policy
155
+ * can not make a long list of names with one hash here.
156
+ */
149
157
  readonly names: HashMap.HashMap<string, Branch>
150
158
  /** The place for the keys of a record, when the value may be a record. */
151
159
  readonly key: Key | undefined
@@ -361,12 +369,12 @@ const nameOf = (signature: SchemaAST.PropertySignature): string | undefined =>
361
369
  /**
362
370
  * The properties of a struct by name. A walk makes it one time for each
363
371
  * struct of a place, so a struct with very many fields does not take a
364
- * search for each name.
372
+ * search for each name. It is a native `Map`, as `Branch.names` is.
365
373
  */
366
374
  const propertiesOf = (
367
375
  struct: SchemaAST.Objects,
368
- ): HashMap.HashMap<string, SchemaAST.PropertySignature> =>
369
- HashMap.fromIterable(
376
+ ): ReadonlyMap<string, SchemaAST.PropertySignature> =>
377
+ new Map(
370
378
  Arr.flatMap(struct.propertySignatures, (signature) => {
371
379
  const name = nameOf(signature)
372
380
  return name === undefined ? [] : [[name, signature] as const]
@@ -377,25 +385,29 @@ const propertiesOf = (
377
385
  const namesOf = (
378
386
  structs: ReadonlyArray<SchemaAST.Objects>,
379
387
  ): ReadonlyArray<string> =>
380
- Arr.dedupe(
381
- Arr.flatMap(structs, (struct) =>
382
- Arr.flatMap(struct.propertySignatures, (signature) => {
383
- const name = nameOf(signature)
384
- if (name === undefined) {
385
- return []
386
- }
387
- // A dot in a path is the step into a nested field. A field name with
388
- // a dot would make two fields have one path, so it is a defect.
389
- if (name.includes(".")) {
390
- throw new Error(`A field name may not have a dot: "${name}"`)
391
- }
392
- // An empty name would make an empty part in a path, such as
393
- // `.secret`.
394
- if (name === "") {
395
- throw new Error("A field name may not be empty")
396
- }
397
- return [name]
398
- }),
388
+ // A native `Set` keeps the first copy of each name, in order, about ten
389
+ // times as fast as `Arr.dedupe`.
390
+ Array.from(
391
+ new Set(
392
+ Arr.flatMap(structs, (struct) =>
393
+ Arr.flatMap(struct.propertySignatures, (signature) => {
394
+ const name = nameOf(signature)
395
+ if (name === undefined) {
396
+ return []
397
+ }
398
+ // A dot in a path is the step into a nested field. A field name with
399
+ // a dot would make two fields have one path, so it is a defect.
400
+ if (name.includes(".")) {
401
+ throw new Error(`A field name may not have a dot: "${name}"`)
402
+ }
403
+ // An empty name would make an empty part in a path, such as
404
+ // `.secret`.
405
+ if (name === "") {
406
+ throw new Error("A field name may not be empty")
407
+ }
408
+ return [name]
409
+ }),
410
+ ),
399
411
  ),
400
412
  )
401
413
 
@@ -478,9 +490,10 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
478
490
  const structProperties = Arr.map(structs, propertiesOf)
479
491
  const classProperties = Arr.map(classes, propertiesOf)
480
492
  const children = Arr.map(namesOf(structs), (name) => {
481
- const properties = Arr.flatMap(structProperties, (named) =>
482
- Option.toArray(HashMap.get(named, name)),
483
- )
493
+ const properties = Arr.flatMap(structProperties, (named) => {
494
+ const property = named.get(name)
495
+ return property === undefined ? [] : [property]
496
+ })
484
497
  const below: Place = {
485
498
  path: [...place.path, name],
486
499
  schemas: Arr.flatMap(properties, (property) =>
@@ -500,7 +513,7 @@ const branchOf = (place: Place, count: { size: number }): Branch => {
500
513
  plain:
501
514
  place.plain &&
502
515
  !valueKinds.object &&
503
- Arr.every(classProperties, (named) => HashMap.has(named, name)),
516
+ Arr.every(classProperties, (named) => named.has(name)),
504
517
  }
505
518
  return [name, branchOf(below, count)] as const
506
519
  })
@@ -143,15 +143,6 @@ const decodeOperands = (
143
143
  decodeOperand(operand, [...path, name, index]),
144
144
  )
145
145
 
146
- const compareOperators: ReadonlyArray<string> = [
147
- "eq",
148
- "ne",
149
- "lt",
150
- "lte",
151
- "gt",
152
- "gte",
153
- ]
154
-
155
146
  const decodeNode = (
156
147
  input: unknown,
157
148
  path: ReadonlyArray<PropertyKey>,
@@ -199,11 +190,11 @@ const makeNode = (
199
190
  return Nodes.never
200
191
  case "Compare": {
201
192
  const op = readString(input, "op", path)
202
- if (!compareOperators.includes(op)) {
193
+ if (!Nodes.isCompareOperator(op)) {
203
194
  throw problem([...path, "op"], `"${op}" is not a compare operator`)
204
195
  }
205
196
  return new Nodes.Compare({
206
- op: op as Nodes.CompareOperator,
197
+ op,
207
198
  field: readString(input, "field", path),
208
199
  value: decodeOperand(read(input, "value", path), [...path, "value"]),
209
200
  })
@@ -21,12 +21,31 @@ import * as Arr from "effect/Array"
21
21
  import * as Data from "effect/Data"
22
22
  import type * as Effect from "effect/Effect"
23
23
  import { hasProperty } from "effect/Predicate"
24
+ import * as Schema from "effect/Schema"
24
25
  import * as Limits from "./limits.ts"
25
26
 
26
27
  /** A value that a condition can compare. */
27
28
  export type Scalar = string | number | boolean | null
28
29
 
29
- export type CompareOperator = "eq" | "ne" | "lt" | "lte" | "gt" | "gte"
30
+ /** The operators of a `Compare` test. The decoders read this list too. */
31
+ export const CompareOperator = Schema.Literals([
32
+ "eq",
33
+ "ne",
34
+ "lt",
35
+ "lte",
36
+ "gt",
37
+ "gte",
38
+ ])
39
+
40
+ export type CompareOperator = typeof CompareOperator.Type
41
+
42
+ /**
43
+ * Tests if a value is an operator. It reads the list of the Schema with
44
+ * `includes`: for a short list, this is about twice as fast as
45
+ * `Schema.is`, and a decoder tests each node of a large input.
46
+ */
47
+ export const isCompareOperator = (u: unknown): u is CompareOperator =>
48
+ (CompareOperator.literals as ReadonlyArray<unknown>).includes(u)
30
49
 
31
50
  /** A fixed value. */
32
51
  export interface Literal {
@@ -49,15 +68,6 @@ export const literal = (value: Scalar): Literal => operands.Literal({ value })
49
68
  export const principalRef = (path: string): PrincipalRef =>
50
69
  operands.Principal({ path })
51
70
 
52
- const compareOperators: ReadonlyArray<string> = [
53
- "eq",
54
- "ne",
55
- "lt",
56
- "lte",
57
- "gt",
58
- "gte",
59
- ]
60
-
61
71
  /** Tests if a value is a scalar. Any number is one, also `NaN`. */
62
72
  export const isScalar = (value: unknown): value is Scalar =>
63
73
  value === null ||
@@ -211,7 +221,7 @@ export class Compare extends Base {
211
221
  readonly field: string
212
222
  readonly value: Operand
213
223
  }) {
214
- if (!compareOperators.includes(props.op)) {
224
+ if (!isCompareOperator(props.op)) {
215
225
  throw new Error(`"${String(props.op)}" is not a compare operator`)
216
226
  }
217
227
  super(
@@ -117,9 +117,13 @@ type States = ReadonlyArray<number>
117
117
 
118
118
  /** Adds the place after each `**`, since a `**` may match no names. */
119
119
  const close = (pattern: Pattern, states: States): States =>
120
- Arr.dedupe(
121
- Arr.flatMap(states, (state) =>
122
- pattern[state]?._tag === "Globstar" ? [state, state + 1] : [state],
120
+ // A native `Set` keeps the first copy of each place, in order. It is
121
+ // faster than `Arr.dedupe`, and this is on the path of each match.
122
+ Array.from(
123
+ new Set(
124
+ Arr.flatMap(states, (state) =>
125
+ pattern[state]?._tag === "Globstar" ? [state, state + 1] : [state],
126
+ ),
123
127
  ),
124
128
  )
125
129
 
@@ -136,13 +136,6 @@ export const encode = (
136
136
  return toJson(node, (rule) => forms.get(rule))
137
137
  })
138
138
 
139
- const algorithms: ReadonlyArray<string> = [
140
- "denyOverrides",
141
- "permitOverrides",
142
- "firstApplicable",
143
- "lastApplicable",
144
- ]
145
-
146
139
  /** Reads a list of strings. */
147
140
  const readStrings = (
148
141
  input: object,
@@ -374,7 +367,7 @@ const readTree = (
374
367
  switch (tag) {
375
368
  case "Rules": {
376
369
  const algorithm = Json.readString(input, "algorithm", path)
377
- if (!algorithms.includes(algorithm)) {
370
+ if (!PolicyNodes.isAlgorithm(algorithm)) {
378
371
  throw Json.problem(
379
372
  [...path, "algorithm"],
380
373
  `"${algorithm}" is not a combining algorithm`,
@@ -392,7 +385,7 @@ const readTree = (
392
385
  _tag: "Rules",
393
386
  place,
394
387
  size: 1 + found.length,
395
- algorithm: algorithm as PolicyNodes.Algorithm,
388
+ algorithm,
396
389
  rules: found,
397
390
  })
398
391
  }
@@ -11,19 +11,27 @@
11
11
  */
12
12
  import * as Arr from "effect/Array"
13
13
  import * as Data from "effect/Data"
14
+ import * as Schema from "effect/Schema"
14
15
  import * as Rule from "./rule.ts"
15
16
  import * as Limits from "./limits.ts"
16
17
  import * as Nodes from "./nodes.ts"
17
18
 
18
- export type Algorithm =
19
- "denyOverrides" | "permitOverrides" | "firstApplicable" | "lastApplicable"
20
-
21
- const algorithms: ReadonlyArray<string> = [
19
+ /** The combining algorithms of a list of rules. The decoder reads this list too. */
20
+ export const Algorithm = Schema.Literals([
22
21
  "denyOverrides",
23
22
  "permitOverrides",
24
23
  "firstApplicable",
25
24
  "lastApplicable",
26
- ]
25
+ ])
26
+
27
+ export type Algorithm = typeof Algorithm.Type
28
+
29
+ /**
30
+ * Tests if a value is a combining algorithm. It reads the list of the Schema
31
+ * with `includes`, as `Nodes.isCompareOperator` does.
32
+ */
33
+ export const isAlgorithm = (u: unknown): u is Algorithm =>
34
+ (Algorithm.literals as ReadonlyArray<unknown>).includes(u)
27
35
 
28
36
  interface Facts {
29
37
  readonly size: number
@@ -103,7 +111,7 @@ export class Rules extends Base {
103
111
  readonly algorithm: Algorithm
104
112
  readonly rules: ReadonlyArray<Rule.Rule>
105
113
  }) {
106
- if (!algorithms.includes(props.algorithm)) {
114
+ if (!isAlgorithm(props.algorithm)) {
107
115
  throw new Error(
108
116
  `"${String(props.algorithm)}" is not a combining algorithm`,
109
117
  )
@@ -196,12 +196,13 @@ interface Search {
196
196
  */
197
197
  readonly members: Map<Nodes.In, Set<string>>
198
198
  /**
199
- * The texts of the structure of the conditions that the search found to be
200
- * unsatisfiable. A condition with the same structure has the same free
201
- * fields, so its answer is the same. The set only grows, so the steps of a
202
- * search may share it. It is a native `Set`, as in {@link dedupeCells}.
199
+ * The keys of the conditions that the search found to be unsatisfiable:
200
+ * see `Algebra.keyOf`. A condition with the same structure has the same
201
+ * free fields, so its answer is the same. The set only grows, so the steps
202
+ * of a search may share it. It is a native `Set`, as in
203
+ * {@link dedupeCells}.
203
204
  */
204
- readonly failed: Set<string>
205
+ readonly failed: Set<string | Node>
205
206
  readonly budget: number
206
207
  }
207
208
 
@@ -364,10 +365,12 @@ const searchNode = (
364
365
  if (node._tag === "False") {
365
366
  return { assignment: Option.none(), state }
366
367
  }
367
- // The text reads all of the node. The step before counted the size of a
368
- // larger node, so the budget counts this work too.
369
- const text = Algebra.structureText(node)
370
- if (search.failed.has(text)) {
368
+ // The key reads at most a small part of the node: a node with very many
369
+ // nodes, or with very many values in its `In` tests, is its own key. A
370
+ // step reads the nodes but not the values, which `membersOf` keeps, so the
371
+ // budget counts the size.
372
+ const key = Algebra.keyOf(node)
373
+ if (search.failed.has(key)) {
371
374
  return { assignment: Option.none(), state }
372
375
  }
373
376
  if (state.steps + node.size > search.budget) {
@@ -400,7 +403,7 @@ const searchNode = (
400
403
  },
401
404
  )
402
405
  if (Option.isNone(result.assignment)) {
403
- search.failed.add(text)
406
+ search.failed.add(key)
404
407
  }
405
408
  return result
406
409
  }