browser-broker 0.1.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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { StartupRefusal } from "../errors.js";
4
+ import { profileDirectory } from "./discovery.js";
5
+ import { DEFAULT_BROWSER_IDS } from "./driver.js";
6
+ /** The refusals this module raises, spelled as §7.2 spells them. */
7
+ export const SETUP_RULES = {
8
+ profileNeverDestroyed: 'setup.profile_never_destroyed',
9
+ };
10
+ /**
11
+ * The lock a running browser leaves in its profile directory.
12
+ *
13
+ * ⚠️ **This is checked as evidence, never as a gate, and the difference is
14
+ * measured.** The single-instance lock a POSIX system leaves behind **does
15
+ * not exist on Windows**, so a cross-platform check looking for it does not
16
+ * report *no lock* there — it **always passes**. A guard that cannot fail on
17
+ * one platform is worse than no guard, because it is trusted equally on both.
18
+ *
19
+ * So this is used only in the direction where a positive result is
20
+ * meaningful: **finding one means a browser is very likely running**, which
21
+ * is worth naming in a refusal. Not finding one means **nothing at all**, and
22
+ * this module never concludes a profile is free from its absence. What
23
+ * actually establishes whether a browser is running is the verified discovery
24
+ * record (§1.2c), which works on every platform.
25
+ */
26
+ const LOCK_ENTRIES = ['SingletonLock', 'SingletonSocket'];
27
+ /**
28
+ * Whether another process appears to hold this profile.
29
+ *
30
+ * Returns `false` on every platform where the mechanism does not exist, which
31
+ * is why the caller must not read `false` as *the profile is free*. See
32
+ * {@link LOCK_ENTRIES}.
33
+ */
34
+ export function profileLockLooksHeld(profileDir) {
35
+ for (const entry of LOCK_ENTRIES) {
36
+ try {
37
+ // `lstat`, not `stat`: the lock is a symbolic link whose target host
38
+ // and process need not exist, and following it would report absent for
39
+ // a lock that is present.
40
+ fs.lstatSync(path.join(profileDir, entry));
41
+ return true;
42
+ }
43
+ catch {
44
+ // Absent, or a platform without the mechanism. Both mean "no evidence",
45
+ // which is not the same as "no lock".
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ const REAL_FILESYSTEM = {
51
+ mkdirSync: (dir, options) => {
52
+ fs.mkdirSync(dir, options);
53
+ },
54
+ writeFileSync: (file, data) => {
55
+ fs.writeFileSync(file, data);
56
+ },
57
+ rmSync: (file, options) => {
58
+ fs.rmSync(file, options);
59
+ },
60
+ };
61
+ function assertProfileRootWritable(profileRoot, filesystem = REAL_FILESYSTEM) {
62
+ try {
63
+ filesystem.mkdirSync(profileRoot, { recursive: true });
64
+ }
65
+ catch (error) {
66
+ throw new StartupRefusal(SETUP_RULES.profileNeverDestroyed, `The profile root could not be created, so no browser profile can be established under it: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
67
+ }
68
+ const probe = path.join(profileRoot, `.write-probe-${String(process.pid)}`);
69
+ try {
70
+ filesystem.writeFileSync(probe, '');
71
+ filesystem.rmSync(probe, { force: true });
72
+ }
73
+ catch (error) {
74
+ throw new StartupRefusal(SETUP_RULES.profileNeverDestroyed, `The profile root is not writable, so a profile that is absent could not be created: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
75
+ }
76
+ }
77
+ /**
78
+ * Establish one profile: create it if absent, use it as it is if present.
79
+ *
80
+ * **Never recreated, never cleared** — the whole of §1.2d's rule, and the
81
+ * reason the two branches differ only in whether a directory is made.
82
+ */
83
+ function establishProfile(profileRoot, browser) {
84
+ const directory = profileDirectory(profileRoot, browser);
85
+ let present;
86
+ try {
87
+ present = fs.statSync(directory).isDirectory();
88
+ }
89
+ catch {
90
+ present = false;
91
+ }
92
+ if (present) {
93
+ if (profileLockLooksHeld(directory)) {
94
+ // Named in plain words rather than reported as a generic launch
95
+ // failure, because this is exactly the case the design protects
96
+ // against and a caller reading it needs to know it is not a bug.
97
+ throw new StartupRefusal(SETUP_RULES.profileNeverDestroyed, `The ${browser} browser's profile directory exists and another process holds its lock. Setup will not recreate or clear it: that profile holds a sign-in a person established by hand, and recreating it would sign them out. Close the other browser running against this profile, or attach to it instead of starting a second one.`);
98
+ }
99
+ // Found and used exactly as it is. No inspection of its contents, no
100
+ // repair, no clearing — there is deliberately no branch here that could
101
+ // decide a profile looks wrong.
102
+ return { browser, disposition: 'found', relativePath: browser };
103
+ }
104
+ fs.mkdirSync(directory, { recursive: true });
105
+ return { browser, disposition: 'created', relativePath: browser };
106
+ }
107
+ /**
108
+ * Run the handshake: confirm the two browser rows, and establish both
109
+ * profiles.
110
+ *
111
+ * The schema has already been stepped by the time this runs — `prepareStore`
112
+ * does it, and `startup.schema_stepped` (§7.2) puts it before anything else
113
+ * happens. This confirms the result rather than repeating it.
114
+ */
115
+ export async function runSetupHandshake(store, profileRoot, options = {}) {
116
+ assertProfileRootWritable(profileRoot, options.filesystem);
117
+ // The configured browsers, or the ones the default configuration names.
118
+ // **The default is a fallback for a caller that has no environment
119
+ // snapshot to hand, never a statement about what this installation has** —
120
+ // every shipped caller passes the configured lists.
121
+ const browsers = options.browsers ?? DEFAULT_BROWSER_IDS;
122
+ const schemaVersion = await store.immediate(({ db }) => {
123
+ // The version is stamped in the store's own header rather than in a
124
+ // table, so it is read the way the stepper writes it.
125
+ const version = db.pragma('user_version', { simple: true });
126
+ // **The row count is deliberately not asserted, and that is a decision
127
+ // rather than an omission** (`DECISIONS.md` §13i). A browser's row is
128
+ // created when it is first launched, not from configuration at startup,
129
+ // so a store holding fewer rows than this process has browsers
130
+ // configured is the ordinary state of a fresh installation rather than a
131
+ // fault — and two processes may hold different configurations, so no
132
+ // count is the right count for all of them.
133
+ //
134
+ // What that check was actually protecting — *is this store stepped* — is
135
+ // read directly from the version the stepper stamps, which is the fact
136
+ // it was standing in for.
137
+ return { value: typeof version === 'number' ? version : 0 };
138
+ });
139
+ // Outside the transaction, deliberately. Creating a directory is filesystem
140
+ // work and the arbitration transaction serialises every writer on the
141
+ // machine — holding it open across a filesystem call on a slow or contended
142
+ // volume would block every other caller for that duration.
143
+ const profiles = browsers.map((browser) => establishProfile(profileRoot, browser));
144
+ return {
145
+ profiles,
146
+ browserRows: [...browsers],
147
+ schemaVersion,
148
+ };
149
+ }
150
+ /**
151
+ * The report as a line per profile, saying which it created against which it
152
+ * found.
153
+ *
154
+ * §1.2d asks setup to *report which profiles it created and which it found*,
155
+ * and the distinction is the useful part: a profile reported as created on a
156
+ * machine where somebody expected a sign-in is the earliest possible warning
157
+ * that they are about to be asked to sign in again.
158
+ */
159
+ export function describeSetupReport(report) {
160
+ return report.profiles.map((profile) => `${profile.browser}: profile ${profile.disposition} at ${profile.relativePath} (under the configured profile root)`);
161
+ }
@@ -0,0 +1,59 @@
1
+ import { CAPTURES_BEFORE_WARNING } from "./tiers.js";
2
+ /**
3
+ * Capture accounting per claim: **a loud warning, never a refusal**
4
+ * (`MILESTONES.md` #33, `SCHEMA.md` §3.11, §6.2, §7.3).
5
+ *
6
+ * ── The two properties, and why each is written the way it is ───────────
7
+ *
8
+ * **1. It never refuses.** There is no code path in this file that can
9
+ * produce a refusal, and that is structural rather than careful: the only
10
+ * thing exported is a function returning a **string or nothing**. There is no
11
+ * boolean anything downstream could branch on to deny, no threshold parameter
12
+ * an operator could raise into a wall, and no error type. `SCHEMA.md` §6.2
13
+ * deletes the setting outright — *"No 'refuse captures after N'. Nothing is
14
+ * ever refused on capture grounds, and a value that could turn a warning into
15
+ * a wall would make that promise conditional."* And §7.3's
16
+ * `capture.never_refused_for_cost` is the build rule asserting that absence.
17
+ *
18
+ * The reasoning, from `DECISIONS.md` §13d, is worth carrying rather than
19
+ * citing: **an agent stopped mid-run on a legitimate job concludes the service
20
+ * is an obstacle and starts looking for a way around it.** A service that is
21
+ * occasionally expensive survives that; a service that is occasionally
22
+ * *unusable* does not.
23
+ *
24
+ * **2. The message names the cheaper alternative, and that is the mechanism
25
+ * rather than decoration.** `MILESTONES.md` #33: a bare *"you have taken a lot
26
+ * of captures"* teaches a caller to ask for a bigger budget. A warning that
27
+ * **names the snapshot or the evaluation answering the same question** teaches
28
+ * the thing the policy exists to teach. So the text below is the row, and a
29
+ * change that shortened it into a bare count would pass every test that only
30
+ * checked a warning was present — which is why the test for this names the
31
+ * alternative it must mention.
32
+ *
33
+ * **3. It fires on every capture past the threshold, not only the first**
34
+ * (§3.11), *"because a warning that appears once has scrolled away by the time
35
+ * it matters."*
36
+ */
37
+ /**
38
+ * The warning for a capture that is past the threshold, or nothing.
39
+ *
40
+ * @param capturesTakenBefore how many captures this lease had already taken
41
+ * when this one was requested. Counted before rather than after, so the
42
+ * caller of this function does not have to know whether the current capture
43
+ * is included — an ambiguity that would put the boundary one either side of
44
+ * where §6.2 puts it depending on who read it.
45
+ *
46
+ * @returns the warning text, or `undefined` when there is nothing to say.
47
+ * **Never a refusal, and there is no shape here that could become one.**
48
+ */
49
+ export function captureWarning(capturesTakenBefore) {
50
+ const thisCapture = capturesTakenBefore + 1;
51
+ if (thisCapture <= CAPTURES_BEFORE_WARNING)
52
+ return undefined;
53
+ return (`This lease has now taken ${String(thisCapture)} captures, past the point where a picture is ` +
54
+ `usually the expensive way to get an answer. This capture was served and always will be — ` +
55
+ `nothing here is ever refused on cost grounds. But if you are reading a value or checking ` +
56
+ `that an element is present, a snapshot or an evaluation returns it as text for a fraction ` +
57
+ `of what an image costs, because an image is re-read on every later turn of the ` +
58
+ `conversation and text is not.`);
59
+ }
@@ -0,0 +1,112 @@
1
+ // The one place an image library is reached, for the same reason `open.ts` is
2
+ // the one place the database driver is. Decoding and re-encoding a PNG is the
3
+ // only image work this service does, and keeping it behind one module is what
4
+ // makes the choice reversible without touching the pipeline.
5
+ import { PNG } from 'pngjs';
6
+ /** Decode an encoded PNG into pixels. */
7
+ export function decodePng(encoded) {
8
+ const png = PNG.sync.read(Buffer.from(encoded));
9
+ return { width: png.width, height: png.height, pixels: new Uint8Array(png.data) };
10
+ }
11
+ /** Encode pixels back into a PNG. */
12
+ export function encodePng(image) {
13
+ const png = new PNG({ width: image.width, height: image.height });
14
+ png.data = Buffer.from(image.pixels);
15
+ return new Uint8Array(PNG.sync.write(png));
16
+ }
17
+ /**
18
+ * What a picture becomes when its long edge is capped.
19
+ *
20
+ * **The aspect ratio is preserved and the short edge is never rounded to
21
+ * zero.** A one-pixel-tall banner shrunk by a large factor would otherwise
22
+ * produce a zero-height image, which is not an image; the schema's
23
+ * `CHECK (height > 0)` would refuse the row, far away from the cause.
24
+ */
25
+ export function scaledDimensions(width, height, longestEdge) {
26
+ const longest = Math.max(width, height);
27
+ // **Never upscale.** A picture smaller than the rung is written as it is,
28
+ // which is what makes `captures.width === captures.source_width` mean
29
+ // "nothing was shrunk" (§1.7) rather than "nothing needed to be".
30
+ if (longest <= longestEdge)
31
+ return { width, height };
32
+ const factor = longestEdge / longest;
33
+ return {
34
+ width: Math.max(1, Math.round(width * factor)),
35
+ height: Math.max(1, Math.round(height * factor)),
36
+ };
37
+ }
38
+ /**
39
+ * Shrink a picture to a target size by averaging each destination pixel over
40
+ * the source region it covers.
41
+ *
42
+ * A box filter rather than nearest-neighbour sampling, and the difference
43
+ * matters for what this service is for: nearest-neighbour throws away every
44
+ * source pixel but one, so a one-pixel rule or a thin line lands on a sampled
45
+ * pixel or vanishes entirely depending on where it happened to fall.
46
+ * Averaging keeps it as a fainter line, which is what a caller judging layout
47
+ * needs to still be able to see. It is also what makes the later diff's
48
+ * "smallest change reported" bound (§6.2) meaningful rather than a lottery.
49
+ *
50
+ * **Returns the same image when nothing needs shrinking**, so a caller cannot
51
+ * tell "already small enough" from "shrunk by a factor of one" by looking at
52
+ * the pixels — they are the same picture either way, which is the honest
53
+ * answer.
54
+ */
55
+ export function downscale(image, longestEdge) {
56
+ const target = scaledDimensions(image.width, image.height, longestEdge);
57
+ if (target.width === image.width && target.height === image.height)
58
+ return image;
59
+ const pixels = new Uint8Array(target.width * target.height * 4);
60
+ const xRatio = image.width / target.width;
61
+ const yRatio = image.height / target.height;
62
+ for (let y = 0; y < target.height; y++) {
63
+ // The source rows this destination row covers. `Math.ceil` on the end and
64
+ // a floor on the start means adjacent destination pixels tile the source
65
+ // without gaps, so no source pixel is skipped by rounding.
66
+ const yStart = Math.floor(y * yRatio);
67
+ const yEnd = Math.min(image.height, Math.max(yStart + 1, Math.ceil((y + 1) * yRatio)));
68
+ for (let x = 0; x < target.width; x++) {
69
+ const xStart = Math.floor(x * xRatio);
70
+ const xEnd = Math.min(image.width, Math.max(xStart + 1, Math.ceil((x + 1) * xRatio)));
71
+ let red = 0;
72
+ let green = 0;
73
+ let blue = 0;
74
+ let alpha = 0;
75
+ let counted = 0;
76
+ for (let sourceY = yStart; sourceY < yEnd; sourceY++) {
77
+ for (let sourceX = xStart; sourceX < xEnd; sourceX++) {
78
+ const at = (sourceY * image.width + sourceX) * 4;
79
+ red += image.pixels[at] ?? 0;
80
+ green += image.pixels[at + 1] ?? 0;
81
+ blue += image.pixels[at + 2] ?? 0;
82
+ alpha += image.pixels[at + 3] ?? 0;
83
+ counted++;
84
+ }
85
+ }
86
+ const to = (y * target.width + x) * 4;
87
+ pixels[to] = Math.round(red / counted);
88
+ pixels[to + 1] = Math.round(green / counted);
89
+ pixels[to + 2] = Math.round(blue / counted);
90
+ pixels[to + 3] = Math.round(alpha / counted);
91
+ }
92
+ }
93
+ return { width: target.width, height: target.height, pixels };
94
+ }
95
+ /**
96
+ * A solid picture of a given size, encoded.
97
+ *
98
+ * Used by the fake driver so that a test's canned capture is something the
99
+ * pipeline can genuinely decode, and by tests that need a picture of known
100
+ * dimensions. It lives here rather than in the fake because the fake must not
101
+ * be the only thing that knows how to make one.
102
+ */
103
+ export function solidPng(width, height, colour = [255, 255, 255, 255]) {
104
+ const pixels = new Uint8Array(width * height * 4);
105
+ for (let at = 0; at < pixels.length; at += 4) {
106
+ pixels[at] = colour[0];
107
+ pixels[at + 1] = colour[1];
108
+ pixels[at + 2] = colour[2];
109
+ pixels[at + 3] = colour[3];
110
+ }
111
+ return encodePng({ width, height, pixels });
112
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The resolution-ladder harness (`MILESTONES.md` #34).
3
+ *
4
+ * Sweeps one picture down a ladder of longest-edge caps and records, per rung,
5
+ * what the study needs to compare rungs against each other: **the dimensions,
6
+ * the bytes, the downscale ratio, and the estimated token cost.**
7
+ *
8
+ * ── It measures the pipeline that ships, not a better one ───────────────
9
+ *
10
+ * Every rung here goes through {@link downscale} and {@link encodePng} — the
11
+ * same box filter and the same encoder the capture pipeline uses. That is the
12
+ * point rather than an economy: `image.ts` names its resampler's cost outright
13
+ * (box averaging is softer than a bicubic or Lanczos filter), and a study run
14
+ * on a hypothetical better resampler would settle rungs this service does not
15
+ * have. **The numbers this produces are only claims about this pipeline.**
16
+ *
17
+ * ── Where the fields come from ──────────────────────────────────────────
18
+ *
19
+ * `bytes` is the length of the re-encoded PNG, which is what `captures.bytes`
20
+ * records (§1.7) and therefore comparable with a real capture's telemetry.
21
+ * `estimatedTokens` comes from {@link estimateTokens} — the formula §6.4 fixes
22
+ * by the version, taking dimensions and nothing else, so a rung's cost here is
23
+ * computed exactly as a live capture's is.
24
+ *
25
+ * **This harness reaches no store and no browser.** It takes decoded pixels and
26
+ * returns numbers, which is what lets the study run its arithmetic rungs in
27
+ * continuous integration with no browser binary while the rendered-text rungs
28
+ * skip.
29
+ */
30
+ import { downscale, encodePng, scaledDimensions } from "./image.js";
31
+ import { estimateTokens } from "./tiers.js";
32
+ /**
33
+ * Sweep one picture down a ladder of caps.
34
+ *
35
+ * The rungs are given rather than read from {@link TIER_LONGEST_EDGE}, because
36
+ * a study that could only measure the three numbers already shipped could never
37
+ * discover that a fourth is better. The shipped rungs are one input among
38
+ * several.
39
+ */
40
+ export function sweepLadder(source, rungs) {
41
+ return rungs.map((longestEdge) => {
42
+ const shrunk = downscale(source, longestEdge);
43
+ const target = scaledDimensions(source.width, source.height, longestEdge);
44
+ const longest = Math.max(source.width, source.height);
45
+ return {
46
+ longestEdge,
47
+ width: shrunk.width,
48
+ height: shrunk.height,
49
+ bytes: encodePng(shrunk).length,
50
+ // Guarded against a zero-size source, which is not an image but is also
51
+ // not this function's business to refuse.
52
+ ratio: longest === 0 ? 1 : Math.min(1, target.width / Math.max(1, source.width)),
53
+ estimatedTokens: estimateTokens(shrunk.width, shrunk.height),
54
+ image: shrunk,
55
+ };
56
+ });
57
+ }
58
+ /**
59
+ * Render a sweep as a table, for the study's own report.
60
+ *
61
+ * Plain text rather than anything structured: this exists so a measurement can
62
+ * be **read** in a pull request body and checked against the assertions that
63
+ * quote it, which is the whole difference between publishing evidence and
64
+ * publishing numbers.
65
+ */
66
+ export function formatLadder(rungs) {
67
+ const header = '| long edge | dimensions | ratio | bytes | est. tokens |';
68
+ const rule = '|---|---|---|---|---|';
69
+ const rows = rungs.map((rung) => `| ${String(rung.longestEdge)} | ${String(rung.width)}x${String(rung.height)} | ` +
70
+ `${rung.ratio.toFixed(3)} | ${String(rung.bytes)} | ${String(rung.estimatedTokens)} |`);
71
+ return [header, rule, ...rows].join('\n');
72
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Objective, OCR-free proxies for what survives a downscale — the instruments
3
+ * the resolution study (`MILESTONES.md` #34) measures the rungs with.
4
+ *
5
+ * ── Why proxies, and what they are honestly worth ───────────────────────
6
+ *
7
+ * The claim the tiers rest on is a claim about **a reader**: *text legibility
8
+ * breaks at a higher resolution than layout critique does.* Settling that
9
+ * directly would mean asking somebody whether they can read a picture, at
10
+ * every rung, on every page — a judgement no test can make and no assertion
11
+ * can hold.
12
+ *
13
+ * So this module does not attempt it. It measures **physical properties of the
14
+ * pixels** that legibility depends on, and it is deliberate that each one is
15
+ * arithmetic over an array rather than an opinion:
16
+ *
17
+ * - {@link strokeContrast} — how much of a glyph's ink survives. A stroke
18
+ * thinner than a destination pixel is averaged into its background by the
19
+ * box filter, and what is left is a fainter mark on a lighter field. This
20
+ * reports how far the darkest ink moved toward the page.
21
+ * - {@link featureSurvives} — whether a **known** one-pixel feature is still
22
+ * distinguishable from the field around it at all. This is the layout-side
23
+ * instrument: a hairline rule, a border, a focus ring.
24
+ * - {@link countDistinctRuns} — how many separate dark runs remain along a
25
+ * line. Two glyphs that merge into one blob have lost the gap between them,
26
+ * and a merged gap is the mechanism by which text stops being readable
27
+ * before it stops being visible.
28
+ *
29
+ * ── ⚠️ WHAT THESE DO NOT ESTABLISH — read before quoting a number ───────
30
+ *
31
+ * **None of these is a legibility measurement, and no number here should be
32
+ * reported as one.** A proxy presented as an answer is worse than an honest
33
+ * gap. Specifically:
34
+ *
35
+ * 1. **A surviving stroke is not a readable glyph.** {@link strokeContrast}
36
+ * can report healthy contrast on a letter whose distinguishing feature —
37
+ * the gap that separates an `e` from a `c` — has closed. Contrast is
38
+ * necessary for legibility and nowhere near sufficient.
39
+ * 2. **These say nothing about a model's vision.** What an agent looking at a
40
+ * picture can actually resolve is a property of that model, not of the
41
+ * pixels. These measure what the *pipeline* destroys, which bounds what any
42
+ * reader could recover but does not predict what one will.
43
+ * 3. **They are measured on synthetic marks of known geometry**, not on
44
+ * rendered prose. That is what makes them arithmetic rather than opinion,
45
+ * and it is also why they cannot speak to font hinting, subpixel rendering
46
+ * or the shapes of real letterforms.
47
+ *
48
+ * They are, within those limits, enough to answer the *comparative* question
49
+ * the study asks — whether the resolution at which fine text detail is
50
+ * destroyed is higher than the one at which a layout-scale feature is — because
51
+ * both sides are measured with the same instrument on the same ladder.
52
+ *
53
+ * ── This is orthogonal to the differ's tolerance ────────────────────────
54
+ *
55
+ * A separate measured limit exists in this repository: at the default
56
+ * tolerance a change fainter than roughly grey 225 on white is not reported.
57
+ * **That is a property of the comparison, not of resolution** — it says what
58
+ * the differ can see, and this module says what the downscale leaves behind.
59
+ * They are different questions and their numbers do not combine.
60
+ */
61
+ /**
62
+ * Whether a row is actually on the picture.
63
+ *
64
+ * **A row off the end must refuse rather than read as blank.** A scan over a
65
+ * row that does not exist finds no ink, and "no ink" is indistinguishable from
66
+ * "the mark was averaged away" — so an off-by-one in a caller's row arithmetic
67
+ * would report a *destroyed feature* instead of a mistake, which is the study
68
+ * measuring nothing and saying so confidently. This was a real defect here: a
69
+ * shrink that moved a mark off the sampled row reported full contrast from an
70
+ * empty scan.
71
+ */
72
+ function requireRow(image, row) {
73
+ if (!Number.isInteger(row) || row < 0 || row >= image.height) {
74
+ throw new RangeError(`row ${String(row)} is not on a picture ${String(image.width)}x${String(image.height)}: an off-picture row finds no ink, which would be misread as a destroyed feature`);
75
+ }
76
+ }
77
+ /** Luminance of one pixel, 0 (black) to 255 (white). */
78
+ function luminanceAt(image, x, y) {
79
+ const at = (y * image.width + x) * 4;
80
+ const red = image.pixels[at] ?? 0;
81
+ const green = image.pixels[at + 1] ?? 0;
82
+ const blue = image.pixels[at + 2] ?? 0;
83
+ // Rec. 601 luma. The weights matter for coloured text; for the black-on-white
84
+ // marks this study uses, any reasonable weighting gives the same answer.
85
+ return 0.299 * red + 0.587 * green + 0.114 * blue;
86
+ }
87
+ /**
88
+ * How dark the darkest ink on a row still is, as a fraction of the original
89
+ * ink-to-background contrast.
90
+ *
91
+ * `1` means the mark survived at full strength; `0` means it was averaged away
92
+ * into the background entirely. A thin stroke shrunk past the point where it
93
+ * covers a whole destination pixel loses contrast in proportion to the fraction
94
+ * of that pixel it covers, which is exactly what a box filter does and exactly
95
+ * what this reports.
96
+ *
97
+ * @param background the field's luminance, which the mark is measured against.
98
+ * @param ink the mark's original luminance, before any shrinking.
99
+ */
100
+ export function strokeContrast(image, row, background, ink) {
101
+ requireRow(image, row);
102
+ const full = Math.abs(background - ink);
103
+ if (full === 0)
104
+ return 0;
105
+ // The pixel on this row furthest from the background is the surviving ink.
106
+ let furthest = 0;
107
+ for (let x = 0; x < image.width; x++) {
108
+ const distance = Math.abs(luminanceAt(image, x, row) - background);
109
+ if (distance > furthest)
110
+ furthest = distance;
111
+ }
112
+ return Math.min(1, furthest / full);
113
+ }
114
+ /**
115
+ * Whether a known feature is still distinguishable from its background.
116
+ *
117
+ * **The threshold is stated as an argument rather than chosen here**, because
118
+ * "distinguishable" is the thing under study: a caller sweeping the ladder
119
+ * decides what fraction of the original contrast it is willing to call
120
+ * survival, and the sweep reports the answer at each. A constant baked in here
121
+ * would be this module quietly deciding the study's result.
122
+ */
123
+ export function featureSurvives(image, row, background, ink, minimumContrast) {
124
+ return strokeContrast(image, row, background, ink) >= minimumContrast;
125
+ }
126
+ /**
127
+ * The strongest measurement anywhere in the picture, rather than on a row a
128
+ * caller had to predict.
129
+ *
130
+ * **This exists because mapping a source row to a destination row is not a
131
+ * measurement, it is a guess.** A mark 24 source pixels tall shrunk by 0.32
132
+ * lands across some 7 destination rows, and which of them carries the most ink
133
+ * depends on where the box filter's boundaries fell — so a study that computed
134
+ * `sourceRow * ratio` and read that one row would report a number that swings
135
+ * with rounding rather than with resolution. Scanning every row and taking the
136
+ * best removes that artefact entirely: the answer is *"the most that survived
137
+ * anywhere"*, which is the generous reading and therefore the honest one for a
138
+ * claim about what is destroyed.
139
+ *
140
+ * Returns the best run count and the contrast on the row that achieved it.
141
+ */
142
+ export function bestSurvivingRow(image, background, ink, minimumContrast) {
143
+ let runs = 0;
144
+ let contrast = 0;
145
+ let row = 0;
146
+ for (let y = 0; y < image.height; y++) {
147
+ const here = countDistinctRuns(image, y, background, ink, minimumContrast);
148
+ if (here > runs) {
149
+ runs = here;
150
+ contrast = strokeContrast(image, y, background, ink);
151
+ row = y;
152
+ }
153
+ }
154
+ // A picture whose marks all fell below the ink threshold has no run anywhere;
155
+ // report the strongest contrast that survived, which is the fading a hairline
156
+ // shows before it disappears.
157
+ if (runs === 0) {
158
+ for (let y = 0; y < image.height; y++) {
159
+ const here = strokeContrast(image, y, background, ink);
160
+ if (here > contrast) {
161
+ contrast = here;
162
+ row = y;
163
+ }
164
+ }
165
+ }
166
+ return { runs, contrast, row };
167
+ }
168
+ /**
169
+ * How many separate dark runs remain along a row.
170
+ *
171
+ * This is the gap-closing instrument. A row of alternating one-pixel ink and
172
+ * one-pixel space has as many runs as it has marks; once the shrink averages
173
+ * neighbouring marks together the runs merge, and the count falls. **The count
174
+ * falling is the mechanism by which text becomes unreadable while remaining
175
+ * visible** — the ink is still there, the spaces between letters are not.
176
+ *
177
+ * A pixel counts as ink when it is at least `minimumContrast` of the way from
178
+ * the background toward the original ink, using the same scale
179
+ * {@link strokeContrast} reports on.
180
+ */
181
+ export function countDistinctRuns(image, row, background, ink, minimumContrast) {
182
+ requireRow(image, row);
183
+ const full = Math.abs(background - ink);
184
+ if (full === 0)
185
+ return 0;
186
+ let runs = 0;
187
+ let inRun = false;
188
+ for (let x = 0; x < image.width; x++) {
189
+ const isInk = Math.abs(luminanceAt(image, x, row) - background) / full >= minimumContrast;
190
+ if (isInk && !inRun)
191
+ runs++;
192
+ inRun = isInk;
193
+ }
194
+ return runs;
195
+ }