@smthrs/evals 0.0.0-stage → 1.0.0-rc.3

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 (143) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +62 -2
  4. package/dist/cjs/Baseline.d.ts +118 -0
  5. package/dist/cjs/Baseline.d.ts.map +1 -0
  6. package/dist/cjs/Baseline.js +158 -0
  7. package/dist/cjs/Baseline.js.map +7 -0
  8. package/dist/cjs/CaseExecutor.d.ts +85 -0
  9. package/dist/cjs/CaseExecutor.d.ts.map +1 -0
  10. package/dist/cjs/CaseExecutor.js +59 -0
  11. package/dist/cjs/CaseExecutor.js.map +7 -0
  12. package/dist/cjs/EvalError.d.ts +64 -0
  13. package/dist/cjs/EvalError.d.ts.map +1 -0
  14. package/dist/cjs/EvalError.js +54 -0
  15. package/dist/cjs/EvalError.js.map +7 -0
  16. package/dist/cjs/Gate.d.ts +55 -0
  17. package/dist/cjs/Gate.d.ts.map +1 -0
  18. package/dist/cjs/Gate.js +88 -0
  19. package/dist/cjs/Gate.js.map +7 -0
  20. package/dist/cjs/Regression.d.ts +116 -0
  21. package/dist/cjs/Regression.d.ts.map +1 -0
  22. package/dist/cjs/Regression.js +191 -0
  23. package/dist/cjs/Regression.js.map +7 -0
  24. package/dist/cjs/Report.d.ts +114 -0
  25. package/dist/cjs/Report.d.ts.map +1 -0
  26. package/dist/cjs/Report.js +161 -0
  27. package/dist/cjs/Report.js.map +7 -0
  28. package/dist/cjs/Runner.d.ts +229 -0
  29. package/dist/cjs/Runner.d.ts.map +1 -0
  30. package/dist/cjs/Runner.js +430 -0
  31. package/dist/cjs/Runner.js.map +7 -0
  32. package/dist/cjs/Suite.d.ts +139 -0
  33. package/dist/cjs/Suite.d.ts.map +1 -0
  34. package/dist/cjs/Suite.js +221 -0
  35. package/dist/cjs/Suite.js.map +7 -0
  36. package/dist/cjs/Trials.d.ts +93 -0
  37. package/dist/cjs/Trials.d.ts.map +1 -0
  38. package/dist/cjs/Trials.js +82 -0
  39. package/dist/cjs/Trials.js.map +7 -0
  40. package/dist/cjs/index.d.ts +76 -0
  41. package/dist/cjs/index.d.ts.map +1 -0
  42. package/dist/cjs/index.js +51 -0
  43. package/dist/cjs/index.js.map +7 -0
  44. package/dist/cjs/internal/CanonicalJson.d.ts +44 -0
  45. package/dist/cjs/internal/CanonicalJson.d.ts.map +1 -0
  46. package/dist/cjs/internal/CanonicalJson.js +114 -0
  47. package/dist/cjs/internal/CanonicalJson.js.map +7 -0
  48. package/dist/cjs/internal/controlCharacters.d.ts +44 -0
  49. package/dist/cjs/internal/controlCharacters.d.ts.map +1 -0
  50. package/dist/cjs/internal/controlCharacters.js +42 -0
  51. package/dist/cjs/internal/controlCharacters.js.map +7 -0
  52. package/dist/cjs/internal/scorerLabel.d.ts +33 -0
  53. package/dist/cjs/internal/scorerLabel.d.ts.map +1 -0
  54. package/dist/cjs/internal/scorerLabel.js +28 -0
  55. package/dist/cjs/internal/scorerLabel.js.map +7 -0
  56. package/dist/cjs/internal/tupleKey.d.ts +20 -0
  57. package/dist/cjs/internal/tupleKey.d.ts.map +1 -0
  58. package/dist/cjs/internal/tupleKey.js +25 -0
  59. package/dist/cjs/internal/tupleKey.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/Baseline.d.ts +118 -0
  62. package/dist/esm/Baseline.d.ts.map +1 -0
  63. package/dist/esm/Baseline.js +203 -0
  64. package/dist/esm/Baseline.js.map +1 -0
  65. package/dist/esm/CaseExecutor.d.ts +85 -0
  66. package/dist/esm/CaseExecutor.d.ts.map +1 -0
  67. package/dist/esm/CaseExecutor.js +60 -0
  68. package/dist/esm/CaseExecutor.js.map +1 -0
  69. package/dist/esm/EvalError.d.ts +64 -0
  70. package/dist/esm/EvalError.d.ts.map +1 -0
  71. package/dist/esm/EvalError.js +65 -0
  72. package/dist/esm/EvalError.js.map +1 -0
  73. package/dist/esm/Gate.d.ts +55 -0
  74. package/dist/esm/Gate.d.ts.map +1 -0
  75. package/dist/esm/Gate.js +90 -0
  76. package/dist/esm/Gate.js.map +1 -0
  77. package/dist/esm/Regression.d.ts +116 -0
  78. package/dist/esm/Regression.d.ts.map +1 -0
  79. package/dist/esm/Regression.js +195 -0
  80. package/dist/esm/Regression.js.map +1 -0
  81. package/dist/esm/Report.d.ts +114 -0
  82. package/dist/esm/Report.d.ts.map +1 -0
  83. package/dist/esm/Report.js +188 -0
  84. package/dist/esm/Report.js.map +1 -0
  85. package/dist/esm/Runner.d.ts +229 -0
  86. package/dist/esm/Runner.d.ts.map +1 -0
  87. package/dist/esm/Runner.js +430 -0
  88. package/dist/esm/Runner.js.map +1 -0
  89. package/dist/esm/Suite.d.ts +139 -0
  90. package/dist/esm/Suite.d.ts.map +1 -0
  91. package/dist/esm/Suite.js +255 -0
  92. package/dist/esm/Suite.js.map +1 -0
  93. package/dist/esm/Trials.d.ts +93 -0
  94. package/dist/esm/Trials.d.ts.map +1 -0
  95. package/dist/esm/Trials.js +116 -0
  96. package/dist/esm/Trials.js.map +1 -0
  97. package/dist/esm/index.d.ts +76 -0
  98. package/dist/esm/index.d.ts.map +1 -0
  99. package/dist/esm/index.js +76 -0
  100. package/dist/esm/index.js.map +1 -0
  101. package/dist/esm/internal/CanonicalJson.d.ts +44 -0
  102. package/dist/esm/internal/CanonicalJson.d.ts.map +1 -0
  103. package/dist/esm/internal/CanonicalJson.js +202 -0
  104. package/dist/esm/internal/CanonicalJson.js.map +1 -0
  105. package/dist/esm/internal/controlCharacters.d.ts +44 -0
  106. package/dist/esm/internal/controlCharacters.d.ts.map +1 -0
  107. package/dist/esm/internal/controlCharacters.js +65 -0
  108. package/dist/esm/internal/controlCharacters.js.map +1 -0
  109. package/dist/esm/internal/scorerLabel.d.ts +33 -0
  110. package/dist/esm/internal/scorerLabel.d.ts.map +1 -0
  111. package/dist/esm/internal/scorerLabel.js +33 -0
  112. package/dist/esm/internal/scorerLabel.js.map +1 -0
  113. package/dist/esm/internal/tupleKey.d.ts +20 -0
  114. package/dist/esm/internal/tupleKey.d.ts.map +1 -0
  115. package/dist/esm/internal/tupleKey.js +20 -0
  116. package/dist/esm/internal/tupleKey.js.map +1 -0
  117. package/docs/README.md +130 -0
  118. package/docs/api.md +626 -0
  119. package/docs/concepts/determinism.md +64 -0
  120. package/docs/concepts/fixed-suites.md +71 -0
  121. package/docs/concepts/step-keys.md +60 -0
  122. package/docs/guides/author-a-suite.md +115 -0
  123. package/docs/guides/gate-a-run-in-ci.md +129 -0
  124. package/docs/guides/maintain-a-baseline.md +91 -0
  125. package/docs/guides/run-a-suite.md +150 -0
  126. package/docs/installation.md +46 -0
  127. package/docs/quickstart.md +238 -0
  128. package/docs/troubleshooting.md +148 -0
  129. package/package.json +198 -3
  130. package/src/Baseline.ts +259 -0
  131. package/src/CaseExecutor.ts +104 -0
  132. package/src/EvalError.ts +74 -0
  133. package/src/Gate.ts +128 -0
  134. package/src/Regression.ts +314 -0
  135. package/src/Report.ts +268 -0
  136. package/src/Runner.ts +759 -0
  137. package/src/Suite.ts +358 -0
  138. package/src/Trials.ts +154 -0
  139. package/src/index.ts +76 -0
  140. package/src/internal/CanonicalJson.ts +234 -0
  141. package/src/internal/controlCharacters.ts +66 -0
  142. package/src/internal/scorerLabel.ts +35 -0
  143. package/src/internal/tupleKey.ts +20 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The one canonical JSON encoder both serializers use.
