@aethervtt/shard-verify 0.0.0-stage → 0.2.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/dist/run.js ADDED
@@ -0,0 +1,19 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ function addApproval(file, approval) {
3
+ if (!approval.reason.trim()) {
4
+ throw new ShardError(
5
+ "verify/approval-needs-reason",
6
+ `Approving "${approval.shot}" needs a reason`,
7
+ {
8
+ hint: 'Say why it looks the way it does: shard approve <shot> --reason "Shadows are softer since 0058".'
9
+ }
10
+ );
11
+ }
12
+ return {
13
+ version: 1,
14
+ approvals: [...file?.approvals ?? [], { ...approval, reason: approval.reason.trim() }]
15
+ };
16
+ }
17
+ export {
18
+ addApproval
19
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Timestamped samples in a fixed ring: pushing never allocates, so a frame can record into it. The
3
+ * oldest samples fall off once it's full.
4
+ */
5
+ export declare class SampleRing {
6
+ private readonly times;
7
+ private readonly values;
8
+ private next;
9
+ private count;
10
+ constructor(capacity: number);
11
+ get size(): number;
12
+ push(time: number, value: number): void;
13
+ clear(): void;
14
+ /** The values of samples at or after `since`, oldest first. Allocates: not for frames. */
15
+ since(since: number): Float64Array;
16
+ }
17
+ /** Nearest-rank percentile (`p` in 0..1) of `values`, which it sorts in place. 0 when empty. */
18
+ export declare function percentile(values: Float64Array, p: number): number;
19
+ /** p50, p95 and p99 of `values` (sorted in place). */
20
+ export declare function distribution(values: Float64Array): {
21
+ p50: number;
22
+ p95: number;
23
+ p99: number;
24
+ };
25
+ /** Rounds to 0.01: records stay readable, and the rounding is far below any budget. */
26
+ export declare function round(value: number): number;
package/dist/stats.js ADDED
@@ -0,0 +1,56 @@
1
+ class SampleRing {
2
+ times;
3
+ values;
4
+ next = 0;
5
+ count = 0;
6
+ constructor(capacity) {
7
+ this.times = new Float64Array(capacity);
8
+ this.values = new Float64Array(capacity);
9
+ }
10
+ get size() {
11
+ return this.count;
12
+ }
13
+ push(time, value) {
14
+ this.times[this.next] = time;
15
+ this.values[this.next] = value;
16
+ this.next = (this.next + 1) % this.times.length;
17
+ if (this.count < this.times.length) this.count++;
18
+ }
19
+ clear() {
20
+ this.next = 0;
21
+ this.count = 0;
22
+ }
23
+ /** The values of samples at or after `since`, oldest first. Allocates: not for frames. */
24
+ since(since) {
25
+ const capacity = this.times.length;
26
+ const start = (this.next - this.count + capacity) % capacity;
27
+ const out = [];
28
+ for (let i = 0; i < this.count; i++) {
29
+ const at = (start + i) % capacity;
30
+ if (this.times[at] >= since) out.push(this.values[at]);
31
+ }
32
+ return Float64Array.from(out);
33
+ }
34
+ }
35
+ function percentile(values, p) {
36
+ if (values.length === 0) return 0;
37
+ values.sort();
38
+ const rank = Math.max(1, Math.ceil(p * values.length));
39
+ return values[rank - 1];
40
+ }
41
+ function distribution(values) {
42
+ return {
43
+ p50: percentile(values, 0.5),
44
+ p95: percentile(values, 0.95),
45
+ p99: percentile(values, 0.99)
46
+ };
47
+ }
48
+ function round(value) {
49
+ return Math.round(value * 100) / 100;
50
+ }
51
+ export {
52
+ SampleRing,
53
+ distribution,
54
+ percentile,
55
+ round
56
+ };
@@ -0,0 +1,51 @@
1
+ import type { Thresholds } from './plan.js';
2
+ import { type PerfRecord } from './record.js';
3
+ /** One broken budget: the metric, the value, the budget, and the baseline it came from. */
4
+ export interface Breach {
5
+ scenario: string;
6
+ metric: string;
7
+ renderer: string;
8
+ fixture: string;
9
+ /** Undefined when the record has no such metric. */
10
+ value: number | undefined;
11
+ /** The limit that was broken, e.g. `max 16.7` or `max 1.0 × three (12.1) = 12.1`. */
12
+ budget: string;
13
+ baseline?: {
14
+ renderer: string;
15
+ value: number | undefined;
16
+ };
17
+ message: string;
18
+ /** A span budget's span (0074). */
19
+ span?: string;
20
+ /**
21
+ * Against a baseline: the spans whose p95 grew the most from its record to this one (0074), when
22
+ * both records have a breakdown.
23
+ */
24
+ grew?: {
25
+ span: string;
26
+ p95: number;
27
+ baselineP95: number;
28
+ }[];
29
+ }
30
+ export interface PerfCheck {
31
+ pass: boolean;
32
+ /** Rules applied, over every record they applied to. */
33
+ checked: number;
34
+ breaches: Breach[];
35
+ }
36
+ /**
37
+ * Applies a plan's thresholds to the records of one run (0062). Absolute rules apply to every
38
+ * record of the scenario whose renderer isn't a ratio baseline there. A ratio rule compares with
39
+ * the baseline renderer's record of the same scenario and fixture, on the same device where the
40
+ * run has one; with no baseline record, the rule fails.
41
+ */
42
+ export declare function checkThresholds(records: readonly PerfRecord[], thresholds: Thresholds): PerfCheck;
43
+ /**
44
+ * The spans whose p95 grew the most from `baseline` to `record` (0074), largest growth first: a
45
+ * span missing from the baseline's breakdown counts from 0. Empty unless both have a breakdown.
46
+ */
47
+ export declare function spansThatGrew(record: PerfRecord, baseline: PerfRecord, limit?: number): {
48
+ span: string;
49
+ p95: number;
50
+ baselineP95: number;
51
+ }[];
@@ -0,0 +1,156 @@
1
+ import { valueAt } from "./expect.js";
2
+ import { rendererName } from "./record.js";
3
+ function checkThresholds(records, thresholds) {
4
+ const breaches = [];
5
+ let checked = 0;
6
+ for (const [scenario, rules] of Object.entries(thresholds)) {
7
+ const metrics = [];
8
+ for (const [metric, rule] of Object.entries(rules)) {
9
+ if (metric !== "spans" && rule && !Array.isArray(rule))
10
+ metrics.push([metric, rule]);
11
+ }
12
+ const baselines = new Set(
13
+ metrics.map(([, r]) => r.maxRatioTo).filter((r) => r !== void 0)
14
+ );
15
+ const subjects = records.filter(
16
+ (r) => r.scenario === scenario && !baselines.has(rendererName(r))
17
+ );
18
+ for (const record of subjects) {
19
+ for (const [metric, rule] of metrics) {
20
+ checked++;
21
+ const breach = apply(record, metric, rule, records);
22
+ if (breach) breaches.push(breach);
23
+ }
24
+ for (const rule of rules.spans ?? []) {
25
+ checked++;
26
+ const breach = applySpan(record, rule);
27
+ if (breach) breaches.push(breach);
28
+ }
29
+ }
30
+ }
31
+ return { pass: breaches.length === 0, checked, breaches };
32
+ }
33
+ function metricOf(record, metric) {
34
+ const value = valueAt(record, metric);
35
+ return typeof value === "number" ? value : void 0;
36
+ }
37
+ function apply(record, metric, rule, records) {
38
+ const value = metricOf(record, metric);
39
+ const base = {
40
+ scenario: record.scenario,
41
+ metric,
42
+ renderer: record.renderer,
43
+ fixture: record.fixture,
44
+ value
45
+ };
46
+ const where = `${record.scenario} ${metric} (${record.renderer})`;
47
+ if (value === void 0) {
48
+ return { ...base, budget: describe(rule), message: `${where}: the record has no ${metric}` };
49
+ }
50
+ if (rule.max !== void 0 && value > rule.max) {
51
+ return {
52
+ ...base,
53
+ budget: `max ${rule.max}`,
54
+ message: `${where}: ${value} is over the max of ${rule.max}`
55
+ };
56
+ }
57
+ if (rule.min !== void 0 && value < rule.min) {
58
+ return {
59
+ ...base,
60
+ budget: `min ${rule.min}`,
61
+ message: `${where}: ${value} is under the min of ${rule.min}`
62
+ };
63
+ }
64
+ if (rule.maxRatioTo === void 0) return void 0;
65
+ const ratio = rule.ratio ?? 1;
66
+ const baseline = findBaseline(record, rule.maxRatioTo, records);
67
+ if (!baseline) {
68
+ return {
69
+ ...base,
70
+ budget: `max ${ratio} \xD7 ${rule.maxRatioTo}`,
71
+ message: `${where}: no ${rule.maxRatioTo} record of ${record.scenario} on ${record.fixture} to compare with`
72
+ };
73
+ }
74
+ const baseValue = metricOf(baseline, metric);
75
+ const limit = baseValue === void 0 ? void 0 : baseValue * ratio;
76
+ if (limit !== void 0 && value <= limit) return void 0;
77
+ const grew = spansThatGrew(record, baseline);
78
+ const breach = {
79
+ ...base,
80
+ budget: `max ${ratio} \xD7 ${baseline.renderer} (${baseValue ?? "none"})${limit === void 0 ? "" : ` = ${round(limit)}`}`,
81
+ baseline: { renderer: baseline.renderer, value: baseValue },
82
+ message: limit === void 0 ? `${where}: ${baseline.renderer}'s record has no ${metric}` : `${where}: ${value} is over ${ratio} \xD7 ${baseline.renderer}'s ${baseValue} (${round(limit)})${grew.length > 0 ? `; grew most: ${grew.map((g) => `${g.span} ${g.baselineP95} \u2192 ${g.p95} ms`).join(", ")}` : ""}`
83
+ };
84
+ if (grew.length > 0) breach.grew = grew;
85
+ return breach;
86
+ }
87
+ function spansOf(record) {
88
+ return record.breakdown ? [...record.breakdown.cpu, ...record.breakdown.gpu] : [];
89
+ }
90
+ function spansThatGrew(record, baseline, limit = 3) {
91
+ if (!record.breakdown || !baseline.breakdown) return [];
92
+ const before = new Map(spansOf(baseline).map((s) => [s.span, s.p95]));
93
+ return spansOf(record).map((s) => ({ span: s.span, p95: s.p95, baselineP95: before.get(s.span) ?? 0 })).filter((g) => g.p95 > g.baselineP95).sort((a, b) => b.p95 - b.baselineP95 - (a.p95 - a.baselineP95) || (a.span < b.span ? -1 : 1)).slice(0, limit);
94
+ }
95
+ function applySpan(record, rule) {
96
+ const stats = spansOf(record).find((s) => s.span === rule.span);
97
+ const where = `${record.scenario} span ${rule.span} (${record.renderer})`;
98
+ const base = {
99
+ scenario: record.scenario,
100
+ renderer: record.renderer,
101
+ fixture: record.fixture,
102
+ span: rule.span
103
+ };
104
+ if (!stats) {
105
+ const metric = `span:${rule.span}`;
106
+ return {
107
+ ...base,
108
+ metric,
109
+ value: void 0,
110
+ budget: describeSpan(rule),
111
+ message: record.breakdown ? `${where}: the record's breakdown has no ${rule.span}` : `${where}: the record has no breakdown (version 1)`
112
+ };
113
+ }
114
+ for (const key of ["p50", "p95", "max"]) {
115
+ const limit = rule[key];
116
+ if (limit === void 0 || stats[key] <= limit) continue;
117
+ return {
118
+ ...base,
119
+ metric: `span:${rule.span}.${key}`,
120
+ value: stats[key],
121
+ budget: `${key} ${limit}`,
122
+ message: `${where}: ${key} ${stats[key]} ms is over ${limit} ms`
123
+ };
124
+ }
125
+ return void 0;
126
+ }
127
+ function describeSpan(rule) {
128
+ const parts = [];
129
+ for (const key of ["p50", "p95", "max"]) {
130
+ if (rule[key] !== void 0) parts.push(`${key} ${rule[key]}`);
131
+ }
132
+ return parts.join(", ");
133
+ }
134
+ function findBaseline(record, renderer, records) {
135
+ const candidates = records.filter(
136
+ (r) => rendererName(r) === renderer && r.scenario === record.scenario && r.fixture === record.fixture
137
+ );
138
+ const sameDevice = candidates.find(
139
+ (r) => r.device.ua === record.device.ua && r.device.dpr === record.device.dpr && r.device.viewport[0] === record.device.viewport[0] && r.device.viewport[1] === record.device.viewport[1]
140
+ );
141
+ return sameDevice ?? candidates[0];
142
+ }
143
+ function describe(rule) {
144
+ const parts = [];
145
+ if (rule.max !== void 0) parts.push(`max ${rule.max}`);
146
+ if (rule.min !== void 0) parts.push(`min ${rule.min}`);
147
+ if (rule.maxRatioTo !== void 0) parts.push(`max ${rule.ratio ?? 1} \xD7 ${rule.maxRatioTo}`);
148
+ return parts.join(", ");
149
+ }
150
+ function round(value) {
151
+ return Math.round(value * 100) / 100;
152
+ }
153
+ export {
154
+ checkThresholds,
155
+ spansThatGrew
156
+ };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-verify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./metrics": {
13
+ "types": "./dist/metrics/index.d.ts",
14
+ "default": "./dist/metrics/index.js"
15
+ },
16
+ "./page": {
17
+ "types": "./dist/page.d.ts",
18
+ "default": "./dist/page.js"
19
+ },
20
+ "./node": {
21
+ "types": "./dist/node/index.d.ts",
22
+ "default": "./dist/node/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "dependencies": {
31
+ "@aethervtt/shard-assets": "0.2.0",
32
+ "@aethervtt/shard-core": "0.2.0",
33
+ "@aethervtt/shard-gpu": "0.2.0",
34
+ "@aethervtt/shard-platform": "0.2.0",
35
+ "@aethervtt/shard-render": "0.2.0",
36
+ "@aethervtt/shard-runtime": "0.2.0",
37
+ "@aethervtt/shard-texture": "0.2.0"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/aethervtt/shard.git",
42
+ "directory": "packages/verify"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "provenance": true
47
+ },
48
+ "shard": {
49
+ "buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
50
+ }
51
+ }