3
+ *
4
+ * A report embeds the raw output of an arbitrary target flow, so the value
5
+ * being serialized is not JSON and cannot be assumed to be finite, acyclic, or
6
+ * even readable. A recursive walker over `JSON.stringify` answered that with a
7
+ * `RangeError` thrown out of a function typed `string`. This encoder is total
8
+ * instead: every shape JSON cannot express is replaced by a marker that names
9
+ * what was there, so a report of a broken run is still a report.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+
14
+ /**
15
+ * The nesting depth beyond which a value is replaced by a marker.
16
+ *
17
+ * @since 0.1.0
18
+ * @private
19
+ */
20
+ const maxDepth = 64
21
+
22
+ /**
23
+ * Default cap on an embedded string, in UTF-16 code units.
24
+ *
25
+ * @since 0.1.0
26
+ * @private
27
+ */
28
+ const maxStringLength = 8192
29
+
30
+ /**
31
+ * Total number of values one encode may visit.
32
+ *
33
+ * The cycle detector forgets a value once it leaves it, which is what keeps a
34
+ * value referenced twice from being reported as a cycle. The cost is that a
35
+ * shared acyclic graph is re-expanded at every reference, so `n` nested
36
+ * two-child wrappers over one shared child expand to `2^n` values while
37
+ * staying far below {@link maxDepth}. The ceiling is roughly three times what
38
+ * a suite at its declared case limit produces.
39
+ *
40
+ * @since 0.1.0
41
+ * @private
42
+ */
43
+ const maxNodes = 2_000_000
44
+
45
+ /**
46
+ * Approximate cap on the encoded output, in UTF-16 code units.
47
+ *
48
+ * @since 0.1.0
49
+ * @private
50
+ */
51
+ const maxBytes = 33_554_432
52
+
53
+ /**
54
+ * Marker left where a total budget ran out.
55
+ *
56
+ * @since 0.1.0
57
+ * @private
58
+ */
59
+ const budgetExceeded = "[budget exceeded]"
60
+
61
+ /**
62
+ * Options accepted by {@link encode}.
63
+ *
64
+ * @since 0.1.0
65
+ * @private
66
+ */
67
+ interface Options {
68
+ /** Cap on embedded strings; `undefined` keeps them whole. */
69
+ readonly maxStringLength?: number | undefined
70
+ /** Cap on values visited; defaults to {@link maxNodes}. */
71
+ readonly maxNodes?: number | undefined
72
+ /** Cap on encoded output length; defaults to {@link maxBytes}. */
73
+ readonly maxBytes?: number | undefined
74
+ }
75
+
76
+ /** Remaining total work, decremented in traversal order so truncation is stable. */
77
+ interface Budget {
78
+ nodes: number
79
+ bytes: number
80
+ }
81
+
82
+ /**
83
+ * Orders keys by UTF-16 code unit, never by locale.
84
+ *
85
+ * @since 0.1.0
86
+ * @private
87
+ */
88
+ const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
89
+
90
+ const truncate = (value: string, limit: number | undefined): string =>
91
+ limit === undefined || value.length <= limit
92
+ ? value
93
+ : `${value.slice(0, limit)}[truncated ${value.length - limit} chars]`
94
+
95
+ const primitive = (value: unknown, options: Options): unknown => {
96
+ if (typeof value === "string") return truncate(value, options.maxStringLength)
97
+ if (typeof value === "number") {
98
+ if (Number.isNaN(value)) return "[NaN]"
99
+ if (value === Number.POSITIVE_INFINITY) return "[Infinity]"
100
+ if (value === Number.NEGATIVE_INFINITY) return "[-Infinity]"
101
+ return Object.is(value, -0) ? 0 : value
102
+ }
103
+ if (typeof value === "bigint") return `[bigint ${value}]`
104
+ if (typeof value === "function") return "[function]"
105
+ if (typeof value === "symbol") return "[symbol]"
106
+ return value
107
+ }
108
+
109
+ const unreadable = (cause: unknown): string => {
110
+ try {
111
+ return `[unreadable: ${String(cause)}]`
112
+ } catch {
113
+ return "[unreadable]"
114
+ }
115
+ }
116
+
117
+ const entriesOf = (value: object): ReadonlyArray<readonly [string, unknown]> =>
118
+ Object.keys(value).map((key) => {
119
+ try {
120
+ return [key, (value as { readonly [key: string]: unknown })[key]] as const
121
+ } catch (cause) {
122
+ return [key, unreadable(cause)] as const
123
+ }
124
+ })
125
+
126
+ const objectOf = (
127
+ entries: ReadonlyArray<readonly [string, unknown]>,
128
+ depth: number,
129
+ seen: Set<object>,
130
+ options: Options,
131
+ budget: Budget
132
+ ): object =>
133
+ Object.fromEntries(
134
+ entries
135
+ .filter(([, entry]) => entry !== undefined)
136
+ .sort(([left], [right]) => compareText(left, right))
137
+ .map(([key, entry]) => {
138
+ // The key is rendered too, so a wide object of long keys spends the
139
+ // byte budget even when every value is a marker.
140
+ budget.bytes -= key.length + 3
141
+ return [key, walk(entry, depth + 1, seen, options, budget)]
142
+ })
143
+ )
144
+
145
+ const walk = (value: unknown, depth: number, seen: Set<object>, options: Options, budget: Budget): unknown => {
146
+ if (budget.nodes <= 0 || budget.bytes <= 0) return budgetExceeded
147
+ budget.nodes -= 1
148
+ if (value === null || typeof value !== "object") {
149
+ const encoded = primitive(value, options)
150
+ budget.bytes -= typeof encoded === "string" ? encoded.length + 2 : 8
151
+ return encoded
152
+ }
153
+ if (seen.has(value)) return "[circular]"
154
+ if (depth > maxDepth) return "[depth exceeded]"
155
+ budget.bytes -= 2
156
+ try {
157
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "[invalid Date]" : value.toISOString()
158
+ seen.add(value)
159
+ try {
160
+ if (value instanceof Error) {
161
+ const properties = new Map(entriesOf(value))
162
+ properties.set("name", String(value.name))
163
+ properties.set("message", String(value.message))
164
+ return objectOf([...properties], depth, seen, options, budget)
165
+ }
166
+ if (Array.isArray(value)) return value.map((entry) => walk(entry, depth + 1, seen, options, budget))
167
+ if (value instanceof Set) return [...value].map((entry) => walk(entry, depth + 1, seen, options, budget))
168
+ if (value instanceof Map) {
169
+ return [...value.entries()]
170
+ .map((
171
+ [key, entry]
172
+ ) => [walk(key, depth + 1, seen, options, budget), walk(entry, depth + 1, seen, options, budget)])
173
+ }
174
+ return objectOf(entriesOf(value), depth, seen, options, budget)
175
+ } finally {
176
+ seen.delete(value)
177
+ }
178
+ } catch (cause) {
179
+ return unreadable(cause)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Rewrites a value into a shape `JSON.stringify` renders deterministically.
185
+ *
186
+ * Object keys are sorted by code unit, `-0` becomes `0`, and `undefined`
187
+ * members are dropped. Everything JSON cannot express becomes a bracketed
188
+ * marker rather than a throw or a silent `null`: `[circular]`,
189
+ * `[depth exceeded]`, `[NaN]`, `[Infinity]`, `[-Infinity]`, `[bigint n]`,
190
+ * `[function]`, `[symbol]`, and `[unreadable: …]` when a foreign operation
191
+ * throws. An `Error` becomes an object containing its own enumerable fields
192
+ * plus `name` and `message`, so typed error fields survive serialization. A
193
+ * `Date` becomes its ISO string, a `Set` an array, and a `Map` an array of
194
+ * key/value pairs.
195
+ *
196
+ * Traversal is bounded in total, not only per branch: once {@link maxNodes}
197
+ * values or {@link maxBytes} output code units are spent, every remaining
198
+ * value becomes `[budget exceeded]`. The budget is spent in traversal order,
199
+ * and traversal order is fixed by the key sort, so one value always truncates
200
+ * at the same place.
201
+ *
202
+ * @since 0.1.0
203
+ * @private
204
+ */
205
+ const encode = (value: unknown, options: Options = {}): unknown =>
206
+ walk(value, 0, new Set<object>(), options, {
207
+ nodes: options.maxNodes ?? maxNodes,
208
+ bytes: options.maxBytes ?? maxBytes
209
+ })
210
+
211
+ /**
212
+ * Serializes a value as canonical JSON with a trailing newline.
213
+ *
214
+ * @since 0.1.0
215
+ * @private
216
+ */
217
+ const stringify = (value: unknown, options: Options = {}): string => `${JSON.stringify(encode(value, options))}\n`
218
+
219
+ /**
220
+ * The canonical JSON encoder: its limits, key order, and serializers.
221
+ *
222
+ * @since 0.1.0
223
+ * @private
224
+ */
225
+ export const CanonicalJson = {
226
+ maxDepth,
227
+ maxStringLength,
228
+ maxNodes,
229
+ maxBytes,
230
+ budgetExceeded,
231
+ compareText,
232
+ encode,
233
+ stringify
234
+ } as const
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The one control-character rule every trust boundary in this package shares.
3
+ *
4
+ * A control character in a name corrupts a Markdown report and a CI log line:
5
+ * a newline lets the value print its own line, which on GitHub Actions is a
6
+ * workflow command the runner executes, an ESC or a C1 CSI (U+009B) sequence
7
+ * rewrites the visible log, and a Unicode bidirectional control reorders what
8
+ * a reader sees. Values an author declares (suite and case names, baseline
9
+ * suites and records) are rejected where they enter the system; values a
10
+ * target returns at runtime (step keys, failure messages) are flattened
11
+ * instead, so a broken target stays a readable failure rather than a rejected
12
+ * run.
13
+ *
14
+ * The rule covers C0 controls (U+0000 through U+001F), DEL (U+007F), C1
15
+ * controls (U+0080 through U+009F), and the bidirectional controls U+061C,
16
+ * U+200E, U+200F, U+202A through U+202E, and U+2066 through U+2069.
17
+ *
18
+ * @since 0.1.0
19
+ */
20
+
21
+ /**
22
+ * Whether one code point falls under the control-character rule.
23
+ *
24
+ * @since 0.1.0
25
+ * @private
26
+ */
27
+ export const isControlCodePoint = (code: number): boolean =>
28
+ code < 0x20 ||
29
+ (code >= 0x7f && code <= 0x9f) ||
30
+ code === 0x061c ||
31
+ code === 0x200e ||
32
+ code === 0x200f ||
33
+ (code >= 0x202a && code <= 0x202e) ||
34
+ (code >= 0x2066 && code <= 0x2069)
35
+
36
+ /**
37
+ * Names the first control character in `value`, or `undefined` when there is
38
+ * none.
39
+ *
40
+ * @since 0.1.0
41
+ * @private
42
+ */
43
+ export const controlCharacter = (value: string): string | undefined => {
44
+ for (const character of value) {
45
+ const code = character.codePointAt(0)!
46
+ if (isControlCodePoint(code)) return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ /**
52
+ * Replaces every control character with a space, the way a report cell does,
53
+ * so a hostile string cannot emit its own log line, terminal escape, or
54
+ * reordered text.
55
+ *
56
+ * @since 0.1.0
57
+ * @private
58
+ */
59
+ export const flattenControlCharacters = (value: string): string => {
60
+ if (controlCharacter(value) === undefined) return value
61
+ let flattened = ""
62
+ for (const character of value) {
63
+ flattened += isControlCodePoint(character.codePointAt(0)!) ? " " : character
64
+ }
65
+ return flattened
66
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The one scorer label rule every rendering in this package shares.
3
+ *
4
+ * A scorer has two identities: `scorer` is the key, a digest of the scorer's
5
+ * own declaration and the only thing a baseline matches on, and `scorerName`
6
+ * is the readable name carried beside it. A digest is unreadable in a report
7
+ * cell, a CI log line, or a diagnostic, so each of them prints the name with
8
+ * the first eight key characters instead. Without a name there is nothing to
9
+ * abbreviate the key down to, so the label is the key itself: a truncated
10
+ * digest and no name would leave a reader nothing to match a baseline record
11
+ * on. One rule is what keeps a report from naming one scorer two ways, an
12
+ * inconclusive reason built by the runner and a Missing row built by the
13
+ * reporter agree.
14
+ *
15
+ * @since 0.1.0
16
+ */
17
+
18
+ /**
19
+ * Renders `name (first 8 of the key)`, or the bare key when the scorer carries
20
+ * no name.
21
+ *
22
+ * Every flow carries a name, so a scorer a run holds always has one. A stored
23
+ * observation may not: a baseline recorded before a scorer was named, or one
24
+ * written by another tool, carries the key alone, and it labels as the key
25
+ * rather than as a blank followed by a digest.
26
+ *
27
+ * @since 0.1.0
28
+ * @private
29
+ */
30
+ export const scorerLabel = (
31
+ identity: { readonly scorer: string; readonly scorerName?: string | undefined }
32
+ ): string => {
33
+ const name = identity.scorerName
34
+ return name === undefined || name.length === 0 ? identity.scorer : `${name} (${identity.scorer.slice(0, 8)})`
35
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The one injective tuple encoder every key in this package is built with.
3
+ *
4
+ * Joining caller-supplied strings on a delimiter is not injective: under a NUL
5
+ * separator the tuples `["a", "b" + NUL + "c"]` and `["a" + NUL + "b", "c"]`
6
+ * produce one key, so two distinct score jobs could share an identity and two
7
+ * distinct baseline records could sort as one. Encoding the tuple as a JSON
8
+ * array keeps the components separated by the encoding itself, whatever they
9
+ * contain.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+
14
+ /**
15
+ * Encodes a tuple of strings as one key.
16
+ *
17
+ * @since 0.1.0
18
+ * @private
19
+ */
20
+ export const tupleKey = (...parts: ReadonlyArray<string>): string => JSON.stringify(parts)