cbrowser 20.2.2 → 21.0.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 (302) hide show
  1. package/README.md +2 -2
  2. package/dist/analysis/accessibility-empathy.d.ts +29 -1
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  4. package/dist/analysis/accessibility-empathy.js +69 -35
  5. package/dist/analysis/accessibility-empathy.js.map +1 -1
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +11 -7
  8. package/dist/browser.js.map +1 -1
  9. package/dist/cif-score.d.ts +56 -5
  10. package/dist/cif-score.d.ts.map +1 -1
  11. package/dist/cif-score.js +595 -346
  12. package/dist/cif-score.js.map +1 -1
  13. package/dist/cli.js +10 -8
  14. package/dist/cli.js.map +1 -1
  15. package/dist/cognitive/default-traits.d.ts +14 -0
  16. package/dist/cognitive/default-traits.d.ts.map +1 -0
  17. package/dist/cognitive/default-traits.js +36 -0
  18. package/dist/cognitive/default-traits.js.map +1 -0
  19. package/dist/cognitive/emotions.d.ts.map +1 -1
  20. package/dist/cognitive/emotions.js +104 -174
  21. package/dist/cognitive/emotions.js.map +1 -1
  22. package/dist/cognitive/index.d.ts +28 -0
  23. package/dist/cognitive/index.d.ts.map +1 -1
  24. package/dist/cognitive/index.js +353 -287
  25. package/dist/cognitive/index.js.map +1 -1
  26. package/dist/cognitive/temporal.d.ts +0 -24
  27. package/dist/cognitive/temporal.d.ts.map +1 -1
  28. package/dist/cognitive/temporal.js +45 -35
  29. package/dist/cognitive/temporal.js.map +1 -1
  30. package/dist/data/ctc-reference-v2-2026-10-24.d.ts +16 -0
  31. package/dist/data/ctc-reference-v2-2026-10-24.d.ts.map +1 -0
  32. package/dist/data/ctc-reference-v2-2026-10-24.js +1004 -0
  33. package/dist/data/ctc-reference-v2-2026-10-24.js.map +1 -0
  34. package/dist/ideal-profiles-gap.d.ts +1 -0
  35. package/dist/ideal-profiles-gap.d.ts.map +1 -1
  36. package/dist/ideal-profiles-gap.js +9 -1
  37. package/dist/ideal-profiles-gap.js.map +1 -1
  38. package/dist/ideal-profiles.d.ts +8 -0
  39. package/dist/ideal-profiles.d.ts.map +1 -1
  40. package/dist/ideal-profiles.js +28 -24
  41. package/dist/ideal-profiles.js.map +1 -1
  42. package/dist/mcp-server-remote.d.ts.map +1 -1
  43. package/dist/mcp-server-remote.js +7 -8
  44. package/dist/mcp-server-remote.js.map +1 -1
  45. package/dist/mcp-tools/account-personas.d.ts.map +1 -1
  46. package/dist/mcp-tools/account-personas.js +3 -1
  47. package/dist/mcp-tools/account-personas.js.map +1 -1
  48. package/dist/mcp-tools/base/answer-provenance.d.ts +28 -0
  49. package/dist/mcp-tools/base/answer-provenance.d.ts.map +1 -0
  50. package/dist/mcp-tools/base/answer-provenance.js +32 -0
  51. package/dist/mcp-tools/base/answer-provenance.js.map +1 -0
  52. package/dist/mcp-tools/base/audit-tools.d.ts +0 -3
  53. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  54. package/dist/mcp-tools/base/audit-tools.js +152 -84
  55. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  56. package/dist/mcp-tools/base/capture-tools.js +1 -1
  57. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  58. package/dist/mcp-tools/base/chain-values.d.ts +38 -0
  59. package/dist/mcp-tools/base/chain-values.d.ts.map +1 -0
  60. package/dist/mcp-tools/base/chain-values.js +85 -0
  61. package/dist/mcp-tools/base/chain-values.js.map +1 -0
  62. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/cognitive-tools.js +126 -120
  64. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/gif-tools.js +23 -5
  67. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  68. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  69. package/dist/mcp-tools/base/persona-comparison-tools.js +229 -172
  70. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  71. package/dist/mcp-tools/base/site-familiarity.d.ts +31 -0
  72. package/dist/mcp-tools/base/site-familiarity.d.ts.map +1 -0
  73. package/dist/mcp-tools/base/site-familiarity.js +30 -0
  74. package/dist/mcp-tools/base/site-familiarity.js.map +1 -0
  75. package/dist/mcp-tools/base/site-knowledge-tools.js +1 -1
  76. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  77. package/dist/mcp-tools/base/story-narrative.d.ts +50 -0
  78. package/dist/mcp-tools/base/story-narrative.d.ts.map +1 -0
  79. package/dist/mcp-tools/base/story-narrative.js +88 -0
  80. package/dist/mcp-tools/base/story-narrative.js.map +1 -0
  81. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  82. package/dist/mcp-tools/base/values-tools.js +19 -5
  83. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  84. package/dist/mcp-tools/base/visual-testing-tools.d.ts +5 -5
  85. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  86. package/dist/mcp-tools/base/visual-testing-tools.js +38 -11
  87. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  88. package/dist/mcp-tools/persona-creation-tools.js +6 -6
  89. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  90. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -1
  91. package/dist/mcp-tools/persona-lifecycle-tools.js +5 -1
  92. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
  93. package/dist/mcp-tools/tier-gate.js +1 -1
  94. package/dist/mcp-tools/tool-result-saver.d.ts +7 -0
  95. package/dist/mcp-tools/tool-result-saver.d.ts.map +1 -1
  96. package/dist/mcp-tools/tool-result-saver.js +11 -0
  97. package/dist/mcp-tools/tool-result-saver.js.map +1 -1
  98. package/dist/mcp-tools/trait-provenance.d.ts +109 -0
  99. package/dist/mcp-tools/trait-provenance.d.ts.map +1 -0
  100. package/dist/mcp-tools/trait-provenance.js +187 -0
  101. package/dist/mcp-tools/trait-provenance.js.map +1 -0
  102. package/dist/mcp-tools/ui-resources.d.ts +6 -7
  103. package/dist/mcp-tools/ui-resources.d.ts.map +1 -1
  104. package/dist/mcp-tools/ui-resources.js +162 -12
  105. package/dist/mcp-tools/ui-resources.js.map +1 -1
  106. package/dist/mcp-tools/widget-kit.d.ts +129 -3
  107. package/dist/mcp-tools/widget-kit.d.ts.map +1 -1
  108. package/dist/mcp-tools/widget-kit.js +507 -28
  109. package/dist/mcp-tools/widget-kit.js.map +1 -1
  110. package/dist/performance/metrics.d.ts.map +1 -1
  111. package/dist/performance/metrics.js +4 -1
  112. package/dist/performance/metrics.js.map +1 -1
  113. package/dist/persona-questionnaire.js +1 -1
  114. package/dist/persona-questionnaire.js.map +1 -1
  115. package/dist/personas.d.ts.map +1 -1
  116. package/dist/personas.js +133 -82
  117. package/dist/personas.js.map +1 -1
  118. package/dist/recording/engine.d.ts.map +1 -1
  119. package/dist/recording/engine.js +5 -2
  120. package/dist/recording/engine.js.map +1 -1
  121. package/dist/recording/saliency-overlay.d.ts +8 -3
  122. package/dist/recording/saliency-overlay.d.ts.map +1 -1
  123. package/dist/recording/saliency-overlay.js +14 -5
  124. package/dist/recording/saliency-overlay.js.map +1 -1
  125. package/dist/recording/types.js +1 -1
  126. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  127. package/dist/testing/nl-test-suite.js +2 -1
  128. package/dist/testing/nl-test-suite.js.map +1 -1
  129. package/dist/trait-reference.d.ts +19 -2
  130. package/dist/trait-reference.d.ts.map +1 -1
  131. package/dist/trait-reference.js +19 -2
  132. package/dist/trait-reference.js.map +1 -1
  133. package/dist/types.d.ts +32 -21
  134. package/dist/types.d.ts.map +1 -1
  135. package/dist/types.js +62 -52
  136. package/dist/types.js.map +1 -1
  137. package/dist/values/persona-completeness.js +1 -1
  138. package/dist/values/persona-completeness.js.map +1 -1
  139. package/dist/visual/ab-comparison.d.ts.map +1 -1
  140. package/dist/visual/ab-comparison.js +4 -1
  141. package/dist/visual/ab-comparison.js.map +1 -1
  142. package/dist/visual/attention-quality.d.ts +1 -1
  143. package/dist/visual/attention-quality.js +1 -1
  144. package/dist/visual/attention-transport.d.ts +74 -10
  145. package/dist/visual/attention-transport.d.ts.map +1 -1
  146. package/dist/visual/attention-transport.js +353 -193
  147. package/dist/visual/attention-transport.js.map +1 -1
  148. package/dist/visual/cognitive-models.d.ts +77 -55
  149. package/dist/visual/cognitive-models.d.ts.map +1 -1
  150. package/dist/visual/cognitive-models.js +231 -104
  151. package/dist/visual/cognitive-models.js.map +1 -1
  152. package/dist/visual/cognitive-transport-chain.d.ts +328 -30
  153. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  154. package/dist/visual/cognitive-transport-chain.js +576 -353
  155. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  156. package/dist/visual/cognitive-transport.d.ts +446 -13
  157. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  158. package/dist/visual/cognitive-transport.js +1085 -245
  159. package/dist/visual/cognitive-transport.js.map +1 -1
  160. package/dist/visual/cross-browser.d.ts.map +1 -1
  161. package/dist/visual/cross-browser.js +8 -6
  162. package/dist/visual/cross-browser.js.map +1 -1
  163. package/dist/visual/ctc-bands.d.ts +91 -0
  164. package/dist/visual/ctc-bands.d.ts.map +1 -0
  165. package/dist/visual/ctc-bands.js +117 -0
  166. package/dist/visual/ctc-bands.js.map +1 -0
  167. package/dist/visual/ctc-reference.d.ts +287 -0
  168. package/dist/visual/ctc-reference.d.ts.map +1 -0
  169. package/dist/visual/ctc-reference.js +376 -0
  170. package/dist/visual/ctc-reference.js.map +1 -0
  171. package/dist/visual/demand-laws.d.ts +145 -0
  172. package/dist/visual/demand-laws.d.ts.map +1 -0
  173. package/dist/visual/demand-laws.js +220 -0
  174. package/dist/visual/demand-laws.js.map +1 -0
  175. package/dist/visual/distance-metrics.d.ts +23 -1
  176. package/dist/visual/distance-metrics.d.ts.map +1 -1
  177. package/dist/visual/distance-metrics.js +49 -18
  178. package/dist/visual/distance-metrics.js.map +1 -1
  179. package/dist/visual/dro-bound.d.ts +220 -0
  180. package/dist/visual/dro-bound.d.ts.map +1 -0
  181. package/dist/visual/dro-bound.js +341 -0
  182. package/dist/visual/dro-bound.js.map +1 -0
  183. package/dist/visual/exact-w1.d.ts +60 -0
  184. package/dist/visual/exact-w1.d.ts.map +1 -0
  185. package/dist/visual/exact-w1.js +247 -0
  186. package/dist/visual/exact-w1.js.map +1 -0
  187. package/dist/visual/human-input.d.ts.map +1 -1
  188. package/dist/visual/human-input.js +45 -41
  189. package/dist/visual/human-input.js.map +1 -1
  190. package/dist/visual/index.d.ts +2 -0
  191. package/dist/visual/index.d.ts.map +1 -1
  192. package/dist/visual/index.js +2 -0
  193. package/dist/visual/index.js.map +1 -1
  194. package/dist/visual/law-times-report.d.ts +49 -0
  195. package/dist/visual/law-times-report.d.ts.map +1 -0
  196. package/dist/visual/law-times-report.js +38 -0
  197. package/dist/visual/law-times-report.js.map +1 -0
  198. package/dist/visual/model-provenance.d.ts +151 -0
  199. package/dist/visual/model-provenance.d.ts.map +1 -0
  200. package/dist/visual/model-provenance.js +334 -0
  201. package/dist/visual/model-provenance.js.map +1 -0
  202. package/dist/visual/normal-cdf.d.ts +14 -0
  203. package/dist/visual/normal-cdf.d.ts.map +1 -0
  204. package/dist/visual/normal-cdf.js +26 -0
  205. package/dist/visual/normal-cdf.js.map +1 -0
  206. package/dist/visual/perceptual-transport.d.ts +28 -4
  207. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  208. package/dist/visual/perceptual-transport.js +66 -32
  209. package/dist/visual/perceptual-transport.js.map +1 -1
  210. package/dist/visual/provenance/attention.d.ts +55 -0
  211. package/dist/visual/provenance/attention.d.ts.map +1 -0
  212. package/dist/visual/provenance/attention.js +188 -0
  213. package/dist/visual/provenance/attention.js.map +1 -0
  214. package/dist/visual/provenance/chain.d.ts +41 -0
  215. package/dist/visual/provenance/chain.d.ts.map +1 -0
  216. package/dist/visual/provenance/chain.js +66 -0
  217. package/dist/visual/provenance/chain.js.map +1 -0
  218. package/dist/visual/provenance/cif.d.ts +42 -0
  219. package/dist/visual/provenance/cif.d.ts.map +1 -0
  220. package/dist/visual/provenance/cif.js +116 -0
  221. package/dist/visual/provenance/cif.js.map +1 -0
  222. package/dist/visual/provenance/ctc-reference.d.ts +36 -0
  223. package/dist/visual/provenance/ctc-reference.d.ts.map +1 -0
  224. package/dist/visual/provenance/ctc-reference.js +96 -0
  225. package/dist/visual/provenance/ctc-reference.js.map +1 -0
  226. package/dist/visual/provenance/distance.d.ts +10 -0
  227. package/dist/visual/provenance/distance.d.ts.map +1 -0
  228. package/dist/visual/provenance/distance.js +72 -0
  229. package/dist/visual/provenance/distance.js.map +1 -0
  230. package/dist/visual/provenance/dro.d.ts +10 -0
  231. package/dist/visual/provenance/dro.d.ts.map +1 -0
  232. package/dist/visual/provenance/dro.js +2 -0
  233. package/dist/visual/provenance/dro.js.map +1 -0
  234. package/dist/visual/provenance/emotion.d.ts +26 -0
  235. package/dist/visual/provenance/emotion.d.ts.map +1 -0
  236. package/dist/visual/provenance/emotion.js +99 -0
  237. package/dist/visual/provenance/emotion.js.map +1 -0
  238. package/dist/visual/provenance/human-input.d.ts +19 -0
  239. package/dist/visual/provenance/human-input.d.ts.map +1 -0
  240. package/dist/visual/provenance/human-input.js +84 -0
  241. package/dist/visual/provenance/human-input.js.map +1 -0
  242. package/dist/visual/provenance/journey-group.d.ts +15 -0
  243. package/dist/visual/provenance/journey-group.d.ts.map +1 -0
  244. package/dist/visual/provenance/journey-group.js +15 -0
  245. package/dist/visual/provenance/journey-group.js.map +1 -0
  246. package/dist/visual/provenance/journey-mcp.d.ts +24 -0
  247. package/dist/visual/provenance/journey-mcp.d.ts.map +1 -0
  248. package/dist/visual/provenance/journey-mcp.js +54 -0
  249. package/dist/visual/provenance/journey-mcp.js.map +1 -0
  250. package/dist/visual/provenance/journey.d.ts +34 -0
  251. package/dist/visual/provenance/journey.d.ts.map +1 -0
  252. package/dist/visual/provenance/journey.js +184 -0
  253. package/dist/visual/provenance/journey.js.map +1 -0
  254. package/dist/visual/provenance/laws.d.ts +38 -0
  255. package/dist/visual/provenance/laws.d.ts.map +1 -0
  256. package/dist/visual/provenance/laws.js +40 -0
  257. package/dist/visual/provenance/laws.js.map +1 -0
  258. package/dist/visual/provenance/page-metrics.d.ts +42 -0
  259. package/dist/visual/provenance/page-metrics.d.ts.map +1 -0
  260. package/dist/visual/provenance/page-metrics.js +79 -0
  261. package/dist/visual/provenance/page-metrics.js.map +1 -0
  262. package/dist/visual/provenance/perceptual.d.ts +63 -0
  263. package/dist/visual/provenance/perceptual.d.ts.map +1 -0
  264. package/dist/visual/provenance/perceptual.js +115 -0
  265. package/dist/visual/provenance/perceptual.js.map +1 -0
  266. package/dist/visual/provenance/persona-rates.d.ts +84 -0
  267. package/dist/visual/provenance/persona-rates.d.ts.map +1 -0
  268. package/dist/visual/provenance/persona-rates.js +112 -0
  269. package/dist/visual/provenance/persona-rates.js.map +1 -0
  270. package/dist/visual/provenance/personas.d.ts +122 -0
  271. package/dist/visual/provenance/personas.d.ts.map +1 -0
  272. package/dist/visual/provenance/personas.js +298 -0
  273. package/dist/visual/provenance/personas.js.map +1 -0
  274. package/dist/visual/provenance/profiles.d.ts +39 -0
  275. package/dist/visual/provenance/profiles.d.ts.map +1 -0
  276. package/dist/visual/provenance/profiles.js +37 -0
  277. package/dist/visual/provenance/profiles.js.map +1 -0
  278. package/dist/visual/provenance/temporal.d.ts +21 -0
  279. package/dist/visual/provenance/temporal.d.ts.map +1 -0
  280. package/dist/visual/provenance/temporal.js +56 -0
  281. package/dist/visual/provenance/temporal.js.map +1 -0
  282. package/dist/visual/provenance/visual-distance.d.ts +22 -0
  283. package/dist/visual/provenance/visual-distance.d.ts.map +1 -0
  284. package/dist/visual/provenance/visual-distance.js +30 -0
  285. package/dist/visual/provenance/visual-distance.js.map +1 -0
  286. package/dist/visual/regression.d.ts.map +1 -1
  287. package/dist/visual/regression.js +14 -9
  288. package/dist/visual/regression.js.map +1 -1
  289. package/dist/visual/responsive.d.ts.map +1 -1
  290. package/dist/visual/responsive.js +4 -1
  291. package/dist/visual/responsive.js.map +1 -1
  292. package/dist/visual/scope-prose.js +2 -2
  293. package/dist/visual/scope-prose.js.map +1 -1
  294. package/dist/visual/seeded-random.d.ts +16 -0
  295. package/dist/visual/seeded-random.d.ts.map +1 -0
  296. package/dist/visual/seeded-random.js +25 -0
  297. package/dist/visual/seeded-random.js.map +1 -0
  298. package/dist/visual/stored-accessibility-traits.d.ts +27 -0
  299. package/dist/visual/stored-accessibility-traits.d.ts.map +1 -0
  300. package/dist/visual/stored-accessibility-traits.js +35 -0
  301. package/dist/visual/stored-accessibility-traits.js.map +1 -0
  302. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  * Treats persona cognitive profiles as probability measures in Wasserstein space.
5
5
  * Provides mathematically grounded operations:
6
6
  *
7
- * - Cognitive distance: W₁(personaA, personaB) — true distance between minds
7
+ * - Cognitive distance: generalized W₁(personaA, personaB) — trait shape AND level
8
8
  * - Barycenter: consensus cognitive profile from multiple personas
9
9
  * - Geodesic interpolation: principled persona blending preserving trait coupling
10
10
  * - Adversarial generation: worst-case persona via distributionally robust optimization
@@ -22,6 +22,9 @@
22
22
  * @since v18.27.0
23
23
  * @see https://github.com/alexandriashai/cbrowser/issues/159
24
24
  */
25
+ import { param } from "./model-provenance.js";
26
+ import { choosingSeconds, distanceToScreenCentre, fittsIndexOfDifficulty, klmSeconds, noviceSearchSeconds, hickHymanSeconds, labelReadingSeconds, pointingSeconds, readingSeconds, smallerOfWidth, } from "./demand-laws.js";
27
+ import { exactW1 } from "./exact-w1.js";
25
28
  // ── The 26 Cognitive Traits ──
26
29
  //
27
30
  // These names MUST match the `CognitiveTraits` interface in ../types.ts, because
@@ -69,11 +72,17 @@ export const COGNITIVE_TRAITS = [
69
72
  * Traits in the same domain are closer. This is the critical design
70
73
  * decision — it determines what "nearby" means in cognitive space.
71
74
  *
72
- * Organized by domain proximity:
73
- * - Same subdomain (e.g. both "Core"): distance 0.2
74
- * - Same domain (e.g. both "Perception"): distance 0.4
75
- * - Related domains: distance 0.6
76
- * - Unrelated domains: distance 1.0
75
+ * Organized by domain proximity (the values are read from the provenance
76
+ * registry, provenance/distance.ts, where each is labelled `hypothesis`: the
77
+ * grouping and the three levels are hand-assigned, untested against data):
78
+ * - Same trait: 0
79
+ * - Same domain (e.g. both "core"): distance.groundSameDomain (0.2)
80
+ * - Related domains: distance.groundRelatedDomain (0.5)
81
+ * - Unrelated domains, or either trait unknown: distance.groundUnrelatedDomain /
82
+ * distance.groundUnknownTrait (both 0.8)
83
+ *
84
+ * The docstring used to say 0.2 / 0.4 / 0.6 / 1.0, which the code never
85
+ * returned. (2026-10-21)
77
86
  */
78
87
  function traitGroundDistance(a, b) {
79
88
  if (a === b)
@@ -119,12 +128,112 @@ function traitGroundDistance(a, b) {
119
128
  // That is how the phantom vocabulary above stayed invisible for so long.
120
129
  // Treat unknown as maximally distant instead. (2026-07-28)
121
130
  if (!domainA || !domainB)
122
- return 0.8;
131
+ return param("distance.groundUnknownTrait");
123
132
  if (domainA === domainB)
124
- return 0.2; // Same subdomain
133
+ return param("distance.groundSameDomain");
125
134
  if (related.has(`${domainA}-${domainB}`))
126
- return 0.5; // Related
127
- return 0.8; // Unrelated
135
+ return param("distance.groundRelatedDomain");
136
+ return param("distance.groundUnrelatedDomain");
137
+ }
138
+ /**
139
+ * The 29 x 29 ground-cost matrix C[i][j] = traitGroundDistance(trait_i, trait_j),
140
+ * built once and reused by every distance call.
141
+ *
142
+ * The four registry values are still read through `param()` on every call (four
143
+ * lookups, negligible), so a provenance recording started at any point sees the
144
+ * ids the distance depends on; the matrix is rebuilt only if one of them changes.
145
+ */
146
+ let groundCostCache = null;
147
+ function groundCostMatrix() {
148
+ const key = [
149
+ param("distance.groundSameDomain"),
150
+ param("distance.groundRelatedDomain"),
151
+ param("distance.groundUnrelatedDomain"),
152
+ param("distance.groundUnknownTrait"),
153
+ ].join(",");
154
+ if (groundCostCache && groundCostCache.key === key)
155
+ return groundCostCache.matrix;
156
+ const d = COGNITIVE_TRAITS.length;
157
+ const matrix = Array.from({ length: d }, () => new Array(d).fill(0));
158
+ for (let i = 0; i < d; i++)
159
+ for (let j = i + 1; j < d; j++)
160
+ matrix[i][j] = matrix[j][i] = traitGroundDistance(COGNITIVE_TRAITS[i], COGNITIVE_TRAITS[j]);
161
+ groundCostCache = { key, matrix };
162
+ return matrix;
163
+ }
164
+ /** The 29 x 29 trait ground-cost matrix, exposed for tests (a copy). */
165
+ export function traitGroundCostMatrix() {
166
+ return groundCostMatrix().map(row => row.slice());
167
+ }
168
+ /**
169
+ * Key of the slack bin's share in `CognitiveDistance.traitContributions`. Not a
170
+ * trait: it is the part of the distance spent creating or destroying trait mass,
171
+ * i.e. the difference in overall level between the two profiles.
172
+ */
173
+ export const OVERALL_LEVEL_KEY = "overallLevel";
174
+ /**
175
+ * The (d+1) x (d+1) cost matrix of the generalized distance: the trait ground
176
+ * costs, plus a slack bin (last index) at distance.slackCost from every trait
177
+ * and 0 from itself.
178
+ *
179
+ * Refuses to build if 2 * slackCost is below the largest ground cost. Below that
180
+ * the triangle inequality fails through the slack bin (trait -> slack -> trait
181
+ * would be cheaper than trait -> trait), the result is no longer a metric, and
182
+ * the slack-bin problem stops being equal to the Piccoli-Rossi distance (see
183
+ * cognitiveDistance). The registry values are frozen, so this can only fire on
184
+ * an edit to provenance/distance.ts, and that edit should fail loudly.
185
+ */
186
+ let generalizedCostCache = null;
187
+ function generalizedCostMatrix() {
188
+ // groundCostMatrix returns the same array until a registry value changes, so
189
+ // its identity plus the slack cost is a complete cache key.
190
+ const ground = groundCostMatrix();
191
+ const slack = param("distance.slackCost");
192
+ if (generalizedCostCache && generalizedCostCache.ground === ground && generalizedCostCache.slack === slack) {
193
+ return generalizedCostCache.matrix;
194
+ }
195
+ let maxGround = 0;
196
+ for (const row of ground)
197
+ for (const c of row)
198
+ if (c > maxGround)
199
+ maxGround = c;
200
+ if (2 * slack < maxGround) {
201
+ throw new Error(`distance.slackCost ${slack} is below half the largest ground cost ${maxGround}: the generalized distance would not be a metric`);
202
+ }
203
+ const d = ground.length;
204
+ const matrix = ground.map(row => [...row, slack]);
205
+ matrix.push([...new Array(d).fill(slack), 0]);
206
+ generalizedCostCache = { ground, slack, matrix };
207
+ return matrix;
208
+ }
209
+ /** The generalized (trait + slack bin) cost matrix, exposed for tests (a copy). */
210
+ export function traitGeneralizedCostMatrix() {
211
+ return generalizedCostMatrix().map(row => row.slice());
212
+ }
213
+ /**
214
+ * A profile's trait measure for the generalized distance: d + 1 masses that sum
215
+ * to 1. Trait i carries t_i / d (t_i clamped to the schema range [0, 1], missing
216
+ * traits at distance.missingTraitDefault); the last entry is the slack bin,
217
+ * 1 - sum_i t_i / d, which holds the mass the traits do not.
218
+ *
219
+ * Unlike traitsToDistribution nothing is renormalised, so the absolute level
220
+ * survives: all traits 0.2 puts 0.2 on the traits and 0.8 in the slack bin, all
221
+ * traits 0.8 puts 0.8 and 0.2. No floor is applied either, because the solver
222
+ * accepts zero atoms (distance.simplexFloor belongs to the simplex encoding).
223
+ */
224
+ export function traitsToLevelMeasure(traits) {
225
+ const d = COGNITIVE_TRAITS.length;
226
+ const missing = param("distance.missingTraitDefault");
227
+ const measure = new Float64Array(d + 1);
228
+ let traitMass = 0;
229
+ for (let i = 0; i < d; i++) {
230
+ const v = Math.min(1, Math.max(0, traits[COGNITIVE_TRAITS[i]] ?? missing));
231
+ measure[i] = v / d;
232
+ traitMass += measure[i];
233
+ }
234
+ // Max with 0: all traits at 1 can sum to 1 + 2e-16.
235
+ measure[d] = Math.max(0, 1 - traitMass);
236
+ return measure;
128
237
  }
129
238
  // ── Core: Trait Distribution Operations ──
130
239
  /**
@@ -132,13 +241,19 @@ function traitGroundDistance(a, b) {
132
241
  * This is the fundamental operation: traits → distribution.
133
242
  */
134
243
  export function traitsToDistribution(traits) {
135
- // The 0.01 floor is load-bearing, not defensive: optimal transport needs a
136
- // measure with no zero atoms. It is also the one place the mapping is LOSSY —
137
- // a trait of exactly 0 (first-timer's siteFamiliarity is the only one across
138
- // all built-in personas) round-trips back as 0.01, not 0. That bound is
139
- // asserted in tests/cognitive-interpolate.test.ts rather than left for someone
140
- // to rediscover as a mystery 0.01 discrepancy.
141
- const values = COGNITIVE_TRAITS.map(t => Math.max(0.01, traits[t] ?? 0.5));
244
+ // The floor (distance.simplexFloor, 0.01) is a numerical encoding choice, not
245
+ // a requirement of the transport problem: the exact W1 solver handles zero
246
+ // atoms. It is kept because it is the one place the mapping is LOSSY and
247
+ // callers depend on the exact behaviour — a trait of exactly 0 (first-timer's
248
+ // siteFamiliarity is the only one across all built-in personas) round-trips
249
+ // back as 0.01, not 0. That bound is asserted in
250
+ // tests/cognitive-interpolate.test.ts rather than left for someone to
251
+ // rediscover as a mystery 0.01 discrepancy. The missing-trait default
252
+ // (distance.missingTraitDefault, 0.5) is a hypothesis: it claims an unmeasured
253
+ // trait sits at the midpoint.
254
+ const floor = param("distance.simplexFloor");
255
+ const missing = param("distance.missingTraitDefault");
256
+ const values = COGNITIVE_TRAITS.map(t => Math.max(floor, traits[t] ?? missing));
142
257
  const sum = values.reduce((a, b) => a + b, 0);
143
258
  return new Float64Array(values.map(v => v / sum));
144
259
  }
@@ -177,7 +292,9 @@ export function distributionToTraits(dist, traitScale) {
177
292
  export function buildOTCognitiveProfile(name, traits) {
178
293
  const distribution = traitsToDistribution(traits);
179
294
  // Same flooring traitsToDistribution applies, or the inverse would not be one.
180
- const traitScale = COGNITIVE_TRAITS.reduce((sum, t) => sum + Math.max(0.01, traits[t] ?? 0.5), 0);
295
+ const floor = param("distance.simplexFloor");
296
+ const missing = param("distance.missingTraitDefault");
297
+ const traitScale = COGNITIVE_TRAITS.reduce((sum, t) => sum + Math.max(floor, traits[t] ?? missing), 0);
181
298
  // Build simple diagonal covariance (variance proportional to trait value uncertainty)
182
299
  const d = COGNITIVE_TRAITS.length;
183
300
  const covariance = [];
@@ -192,49 +309,158 @@ export function buildOTCognitiveProfile(name, traits) {
192
309
  }
193
310
  // ── Cognitive Distance ──
194
311
  /**
195
- * Compute the Wasserstein-1 distance between two cognitive profiles.
196
- * Uses the ground metric between traits to weight the transport.
197
- *
198
- * This is the "true cognitive distance" — how different two minds are.
312
+ * E|<x, theta>| / ||x|| for theta uniform on the unit sphere in `d`
313
+ * dimensions: Gamma(d/2) / (sqrt(pi) * Gamma((d + 1)/2)). The sliced
314
+ * Wasserstein-1 distance between two points is this times their Euclidean
315
+ * distance. Built from c_1 = 1, c_2 = 2/pi and c_(d+2) = c_d * d/(d + 1), so
316
+ * no gamma function is needed; for odd d it is a product of rationals.
317
+ * Exported for tests of the formula.
199
318
  */
319
+ export function sphereMeanAbsProjection(d) {
320
+ if (!Number.isInteger(d) || d < 1)
321
+ throw new RangeError(`sphereMeanAbsProjection: d must be a positive integer, not ${d}`);
322
+ let c = d % 2 === 1 ? 1 : 2 / Math.PI;
323
+ for (let k = d % 2 === 1 ? 1 : 2; k < d; k += 2)
324
+ c *= k / (k + 1);
325
+ return c;
326
+ }
200
327
  /**
201
- * Fixed seed for the sliced-Wasserstein projection set. Any constant works; what
202
- * matters is that it never changes, so the same pair of profiles always yields
203
- * the same sliced distance across processes and releases.
328
+ * Distance between two cognitive profiles.
329
+ *
330
+ * `w1` is the exact GENERALIZED Wasserstein-1 distance between the two trait
331
+ * vectors (Piccoli & Rossi 2014, "Generalized Wasserstein distance and its
332
+ * application to transport equations with source"; the unbalanced-OT form with
333
+ * a per-unit cost for creating or destroying mass). Each profile is the measure
334
+ * that puts t_i / 29 on trait i, so its total mass is its mean trait level and
335
+ * is NOT normalised away. The distance is the cheapest way to turn one measure
336
+ * into the other when moving a unit of mass from trait i to trait j costs
337
+ * d(trait_i, trait_j) (traitGroundDistance) and creating or destroying a unit
338
+ * costs c = distance.slackCost (0.4, a registered hypothesis).
339
+ *
340
+ * It is computed by the standard slack-bin reduction (traitsToLevelMeasure):
341
+ * each measure gets one extra bin holding 1 - its trait mass, so both sum to 1,
342
+ * the slack bin is at cost c from every trait and 0 from itself, and the exact
343
+ * balanced W1 is solved on the 30 bins by min-cost flow (successive shortest
344
+ * paths; see exact-w1.ts). Mass sent from a trait to the other side's slack bin
345
+ * is destroyed, mass sent from the slack bin to a trait is created. The
346
+ * reduction equals the Piccoli-Rossi distance (with a = c, b = 1) whenever
347
+ * 2c >= the largest ground cost (0.8): a plan that both destroys at i and
348
+ * creates at j can then be replaced by transporting i -> j at no greater cost,
349
+ * so some optimum only destroys (or only creates) |mass_A - mass_B|, which the
350
+ * slack bins always have room for since both trait masses are at most 1. The
351
+ * same condition makes the 30-bin cost a metric, so `w1` is a metric on trait
352
+ * vectors: 0 exactly when the 29 values agree (after the [0, 1] clamp and the
353
+ * missing-trait default). tests/cognitive-distance-w1.test.ts checks the
354
+ * reduction against the Piccoli-Rossi LP solved by an independent simplex.
355
+ *
356
+ * Bounds, with mean_i over the 29 traits:
357
+ * c * |mean_i t_A,i - mean_i t_B,i| <= w1 <= c * mean_i |t_A,i - t_B,i| <= c.
358
+ * The lower bound is the mass imbalance, which can only be created or
359
+ * destroyed; the upper bound routes every difference through the slack bin.
360
+ * Proportional profiles are no longer at distance 0: all traits 0.2 versus all
361
+ * traits 0.8 is exactly 0.6 * c = 0.24 (both bounds meet). History: until
362
+ * 2026-10-21 this field was a weighted L1 that was not optimal transport (see
363
+ * exact-w1.ts); its exact replacement, shipped the same day, normalised both
364
+ * profiles to the simplex, and that pair was at 0 on every metric.
365
+ *
366
+ * `w1` is also positively homogeneous in the trait difference: moving every
367
+ * trait of A toward B by the same fraction scales the distance by that
368
+ * fraction (Kantorovich-Rubinstein: W1 under a metric cost depends only on the
369
+ * signed difference of the measures). So pushing a persona's traits further
370
+ * from another's never brings it closer.
371
+ *
372
+ * `w2` and `sliced` are UNCHANGED and still computed on the simplex
373
+ * (traitsToDistribution), so they remain shape-only: proportional profiles are
374
+ * at 0 on both.
375
+ *
376
+ * `traitContributions[t]` is the cost attributed to trait t under the optimal
377
+ * plan: half the cost of mass shipped OUT of t plus half the cost of mass
378
+ * shipped INTO t, i.e. 0.5 * (sum_j P[t][j] C(t,j) + sum_i P[i][t] C(i,t)) over
379
+ * all 30 bins. The slack bin's share is reported under OVERALL_LEVEL_KEY: half
380
+ * of every creation or destruction is charged to the trait it happened at and
381
+ * half to the overall level. Each unit of cost is split between the two
382
+ * endpoints it connects, so the contributions sum to `w1` exactly and are
383
+ * symmetric in the two profiles (swapping A and B transposes P and leaves every
384
+ * contribution unchanged). Mass that stays put (P[t][t]) costs 0 and
385
+ * contributes nothing.
386
+ *
387
+ * The optimal plan is often not unique (ties are common under a three-level
388
+ * cost), and one tie matters for the attribution: destroying a unit at trait i
389
+ * and creating one at trait j costs 2c = 0.8, exactly what moving it i -> j
390
+ * costs between unrelated domains. Left to the solver, a pure shape difference
391
+ * can be booked as a level difference: with exactW1's earlier end-node rule
392
+ * (same optimal costs) 211 of the 300 roster pairs did, by up to 0.017. The
393
+ * current solver happens not to (0 of 300 roster pairs, 0 of 21,000 random
394
+ * pairs, 2026-10-21), but nothing guarantees which tied plan it returns, so
395
+ * the plan is UNCROSSED after solving: wherever it both destroys at i and
396
+ * creates at j, that mass is routed i -> j directly, which costs
397
+ * C(i, j) <= 2c, so the plan stays optimal. With the old end-node rule put
398
+ * back, uncrossing alone brings all 300 pairs back to the identity below.
399
+ * The uncrossed plan creates or destroys exactly |mass_A - mass_B|, which makes
400
+ * the attribution unique where it matters:
401
+ * overallLevel = 0.5 * c * |mean_i t_A,i - mean_i t_B,i|,
402
+ * half of what the level difference forces (the other half is charged to the
403
+ * traits where the mass is created or destroyed). Remaining ties among trait
404
+ * pairs are settled by solving the pair in a canonical order, so the
405
+ * attribution is deterministic and the same for (A, B) and (B, A). `w1` itself
406
+ * is unique.
204
407
  */
205
- const SLICED_PROJECTION_SEED = 0x5f3759df;
206
- /** mulberry32 — small, fast, well-distributed 32-bit PRNG. */
207
- function seededRandom(seed) {
208
- let a = seed >>> 0;
209
- return () => {
210
- a = (a + 0x6d2b79f5) >>> 0;
211
- let t = a;
212
- t = Math.imul(t ^ (t >>> 15), t | 1);
213
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
214
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
215
- };
216
- }
217
408
  export function cognitiveDistance(profileA, profileB) {
218
409
  const d = COGNITIVE_TRAITS.length;
219
410
  const a = profileA.distribution;
220
411
  const b = profileB.distribution;
221
- // W1 with ground metric: solve the discrete OT problem
222
- // For d=25 with full ground metric, use the LP formulation
223
- // Simplified: weighted L1 with ground metric as scaling
412
+ // Generalized W1: exact balanced W1 on the 30-bin slack measures.
413
+ //
414
+ // The pair is solved in a canonical order (lexicographically smaller
415
+ // measure as the source) and the plan transposed back when needed. The
416
+ // optimal COST is unique, but the optimal PLAN is not: with 30 bins and a
417
+ // three-level cost many plans tie, and the solver run on (b, a) does not in
418
+ // general find the transpose of its (a, b) plan. Fixing the order makes the
419
+ // per-trait attribution below identical for (A, B) and (B, A).
420
+ const ma = traitsToLevelMeasure(profileA.traits);
421
+ const mb = traitsToLevelMeasure(profileB.traits);
422
+ const cost = generalizedCostMatrix();
423
+ const bins = d + 1;
424
+ let swapped = false;
425
+ for (let i = 0; i < bins; i++) {
426
+ if (ma[i] === mb[i])
427
+ continue;
428
+ swapped = ma[i] > mb[i];
429
+ break;
430
+ }
431
+ const solved = swapped ? exactW1(mb, ma, cost) : exactW1(ma, mb, cost);
432
+ // Uncross (see the docstring): mass destroyed at i (i -> slack) and mass
433
+ // created at j (slack -> j) become i -> j plus slack -> slack. Row and column
434
+ // sums are unchanged, and the cost changes by m * (C(i, j) - 2c) <= 0, which is
435
+ // 0 at an optimum. Done on the canonical-order plan, before any transpose, so
436
+ // (A, B) and (B, A) still get the same plan.
437
+ const sp = solved.plan;
438
+ for (let i = 0; i < d; i++) {
439
+ for (let j = 0; j < d && sp[i][d] > 0; j++) {
440
+ const m = Math.min(sp[i][d], sp[d][j]);
441
+ if (m <= 0)
442
+ continue;
443
+ sp[i][d] -= m;
444
+ sp[d][j] -= m;
445
+ sp[i][j] += m;
446
+ sp[d][d] += m;
447
+ }
448
+ }
449
+ const plan = swapped ? sp.map((_, i) => sp.map(row => row[i])) : sp;
224
450
  let w1 = 0;
225
451
  const traitContributions = {};
226
- for (let i = 0; i < d; i++) {
227
- const diff = Math.abs(a[i] - b[i]);
228
- // Weight by average ground distance to other traits (captures trait importance)
229
- let avgDist = 0;
230
- for (let j = 0; j < d; j++) {
231
- if (i !== j)
232
- avgDist += traitGroundDistance(COGNITIVE_TRAITS[i], COGNITIVE_TRAITS[j]);
452
+ for (let t = 0; t < bins; t++) {
453
+ let out = 0, into = 0;
454
+ const row = plan[t];
455
+ const costRow = cost[t];
456
+ for (let j = 0; j < bins; j++) {
457
+ if (row[j] > 0)
458
+ out += row[j] * costRow[j];
459
+ if (plan[j][t] > 0)
460
+ into += plan[j][t] * cost[j][t];
233
461
  }
234
- avgDist /= (d - 1);
235
- const weighted = diff * avgDist;
236
- w1 += weighted;
237
- traitContributions[COGNITIVE_TRAITS[i]] = weighted;
462
+ w1 += out;
463
+ traitContributions[t < d ? COGNITIVE_TRAITS[t] : OVERALL_LEVEL_KEY] = 0.5 * (out + into);
238
464
  }
239
465
  // W2: Bures-Wasserstein for Gaussians
240
466
  // BW²(N(m₁,Σ₁), N(m₂,Σ₂)) = ||m₁-m₂||² + Tr(Σ₁) + Tr(Σ₂) - 2Tr((Σ₁^½ Σ₂ Σ₁^½)^½)
@@ -247,37 +473,25 @@ export function cognitiveDistance(profileA, profileB) {
247
473
  w2sq += meanDiff * meanDiff + (sigA - sigB) * (sigA - sigB);
248
474
  }
249
475
  const w2 = Math.sqrt(Math.max(0, w2sq));
250
- // Sliced Wasserstein (fast approximation for validation).
476
+ // Sliced Wasserstein-1 between the two profiles, as points on the simplex:
477
+ // the mean over directions theta, uniform on the unit sphere, of
478
+ // |<a - b, theta>|. For two points that mean has a closed form,
479
+ // sphereMeanAbsProjection(d) * ||a - b||_2, so it is computed exactly rather
480
+ // than estimated.
251
481
  //
252
- // The projections are drawn from a SEEDED generator, not Math.random(). Two
253
- // identical calls used to return different sliced values (measured: 0.031568
254
- // vs 0.029943 for the same persona pair, a ~5% swing) while the MCP tool that
255
- // exposes this declares idempotentHint: true. A metric a caller cannot
256
- // reproduce is not a metric they can act on or diff against a baseline, so the
257
- // projection set is now fixed. (2026-07-29)
258
- const numProjections = 100;
259
- let slicedSum = 0;
260
- const rand = seededRandom(SLICED_PROJECTION_SEED);
261
- for (let p = 0; p < numProjections; p++) {
262
- // Deterministic pseudo-random projection
263
- const dir = new Float64Array(d);
264
- let norm = 0;
265
- for (let i = 0; i < d; i++) {
266
- dir[i] = rand() * 2 - 1;
267
- norm += dir[i] * dir[i];
268
- }
269
- norm = Math.sqrt(norm);
270
- for (let i = 0; i < d; i++)
271
- dir[i] /= norm;
272
- // Project and compute 1D W1
273
- let projA = 0, projB = 0;
274
- for (let i = 0; i < d; i++) {
275
- projA += a[i] * dir[i];
276
- projB += b[i] * dir[i];
277
- }
278
- slicedSum += Math.abs(projA - projB);
279
- }
280
- const sliced = slicedSum / numProjections;
482
+ // It was estimated from 100 directions: unseeded Math.random() until
483
+ // 2026-07-29 (the same pair gave 0.031568 and 0.029943), then seeded, but
484
+ // drawn uniform in the cube [-1, 1]^d and normalized, which is not uniform
485
+ // on the sphere. Against the exact value the roster's 300 pairs were off by
486
+ // -20% to +19% (measured 2026-10-23). Uniform directions would not have
487
+ // fixed that: 100 of them were off by -24% to +26%, the sampling error of
488
+ // the estimate, and drawing them in 29 dimensions needs Gaussian components
489
+ // whose Math.log and Math.cos differ in the last bits between Node and Bun.
490
+ // The closed form takes only arithmetic and Math.sqrt.
491
+ let diffSq = 0;
492
+ for (let i = 0; i < d; i++)
493
+ diffSq += (a[i] - b[i]) * (a[i] - b[i]);
494
+ const sliced = sphereMeanAbsProjection(d) * Math.sqrt(diffSq);
281
495
  return { w1, w2, traitContributions, sliced };
282
496
  }
283
497
  // ── Barycenter (Consensus Cognition) ──
@@ -476,7 +690,6 @@ export function generateAdversarialPersonas(knownPersonas, costFn, epsilon = 0.1
476
690
  *
477
691
  * The expectation distribution is derived from the persona's traits:
478
692
  * - High patience → expects slower information delivery (wider temporal spread)
479
- * - High comprehension → expects denser information
480
693
  * - attention / motor / text read the three BRIDGED capacities
481
694
  * (sustainedAttention, motorCapacity, readingCapacity), so a caller must run
482
695
  * bridgeCapacityTraits (visual/cognitive-models.ts) before building the
@@ -499,8 +712,11 @@ export function estimateCognitiveLoad(persona, pageMetrics) {
499
712
  // Per-dimension load: mismatch between persona capacity and page demand
500
713
  // Use power 1.5 on page metrics for sharper scaling at high demand
501
714
  const breakdown = {};
502
- // Information processing load
503
- breakdown.information = Math.pow(pageMetrics.informationDensity, 1.5) * (1 - p('comprehension') * 0.8);
715
+ // No information dimension since 21.0 (Stage C, decided 2026-10-23). It was
716
+ // informationDensity^1.5 x (1 - 0.8 x comprehension): text amount charged
717
+ // against grasp of UI conventions, which is not reading. Text is the `text`
718
+ // dimension below, against the bridged reading capacity. informationDensity
719
+ // stays in the signature as a reported page metric and is not read.
504
720
  // Visual processing load. PERSONA-INVARIANT, and reported as such.
505
721
  //
506
722
  // It read `visualProcessing`, which exists in no schema, so it was already a
@@ -536,7 +752,7 @@ export function estimateCognitiveLoad(persona, pageMetrics) {
536
752
  // Total: weighted sum (each dimension contributes)
537
753
  const total = Object.values(breakdown).reduce((a, b) => a + b, 0) / Object.keys(breakdown).length;
538
754
  // Find bottleneck
539
- let maxLoad = 0, bottleneck = 'information';
755
+ let maxLoad = 0, bottleneck = Object.keys(breakdown)[0];
540
756
  for (const [key, val] of Object.entries(breakdown)) {
541
757
  if (val > maxLoad) {
542
758
  maxLoad = val;
@@ -556,8 +772,9 @@ export function estimateCognitiveLoad(persona, pageMetrics) {
556
772
  // ── Coverage Optimization ──
557
773
  /**
558
774
  * Given a set of personas, find the N most different ones for maximum
559
- * test coverage. Uses greedy farthest-point sampling in Wasserstein space,
560
- * seeded with the two farthest-apart profiles.
775
+ * test coverage. Uses greedy farthest-point sampling under the generalized W1
776
+ * of cognitiveDistance (so a difference in overall level counts as well as one
777
+ * in profile shape), seeded with the two farthest-apart profiles.
561
778
  *
562
779
  * The seed used to be `profiles[0]` — whichever persona happened to be first in
563
780
  * the caller's array. Farthest-point traversal is seed-sensitive, so the same
@@ -654,6 +871,7 @@ export async function extractPerScreenMetrics(page, maxScreens = 20, geometry) {
654
871
  // The caller's geometry when it has one, so the curve length and the caption come
655
872
  // from the SAME measurement rather than two reads of a page that can move between
656
873
  // them. Measuring here is the fallback for direct callers, not the normal path.
874
+ const choiceCap = param("pageMetrics.choiceCap");
657
875
  const geo = geometry ?? (await measurePageGeometry(page, maxScreens));
658
876
  const vh = geo.viewportHeight;
659
877
  const docHeight = geo.documentHeight;
@@ -699,21 +917,82 @@ export async function extractPerScreenMetrics(page, maxScreens = 20, geometry) {
699
917
  }
700
918
  return { newInteractive, newChoices };
701
919
  });
920
+ // The law times are re-charged for the same share: what this screen
921
+ // introduces, so a sticky nav or a header search box is not acquired, chosen
922
+ // from or filled in again on every screen (a definition; exact per-element
923
+ // times would need box identities across screens). Procedure takes the
924
+ // interactive share, not a count of new entry fields: KLM charges an entry
925
+ // step once per visit, so a header search box seen on every screen is one
926
+ // step, not one per screen. Scaling this screen's steps by the share of its
927
+ // controls that are new assumes a field is as likely to be new as any other
928
+ // control. Menu labels are read once per visit too (the first read the
929
+ // search law leaves out), so a screen charges its labels' first read by the
930
+ // share of its choices that are new, like choosing; its other prose is read
931
+ // per screen as before.
932
+ const share = (newOnes, all) => (all > 0 ? Math.min(1, newOnes / all) : 0);
933
+ const pointShare = share(fresh.newInteractive, metrics.interactiveElementCount);
934
+ const chooseShare = share(fresh.newChoices, metrics.choiceCountRaw);
702
935
  out.push({
703
936
  ...metrics,
704
937
  interactiveElementCount: fresh.newInteractive,
705
- choiceCount: Math.min(fresh.newChoices, 100),
938
+ choiceCount: Math.min(fresh.newChoices, choiceCap),
706
939
  choiceCountRaw: fresh.newChoices,
707
- choiceCountCapped: fresh.newChoices > 100,
940
+ choiceCountCapped: fresh.newChoices > choiceCap,
941
+ pointingSeconds: metrics.pointingSeconds * pointShare,
942
+ choosingSeconds: metrics.choosingSeconds * chooseShare,
943
+ choosingSecondsExpert: metrics.choosingSecondsExpert * chooseShare,
944
+ procedureSeconds: metrics.procedureSeconds * pointShare,
945
+ readingSeconds: metrics.readingSeconds - metrics.lawDiagnostics.labelSeconds * (1 - chooseShare),
946
+ lawDiagnostics: { ...metrics.lawDiagnostics, labelSeconds: metrics.lawDiagnostics.labelSeconds * chooseShare },
708
947
  });
709
948
  }
710
949
  await page.evaluate(() => window.scrollTo(0, 0));
711
950
  await page.waitForTimeout(200);
712
951
  return out;
713
952
  }
714
- export async function extractPageMetrics(page, scope = "viewport",
953
+ /** The layout measurePageRaw writes, and the only one the CTC reference loads. */
954
+ export const RAW_PAGE_FORMAT = 2;
955
+ function readMeasureParams() {
956
+ const P = (id) => param(`pageMetrics.${id}`);
957
+ return {
958
+ walkMaxSteps: P("walk.maxSteps"), walkSettleMs: P("walk.settleMs"),
959
+ colourSample: P("visual.colourSample"), proseLengthAbove: P("prose.lengthAbove"),
960
+ scriptMinChars: P("script.minChars"), scriptTextShare: P("script.textShare"), scriptKeyShare: P("script.keyShare"),
961
+ lengthBins: P("raw.lengthBins"), maxBoxes: P("raw.maxBoxes"),
962
+ maxTextBlocks: P("raw.maxTextBlocks"), maxChoiceGroups: P("raw.maxChoiceGroups"),
963
+ };
964
+ }
965
+ function readDeriveParams() {
966
+ const P = (id) => param(`pageMetrics.${id}`);
967
+ return {
968
+ textNestedShare: P("text.nestedShare"), informationMidpoint: P("informationDensity.midpoint"),
969
+ colourWeight: P("visual.colourWeight"), imageWeight: P("visual.imageWeight"),
970
+ elementWeight: P("visual.elementWeight"), visualMidpoint: P("visual.midpoint"),
971
+ areaUnit: P("textDensity.areaUnit"), textDensityMidpoint: P("textDensity.midpoint"),
972
+ carouselCount: P("animation.carouselCount"), animationSaturation: P("animation.saturation"),
973
+ choiceCap: P("choiceCap"),
974
+ abjadLengthScale: P("abjad.lengthScale"), abjadLongWordMin: P("abjad.longWordMin"),
975
+ abjadTechWordMin: P("abjad.techWordMin"), abjadSentenceLengthAbove: P("abjad.sentenceLengthAbove"),
976
+ latinLongWordMin: P("latin.longWordMin"), latinTechWordMin: P("latin.techWordMin"),
977
+ alphabeticSentenceLengthAbove: P("alphabetic.sentenceLengthAbove"),
978
+ cjkCharsPerWord: P("cjk.charsPerWord"), cjkLatinEquivalentLength: P("cjk.latinEquivalentLength"),
979
+ cjkLongRunMin: P("cjk.longRunMin"), cjkTechRunMin: P("cjk.techRunMin"),
980
+ cjkLatinTechWordMin: P("cjk.latinTechWordMin"), cjkSentenceLengthAbove: P("cjk.sentenceLengthAbove"),
981
+ wordLengthOffset: P("wordLength.offset"), wordLengthSpan: P("wordLength.span"),
982
+ sentenceSaturation: P("sentenceLength.saturation"), lexicalWindow: P("lexicalDiversity.window"),
983
+ };
984
+ }
985
+ /**
986
+ * Measure the page: counts and geometry, no normaliser (RawPageMeasurement).
987
+ * The scope decides which elements count; at full_page the page is walked
988
+ * first so lazy content exists to be counted.
989
+ */
990
+ export async function measurePageRaw(page, scope = "viewport",
715
991
  /** Geometry already measured by the caller, so both derivations share one read. */
716
992
  geometry) {
993
+ // Every constant the measurement uses, read here in Node (the page cannot call
994
+ // param()) and handed to the browser side with the scope and geometry.
995
+ const P = readMeasureParams();
717
996
  // Lazy content does not exist until it has been scrolled past, so a full-page
718
997
  // measurement that never scrolls measures a page the user would never see.
719
998
  // Same five-step walk empathy_audit does for scope:"full_page", including the
@@ -727,13 +1006,13 @@ geometry) {
727
1006
  // lazy content) and it is exactly the kind that survives a fix aimed at the two
728
1007
  // obvious ones.
729
1008
  const walkGeo = geometry ?? (await measurePageGeometry(page));
730
- const steps = Math.min(5, walkGeo.screenCount);
1009
+ const steps = Math.min(P.walkMaxSteps, walkGeo.screenCount);
731
1010
  for (let i = 1; i <= steps; i++) {
732
1011
  await page.evaluate((y) => window.scrollTo(0, y), i * walkGeo.viewportHeight);
733
- await page.waitForTimeout(300);
1012
+ await page.waitForTimeout(P.walkSettleMs);
734
1013
  }
735
1014
  await page.evaluate(() => window.scrollTo(0, 0));
736
- await page.waitForTimeout(300);
1015
+ await page.waitForTimeout(P.walkSettleMs);
737
1016
  }
738
1017
  catch { /* a page that will not scroll is still measurable */ }
739
1018
  }
@@ -741,11 +1020,10 @@ geometry) {
741
1020
  // the sequential path were two derivations of one quantity from two separate reads;
742
1021
  // passing it in makes them one measurement rather than two that agree by luck.
743
1022
  const geo = geometry ?? (await measurePageGeometry(page));
744
- return page.evaluate(([measureScope, geoIn]) => {
1023
+ return page.evaluate(([measureScope, geoIn, P]) => {
745
1024
  const vw = window.innerWidth;
746
1025
  const vh = geoIn.viewportHeight;
747
1026
  const fullPage = measureScope === "full_page";
748
- const docHeight = geoIn.documentHeight;
749
1027
  // Viewport check — only count elements whose bounding box intersects the
750
1028
  // viewport. At full_page scope every laid-out element counts, so the filter
751
1029
  // becomes a zero-size check only.
@@ -758,6 +1036,26 @@ geometry) {
758
1036
  return rect.bottom > 0 && rect.top < vh &&
759
1037
  rect.right > 0 && rect.left < vw;
760
1038
  };
1039
+ const scrollX = window.scrollX || 0;
1040
+ const scrollY = window.scrollY || 0;
1041
+ const kinds = [];
1042
+ const kindIndex = new Map();
1043
+ const kindOf = (k) => {
1044
+ let i = kindIndex.get(k);
1045
+ if (i === undefined) {
1046
+ i = kinds.length;
1047
+ kinds.push(k);
1048
+ kindIndex.set(k, i);
1049
+ }
1050
+ return i;
1051
+ };
1052
+ const elementKind = (el) => {
1053
+ const tag = el.tagName.toLowerCase();
1054
+ const type = tag === 'input' ? (el.getAttribute('type') || 'text').toLowerCase() : '';
1055
+ const role = el.getAttribute('role');
1056
+ return `${tag}${type ? `:${type}` : ''}${role ? `[${role}]` : ''}`;
1057
+ };
1058
+ const half = (x) => (Number.isFinite(x) ? Math.round(x * 2) / 2 : 0);
761
1059
  // Gather only viewport-visible text
762
1060
  const textEls = Array.from(document.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, td, th, span, div, a, button, label'));
763
1061
  let viewportTextLength = 0;
@@ -768,27 +1066,158 @@ geometry) {
768
1066
  viewportTextLength += text.length;
769
1067
  }
770
1068
  });
771
- // Deduplicate: rough estimate — child text counted in parent. Use 40% factor.
772
- const textLength = Math.round(viewportTextLength * 0.4);
773
- // Area to normalise density against. Counting whole-page text and dividing
774
- // by one screen would report a density the page does not have.
775
- const viewportArea = vw * (fullPage ? docHeight : vh);
776
- // Interactive elements — viewport only
1069
+ // Interactive elements — viewport only, with their boxes
777
1070
  const interactive = Array.from(document.querySelectorAll('a, button, input, select, textarea, [role="button"], [onclick], [tabindex]'));
778
1071
  let interactiveCount = 0;
779
- interactive.forEach(el => { if (inViewport(el))
780
- interactiveCount++; });
1072
+ const boxes = [];
1073
+ interactive.forEach(el => {
1074
+ if (!inViewport(el))
1075
+ return;
1076
+ if (interactiveCount < P.maxBoxes) {
1077
+ const r = el.getBoundingClientRect();
1078
+ boxes.push(kindOf(elementKind(el)), Math.round(r.left + scrollX), Math.round(r.top + scrollY), Math.round(r.width), Math.round(r.height));
1079
+ }
1080
+ interactiveCount++;
1081
+ });
781
1082
  // Links and choices — viewport only
782
1083
  const links = Array.from(document.querySelectorAll('a[href]'));
783
1084
  const selects = Array.from(document.querySelectorAll('select'));
784
- let choiceCount = 0;
1085
+ let linkCount = 0;
1086
+ let optionCount = 0;
785
1087
  links.forEach(el => { if (inViewport(el))
786
- choiceCount++; });
1088
+ linkCount++; });
1089
+ selects.forEach(s => { if (inViewport(s))
1090
+ optionCount += s.querySelectorAll('option').length; });
1091
+ // Choice groups: what the choices are offered in.
1092
+ const choiceGroups = [];
1093
+ let choiceGroupCount = 0;
1094
+ // Format 1's count, for comparison: every group, nested ones again.
1095
+ let nestedGroups = 0, nestedItems = 0;
1096
+ const addGroup = (kind, size, nestedSize = size) => {
1097
+ if (nestedSize > 0) {
1098
+ nestedGroups++;
1099
+ nestedItems += nestedSize;
1100
+ }
1101
+ if (size <= 0)
1102
+ return;
1103
+ if (choiceGroupCount < P.maxChoiceGroups)
1104
+ choiceGroups.push(kindOf(kind), size);
1105
+ choiceGroupCount++;
1106
+ };
1107
+ // Each choice in one group only: the outermost that offers it. A nav's
1108
+ // links are its visible a[href]; a menu, menubar, listbox, tablist or
1109
+ // radiogroup's are its visible items, each read as the link inside it
1110
+ // when it has one, so an item and its link are one choice. Groups are
1111
+ // decided in document order, every ancestor before its descendants, so a
1112
+ // menubar inside a nav (or a submenu inside the menubar, or a nav inside a
1113
+ // navigation region) keeps only the choices no outer group took, and is
1114
+ // no group at all when that is none.
1115
+ const NAV = 'nav, [role="navigation"]';
1116
+ const COMPOSITE = '[role="menu"], [role="menubar"], [role="listbox"], [role="tablist"], [role="radiogroup"]';
1117
+ const ITEM = '[role^="menuitem"], [role="option"], [role="tab"], [role="radio"]';
1118
+ const claimed = new Set();
1119
+ // The groups in scope, kept for the prose pass: the prose inside them is
1120
+ // menu labels, read as choices, and is reported apart (proseInNav).
1121
+ const scopedGroups = new Set();
1122
+ const navGroups = [];
1123
+ const compositeGroups = [];
1124
+ document.querySelectorAll(`${NAV}, ${COMPOSITE}`).forEach(g => {
1125
+ if (!inViewport(g))
1126
+ return;
1127
+ scopedGroups.add(g);
1128
+ const nav = g.matches(NAV);
1129
+ const offered = nav
1130
+ ? Array.from(g.querySelectorAll('a[href]')).filter(inViewport)
1131
+ : Array.from(g.querySelectorAll(ITEM)).filter(inViewport).map(x => (x.matches('a[href]') ? x : x.querySelector('a[href]') || x));
1132
+ let own = 0;
1133
+ for (const c of new Set(offered)) {
1134
+ if (claimed.has(c))
1135
+ continue;
1136
+ claimed.add(c);
1137
+ own++;
1138
+ }
1139
+ if (nav)
1140
+ navGroups.push([own, offered.length]);
1141
+ else
1142
+ compositeGroups.push([g.getAttribute('role') || '', own, offered.length]);
1143
+ });
1144
+ // Uncapped, so links minus groupedLinks is the links in no group however
1145
+ // many groups the page has (choiceGroups stores the first maxChoiceGroups).
1146
+ const navGroupCount = navGroups.filter(([own]) => own > 0).length;
1147
+ const navLinks = navGroups.reduce((n, [own]) => n + own, 0);
1148
+ const groupedLinkList = Array.from(claimed).filter(c => c.matches('a[href]') && inViewport(c));
1149
+ const groupedLinks = groupedLinkList.length;
1150
+ // Of those, the links that show visible text: at least one letter or
1151
+ // digit (any script, so an abjad word, a CJK character or a page number)
1152
+ // in text that is drawn.
1153
+ // aria-label, title and alt are attributes and never reach this; an svg
1154
+ // <title> is not drawn, so it has no box. A link is in scope by the rule
1155
+ // every count here uses (inViewport); its text must also be visible
1156
+ // inside it: hidden, transparent or sub-pixel text does not count, nor
1157
+ // text clipped away by a box inside the link (the visually-hidden
1158
+ // pattern, a 1px clipped span, and the off-screen text indent), however
1159
+ // innerText would report it.
1160
+ const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
1161
+ const textDrawn = (node, link) => {
1162
+ if (!LETTER_OR_DIGIT.test(node.nodeValue || ''))
1163
+ return false;
1164
+ const range = document.createRange();
1165
+ range.selectNodeContents(node);
1166
+ const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0);
1167
+ if (!rects.length)
1168
+ return false;
1169
+ let left = Math.min(...rects.map(r => r.left)), top = Math.min(...rects.map(r => r.top));
1170
+ let right = Math.max(...rects.map(r => r.right)), bottom = Math.max(...rects.map(r => r.bottom));
1171
+ for (let el = node.parentElement; el; el = el.parentElement) {
1172
+ const cs = getComputedStyle(el);
1173
+ if (cs.visibility !== 'visible' || parseFloat(cs.opacity) === 0 || parseFloat(cs.fontSize) < 1)
1174
+ return false;
1175
+ if (/inset\(\s*50%/.test(cs.clipPath) || /^rect\(0px,? 0px,? 0px,? 0px\)$/.test(cs.clip))
1176
+ return false;
1177
+ if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible' || cs.clip !== 'auto' || cs.clipPath !== 'none') {
1178
+ const r = el.getBoundingClientRect();
1179
+ left = Math.max(left, r.left);
1180
+ top = Math.max(top, r.top);
1181
+ right = Math.min(right, r.right);
1182
+ bottom = Math.min(bottom, r.bottom);
1183
+ if (right - left < 2 || bottom - top < 2)
1184
+ return false;
1185
+ }
1186
+ if (el === link)
1187
+ break;
1188
+ }
1189
+ return true;
1190
+ };
1191
+ const readable = (link) => {
1192
+ const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT);
1193
+ for (let n = walker.nextNode(); n; n = walker.nextNode())
1194
+ if (textDrawn(n, link))
1195
+ return true;
1196
+ return false;
1197
+ };
1198
+ const groupedLinksWithText = groupedLinkList.filter(readable).length;
1199
+ navGroups.forEach(([own, offered]) => addGroup('nav', own, offered));
787
1200
  selects.forEach(s => { if (inViewport(s))
788
- choiceCount += s.querySelectorAll('option').length; });
1201
+ addGroup('select', s.querySelectorAll('option').length); });
1202
+ const toggles = new Map();
1203
+ let unnamed = 0;
1204
+ document.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(el => {
1205
+ if (!inViewport(el))
1206
+ return;
1207
+ const kind = (el.getAttribute('type') || '').toLowerCase();
1208
+ const name = el.getAttribute('name');
1209
+ const key = name ? `${kind}\u0000${name}` : `${kind}\u0001${unnamed++}`;
1210
+ const g = toggles.get(key);
1211
+ if (g)
1212
+ g.size++;
1213
+ else
1214
+ toggles.set(key, { kind, size: 1 });
1215
+ });
1216
+ toggles.forEach(g => addGroup(g.kind, g.size));
1217
+ compositeGroups.forEach(([kind, own, offered]) => addGroup(kind, own, offered));
789
1218
  // Animations — count only ACTUAL running animations, not CSS transition utilities
790
1219
  // WCAG 2.3.3 cares about content that moves/blinks automatically, not hover transitions
791
- let animCount = 0;
1220
+ let runningAnimations = 0;
792
1221
  document.querySelectorAll('*').forEach(el => {
793
1222
  if (!inViewport(el))
794
1223
  return;
@@ -797,18 +1226,18 @@ geometry) {
797
1226
  if (style.animationName && style.animationName !== 'none' &&
798
1227
  style.animationPlayState === 'running' &&
799
1228
  style.animationDuration !== '0s' && style.animationDuration !== '0.01ms') {
800
- animCount++;
1229
+ runningAnimations++;
801
1230
  }
802
1231
  });
803
1232
  // Also count auto-playing media
1233
+ let autoplayElements = 0;
804
1234
  document.querySelectorAll('video[autoplay], [autoplay]').forEach(el => {
805
1235
  if (inViewport(el))
806
- animCount++;
1236
+ autoplayElements++;
807
1237
  });
808
1238
  const hasCarousel = !!document.querySelector('[class*="carousel"], [class*="slider"], [class*="swiper"]');
809
1239
  // Navigation depth (breadcrumbs — these are structural, keep full-page)
810
1240
  const breadcrumbs = document.querySelectorAll('[class*="breadcrumb"] a, nav[aria-label*="breadcrumb"] a');
811
- const navDepth = Math.max(1, breadcrumbs.length);
812
1241
  // Visual complexity — sample viewport-visible elements only
813
1242
  const all = document.querySelectorAll('*');
814
1243
  const uniqueColors = new Set();
@@ -821,7 +1250,7 @@ geometry) {
821
1250
  if (!inViewport(all[i]))
822
1251
  continue;
823
1252
  visibleCount++;
824
- if (visibleCount <= 50) {
1253
+ if (visibleCount <= P.colourSample) {
825
1254
  const cs = getComputedStyle(all[i]);
826
1255
  uniqueColors.add(cs.backgroundColor);
827
1256
  uniqueColors.add(cs.color);
@@ -830,26 +1259,81 @@ geometry) {
830
1259
  if (tag === 'IMG' || tag === 'SVG' || all[i].getAttribute('role') === 'img')
831
1260
  imageCount++;
832
1261
  }
833
- // Normalize to 0-1 using logarithmic scaling
834
- const logScale = (value, midpoint) => {
835
- if (value <= 0)
836
- return 0;
837
- return value / (value + midpoint);
838
- };
839
- const informationDensity = logScale(textLength, 5000); // Viewport text: 0.5 at 5K chars
840
- const visualComplexity = logScale(uniqueColors.size * 3 + imageCount * 2 + visibleCount * 0.5, 200);
841
- const textDensity = logScale(textLength / Math.max(1, viewportArea * 0.001), 3);
842
- const animationLevel = Math.min(1, (animCount + (hasCarousel ? 3 : 0)) / 10);
843
1262
  // ── Text Readability Analysis (v18.56) ──
844
1263
  // Extract actual visible text for linguistic analysis
1264
+ const cjkRegex = /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/;
1265
+ const cjkCharRegex = /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/g;
845
1266
  const visibleTextParts = [];
1267
+ const textBlocks = [];
846
1268
  const proseSelectors = 'p, li, h1, h2, h3, h4, h5, h6, td, th, blockquote, figcaption';
1269
+ // Each visible character once: an element counts only when no element
1270
+ // above it already counted, so a p inside an li (or an li inside a td) is
1271
+ // read as part of its outermost container, never again on its own.
1272
+ // querySelectorAll is in document order, so every ancestor is decided
1273
+ // before its descendants. An ancestor that did not count (not laid out, or
1274
+ // too short) leaves its descendants free to.
1275
+ const counted = new Set();
1276
+ const insideCounted = (el) => {
1277
+ for (let a = el.parentElement?.closest(proseSelectors); a; a = a.parentElement?.closest(proseSelectors)) {
1278
+ if (counted.has(a))
1279
+ return true;
1280
+ }
1281
+ return false;
1282
+ };
1283
+ const wordsIn = (t) => t.split(/\s+/).filter(w => w.length > 0).length;
1284
+ // The part of each counted block that sits in a nav or composite group in
1285
+ // scope (the groups choiceGroups counts): the whole block when a group
1286
+ // holds it (a menu's li), or the text of the outermost groups it holds (a
1287
+ // layout td around a nav). Kept as text, so it is tokenised below as the
1288
+ // prose is.
1289
+ const GROUP = `${NAV}, ${COMPOSITE}`;
1290
+ const inScopedGroup = (el) => {
1291
+ for (let a = el.closest(GROUP); a; a = a.parentElement?.closest(GROUP)) {
1292
+ if (scopedGroups.has(a))
1293
+ return true;
1294
+ }
1295
+ return false;
1296
+ };
1297
+ const navParts = [];
1298
+ let proseChars = 0, proseWords = 0;
1299
+ let nestedElements = 0, nestedChars = 0, nestedWords = 0;
847
1300
  document.querySelectorAll(proseSelectors).forEach(el => {
848
- if (inViewport(el)) {
849
- const t = el.innerText?.trim();
850
- if (t && t.length > 5)
851
- visibleTextParts.push(t);
1301
+ if (!inViewport(el))
1302
+ return;
1303
+ const t = el.innerText?.trim();
1304
+ if (!t || t.length <= P.proseLengthAbove)
1305
+ return;
1306
+ const words = wordsIn(t);
1307
+ // Format 1's count, for comparison: every matching element.
1308
+ nestedElements++;
1309
+ nestedChars += t.length;
1310
+ nestedWords += words;
1311
+ if (insideCounted(el))
1312
+ return;
1313
+ counted.add(el);
1314
+ proseChars += t.length;
1315
+ proseWords += words;
1316
+ if (inScopedGroup(el)) {
1317
+ navParts.push(t);
1318
+ }
1319
+ else {
1320
+ const held = [];
1321
+ el.querySelectorAll(GROUP).forEach(g => {
1322
+ if (scopedGroups.has(g) && !held.some(o => o.contains(g)))
1323
+ held.push(g);
1324
+ });
1325
+ for (const g of held) {
1326
+ const gt = g.innerText?.trim();
1327
+ if (gt)
1328
+ navParts.push(gt);
1329
+ }
1330
+ }
1331
+ if (visibleTextParts.length < P.maxTextBlocks) {
1332
+ const cs = getComputedStyle(el);
1333
+ const r = el.getBoundingClientRect();
1334
+ textBlocks.push(t.length, words, (t.match(cjkCharRegex) || []).length, t.split(/[.。؟!!?؛]+|\n{2,}/).filter(s => s.trim().length > 0).length, half(parseFloat(cs.fontSize)), half(parseFloat(cs.lineHeight)), Math.round(r.width), Math.round(r.height));
852
1335
  }
1336
+ visibleTextParts.push(t);
853
1337
  });
854
1338
  const visibleText = visibleTextParts.join(' ');
855
1339
  // ── Script detection ──
@@ -857,17 +1341,65 @@ geometry) {
857
1341
  // 1. CJK logographic (Chinese, Japanese, Korean) — no word boundaries, character = morpheme
858
1342
  // 2. Abjad (Arabic, Hebrew, Persian, Urdu) — short stems, root-based morphology, RTL
859
1343
  // 3. Alphabetic (Latin, Cyrillic, Greek, Devanagari) — space-delimited, variable word length
860
- // CJK characters
861
- const cjkRegex = /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/;
862
1344
  // Arabic/Hebrew/Persian/Urdu characters
863
- const abjadRegex = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0590-\u05FF]/;
864
- const abjadCharRegex = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0590-\u05FF]/g;
865
- const cjkCharRegex = /[\u3000-\u9fff\uac00-\ud7af\uff00-\uffef]/g;
1345
+ const abjadRegex = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff\u0590-\u05ff]/;
1346
+ const abjadCharRegex = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff\u0590-\u05ff]/g;
1347
+ // What a word keeps before its letters are counted, per script; shared by the
1348
+ // prose and its in-group part (proseInNav) so both count letters alike.
1349
+ const ABJAD_CLEAN = /[^\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff\u0590-\u05ffa-zA-Z\u00c0-\u00ff]/g;
1350
+ const LATIN_CLEAN = /[^a-zA-ZÀ-ÿÀ-ɏḀ-ỿ]/g;
866
1351
  const cjkCharCount = (visibleText.match(cjkCharRegex) || []).length;
867
1352
  const totalCharCount = visibleText.replace(/\s/g, '').length;
868
- // Also check key page elements independently (Tolgee translations fire faster than GT)
869
- const h1Text = document.querySelector('h1')?.textContent || '';
870
- const navText = document.querySelector('nav')?.textContent || '';
1353
+ // Also check key page elements independently (Tolgee translations fire
1354
+ // faster than GT): the first h1 and the first nav. Neither may count text
1355
+ // the page declares to be in ANOTHER language, and a language picker is
1356
+ // not the nav: www.w3.org's first nav lists 日本語 and 简体中文 editions,
1357
+ // and the rule read the whole page as CJK.
1358
+ //
1359
+ // A text's language is the nearest lang attribute above it, or, failing
1360
+ // that, the hreflang of a link it sits in, or the page's own (html, then
1361
+ // body). It is another language when its primary subtag differs.
1362
+ const primary = (tag) => (tag || '').trim().toLowerCase().split(/[-_]/)[0];
1363
+ const pageLang = primary(document.documentElement.getAttribute('lang') || document.body?.getAttribute('lang'));
1364
+ const otherLanguage = (el) => {
1365
+ for (let a = el; a; a = a.parentElement) {
1366
+ if (a.hasAttribute('lang'))
1367
+ return primary(a.getAttribute('lang')) !== pageLang;
1368
+ if (a.tagName === 'A' && a.hasAttribute('hreflang'))
1369
+ return primary(a.getAttribute('hreflang')) !== pageLang;
1370
+ }
1371
+ return false;
1372
+ };
1373
+ // The text of an element as textContent reads it (hidden text included,
1374
+ // as before), less scripts, styles and other-language text.
1375
+ const keyText = (root) => {
1376
+ if (!root)
1377
+ return '';
1378
+ let out = '';
1379
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1380
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
1381
+ const parent = n.parentElement;
1382
+ if (!parent || parent.closest('script, style, noscript, template') || otherLanguage(parent))
1383
+ continue;
1384
+ out += n.nodeValue || '';
1385
+ }
1386
+ return out;
1387
+ };
1388
+ // A language picker: named so (aria-label, its labelling element, id,
1389
+ // class, title), or most of its links lead to another-language edition.
1390
+ const PICKER_NAME = /(^|[^a-z])(lang|langs|language|languages|locale|locales|i18n)([^a-z]|$)/i;
1391
+ const isLanguagePicker = (nav) => {
1392
+ const labelledBy = (nav.getAttribute('aria-labelledby') || '').split(/\s+/)
1393
+ .map(id => (id ? document.getElementById(id)?.textContent || '' : '')).join(' ');
1394
+ const name = [nav.getAttribute('aria-label'), labelledBy, nav.id, nav.getAttribute('class'), nav.getAttribute('title')].join(' ');
1395
+ if (PICKER_NAME.test(name))
1396
+ return true;
1397
+ const links = Array.from(nav.querySelectorAll('a[href]'));
1398
+ const elsewhere = links.filter(a => otherLanguage(a) || (a.hasAttribute('hreflang') && primary(a.getAttribute('hreflang')) !== pageLang));
1399
+ return links.length > 0 && elsewhere.length * 2 >= links.length;
1400
+ };
1401
+ const h1Text = keyText(document.querySelector('h1'));
1402
+ const navText = keyText(Array.from(document.querySelectorAll('nav')).find(nav => !isLanguagePicker(nav)));
871
1403
  const keyElementsCJK = (h1Text.match(cjkCharRegex) || []).length + (navText.match(cjkCharRegex) || []).length;
872
1404
  const keyElementsTotal = (h1Text + navText).replace(/\s/g, '').length;
873
1405
  // Abjad detection (Arabic, Hebrew, etc.)
@@ -877,21 +1409,26 @@ geometry) {
877
1409
  // - >30% of visible text is CJK, OR
878
1410
  // - >20% of key elements (h1 + nav) are CJK (catches partial translation)
879
1411
  // - AND at least 5 CJK characters present (avoid false positives on single emoji)
880
- const isCJKDominant = cjkCharCount >= 5 && ((totalCharCount > 0 && cjkCharCount / totalCharCount > 0.3) ||
881
- (keyElementsTotal > 0 && keyElementsCJK / keyElementsTotal > 0.2));
1412
+ const isCJKDominant = cjkCharCount >= P.scriptMinChars && ((totalCharCount > 0 && cjkCharCount / totalCharCount > P.scriptTextShare) ||
1413
+ (keyElementsTotal > 0 && keyElementsCJK / keyElementsTotal > P.scriptKeyShare));
882
1414
  // Consider Abjad if:
883
1415
  // - >30% of visible text is abjad, OR
884
1416
  // - >20% of key elements are abjad
885
1417
  // - AND at least 5 abjad characters present
886
- const isAbjadDominant = !isCJKDominant && abjadCharCount >= 5 && ((totalCharCount > 0 && abjadCharCount / totalCharCount > 0.3) ||
887
- (keyElementsTotal > 0 && keyElementsAbjad / keyElementsTotal > 0.2));
888
- let wordCount;
889
- let totalWordLength;
890
- let longWords;
891
- let techWords;
1418
+ const isAbjadDominant = !isCJKDominant && abjadCharCount >= P.scriptMinChars && ((totalCharCount > 0 && abjadCharCount / totalCharCount > P.scriptTextShare) ||
1419
+ (keyElementsTotal > 0 && keyElementsAbjad / keyElementsTotal > P.scriptKeyShare));
1420
+ // Lengths are kept as histograms so every length threshold is applied in
1421
+ // deriveMetrics. Word lengths exactly; run and fragment lengths, which only
1422
+ // ever meet a threshold, share one bin from lengthBins up.
1423
+ const tally = (m, len) => m.set(len, (m.get(len) || 0) + 1);
1424
+ const binned = (len) => Math.min(len, P.lengthBins);
1425
+ const pairs = (m) => Array.from(m.entries()).sort((a, b) => a[0] - b[0]);
1426
+ const wordLengths = new Map();
1427
+ const abjadWordLengths = new Map();
1428
+ const cjkRunLengths = new Map();
1429
+ const fragmentLengths = new Map();
892
1430
  const uniqueWords = new Set();
893
- let sentenceCount;
894
- let avgWordsPerSentence;
1431
+ let sentenceSplit;
895
1432
  if (isAbjadDominant) {
896
1433
  // ── Abjad text analysis (Arabic, Hebrew, Persian, Urdu) ──
897
1434
  // Arabic/Hebrew use spaces between words but have:
@@ -899,138 +1436,441 @@ geometry) {
899
1436
  // - Root-based morphology — complex words look short
900
1437
  // - Technical terms are multi-word phrases, not compound words
901
1438
  // - Diacritics (tashkeel) and ligatures affect visual complexity
902
- const abjadWords = visibleText.split(/\s+/).filter(w => w.length > 0);
903
- wordCount = abjadWords.length;
904
- totalWordLength = 0;
905
- longWords = 0;
906
- techWords = 0;
907
- for (const word of abjadWords) {
1439
+ for (const word of visibleText.split(/\s+/).filter(w => w.length > 0)) {
908
1440
  // Count only abjad + Latin characters (strip punctuation, numbers)
909
- const clean = word.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0590-\u05FFa-zA-ZÀ-ÿ]/g, '');
910
- const len = clean.length;
911
- // Arabic stems are 3-4 chars. Normalize: Arabic 3-char word → equivalent
912
- // of 5 Latin chars for readability (higher information per character than Latin)
913
- const isAbjadWord = abjadRegex.test(clean);
914
- const normalizedLen = isAbjadWord ? Math.round(len * 1.6) : len;
915
- totalWordLength += normalizedLen;
916
- // "Long" in abjad: 5+ characters (stems with prefixes/suffixes)
917
- // Equivalent to 8+ Latin chars
918
- if (isAbjadWord ? len >= 5 : len >= 7)
919
- longWords++;
920
- // "Technical" in abjad: 6+ character words or Latin technical terms
921
- // Arabic technical vocab like المعرفية (7 chars) or الشخصيات (8 chars)
922
- if (isAbjadWord ? len >= 6 : len >= 10)
923
- techWords++;
1441
+ const clean = word.replace(ABJAD_CLEAN, '');
1442
+ // An abjad word's length is mapped to a Latin equivalent in deriveMetrics
1443
+ // (short root-based stems carry more per character); the rest count as Latin.
1444
+ tally(abjadRegex.test(clean) ? abjadWordLengths : wordLengths, clean.length);
924
1445
  uniqueWords.add(clean);
925
1446
  }
926
1447
  // Sentence analysis: Arabic uses ، (comma) and . (period) and ؟ and ؛
927
- const abjadSentences = visibleText.split(/[.。؟!!?؛]+|\n{2,}/).filter(s => s.trim().length > 5);
928
- sentenceCount = Math.max(1, abjadSentences.length);
929
- avgWordsPerSentence = wordCount / sentenceCount;
1448
+ sentenceSplit = /[.。؟!!?؛]+|\n{2,}/;
930
1449
  }
931
1450
  else if (isCJKDominant) {
932
1451
  // ── CJK text analysis ──
933
1452
  // CJK languages don't use spaces between words. Each character is roughly
934
- // one morpheme. We estimate word boundaries using bigram/trigram heuristics.
935
- // Estimate word count: CJK averages ~1.5-2 characters per "word"
936
- // (Chinese ~1.5, Japanese ~2 with particles, Korean ~2 with jamo)
937
- const cjkWordEstimate = Math.round(cjkCharCount / 1.8);
938
- // Latin words in CJK text (English loanwords, technical terms)
939
- const latinWords = visibleText.split(/\s+/).filter(w => /^[a-zA-Z]/.test(w));
940
- wordCount = cjkWordEstimate + latinWords.length;
941
- // Average "word" length in CJK: 1.8 chars per word normalized to same scale
942
- // CJK characters carry more meaning per character than Latin.
943
- // A 2-character CJK word ≈ 5-7 character English word in information density.
944
- // Normalize: CJK 2-char word → equivalent of ~6 Latin chars for readability
945
- totalWordLength = cjkWordEstimate * 6 + latinWords.reduce((sum, w) => sum + w.length, 0);
946
- // "Long words" in CJK: compound terms are 3-4+ characters (equivalent to 7+ Latin)
947
- // Extract runs of CJK characters and count those ≥ 3 characters
948
- const cjkRuns = visibleText.match(/[\u3000-\u9fff\uac00-\ud7af]{3,}/g) || [];
949
- longWords = cjkRuns.filter(r => r.length >= 3).length;
950
- // "Technical terms" in CJK: 4+ character compounds or Latin technical terms
951
- const cjkTechRuns = cjkRuns.filter(r => r.length >= 4);
952
- const latinTechWords = latinWords.filter(w => w.length >= 8);
953
- techWords = cjkTechRuns.length + latinTechWords.length;
1453
+ // one morpheme; deriveMetrics estimates the word count from the character
1454
+ // count. Latin words in CJK text (English loanwords, technical terms) are
1455
+ // counted as written.
1456
+ for (const w of visibleText.split(/\s+/).filter(w => /^[a-zA-Z]/.test(w))) {
1457
+ tally(wordLengths, w.length);
1458
+ uniqueWords.add(w.toLowerCase());
1459
+ }
1460
+ // "Long words" and "technical terms" in CJK are compounds: maximal runs of
1461
+ // CJK characters, kept by length.
1462
+ for (const run of visibleText.match(/[\u3000-\u9fff\uac00-\ud7af]+/g) || [])
1463
+ tally(cjkRunLengths, binned(run.length));
954
1464
  // Lexical diversity: use unique CJK bigrams as proxy for vocabulary
955
1465
  for (let i = 0; i < visibleText.length - 1; i++) {
956
1466
  if (cjkRegex.test(visibleText[i]) && cjkRegex.test(visibleText[i + 1])) {
957
1467
  uniqueWords.add(visibleText[i] + visibleText[i + 1]);
958
1468
  }
959
1469
  }
960
- for (const w of latinWords)
961
- uniqueWords.add(w.toLowerCase());
962
1470
  // Sentence analysis: CJK uses 。!? as sentence enders
963
- const cjkSentences = visibleText.split(/[。!?.!?]+|\n{2,}/).filter(s => s.trim().length > 3);
964
- sentenceCount = Math.max(1, cjkSentences.length);
965
- // CJK sentences tend to be shorter in word count but longer in information density
966
- avgWordsPerSentence = wordCount / sentenceCount;
1471
+ sentenceSplit = /[。!?.!?]+|\n{2,}/;
967
1472
  }
968
1473
  else {
969
1474
  // ── Alphabetic text analysis ──
970
- const words_arr = visibleText.split(/\s+/).filter(w => w.length > 0);
971
- wordCount = words_arr.length;
972
- totalWordLength = 0;
973
- longWords = 0;
974
- techWords = 0;
975
- for (const word of words_arr) {
976
- const clean = word.replace(/[^a-zA-ZÀ-ÿ\u00C0-\u024F\u1E00-\u1EFF]/g, '');
977
- totalWordLength += clean.length;
978
- if (clean.length >= 7)
979
- longWords++;
980
- if (clean.length >= 10)
981
- techWords++;
1475
+ for (const word of visibleText.split(/\s+/).filter(w => w.length > 0)) {
1476
+ const clean = word.replace(LATIN_CLEAN, '');
1477
+ tally(wordLengths, clean.length);
982
1478
  uniqueWords.add(clean.toLowerCase());
983
1479
  }
984
- const sentences_arr = visibleText.split(/[.!?]+|\n{2,}/).filter(s => s.trim().length > 10);
985
- sentenceCount = Math.max(1, sentences_arr.length);
986
- avgWordsPerSentence = wordCount / sentenceCount;
1480
+ sentenceSplit = /[.!?]+|\n{2,}/;
1481
+ }
1482
+ for (const s of visibleText.split(sentenceSplit))
1483
+ tally(fragmentLengths, binned(s.trim().length));
1484
+ // The in-group part in the reading law's units, as the branches above count
1485
+ // the prose: letters of the words they keep as Latin, abjad words, CJK
1486
+ // characters (CJK only when the page reads as CJK).
1487
+ const inGroupText = navParts.join(' ');
1488
+ let navLetters = 0, navAbjadWords = 0, navCjkChars = 0;
1489
+ const navWordList = inGroupText.split(/\s+/).filter(w => w.length > 0);
1490
+ if (isAbjadDominant) {
1491
+ for (const word of navWordList) {
1492
+ const clean = word.replace(ABJAD_CLEAN, '');
1493
+ if (abjadRegex.test(clean))
1494
+ navAbjadWords++;
1495
+ else
1496
+ navLetters += clean.length;
1497
+ }
987
1498
  }
988
- // ── Normalize to 0-1 (script-aware) ──
989
- // Average word length: Latin 4→0.2, 6→0.5, 9→0.8
990
- // CJK equivalent: normalized through the 6-char equivalence above
991
- const rawAvgWordLen = wordCount > 0 ? totalWordLength / wordCount : 0;
992
- const avgWordLength = Math.min(1, Math.max(0, (rawAvgWordLen - 3) / 7.5));
993
- // Average sentence length: 10 words → 0.3, 20 → 0.6, 35+ → 0.9
994
- const avgSentenceLength = Math.min(1, avgWordsPerSentence / 40);
995
- // Lexical diversity: unique items / total (capped at 100)
996
- const lexicalDiversity = wordCount > 0 ? Math.min(1, uniqueWords.size / Math.min(wordCount, 100)) : 0;
997
- // Long word ratio
998
- const longWordRatio = wordCount > 0 ? longWords / wordCount : 0;
999
- // Technical density
1000
- const technicalDensity = wordCount > 0 ? techWords / wordCount : 0;
1499
+ else if (isCJKDominant) {
1500
+ for (const w of navWordList)
1501
+ if (/^[a-zA-Z]/.test(w))
1502
+ navLetters += w.length;
1503
+ navCjkChars = (inGroupText.match(cjkCharRegex) || []).length;
1504
+ }
1505
+ else {
1506
+ for (const word of navWordList)
1507
+ navLetters += word.replace(LATIN_CLEAN, '').length;
1508
+ }
1509
+ const proseInNav = {
1510
+ chars: navParts.reduce((n, part) => n + part.length, 0),
1511
+ words: navWordList.length,
1512
+ letters: navLetters,
1513
+ abjadWords: navAbjadWords,
1514
+ cjkChars: navCjkChars,
1515
+ };
1001
1516
  return {
1002
- informationDensity,
1003
- visualComplexity,
1004
- interactiveElementCount: interactiveCount,
1005
- textDensity,
1006
- animationLevel,
1007
- // Capped for the demand math, which saturates well before 100 anyway
1008
- // (choices/(choices+10) is 0.91 at 100 and 0.96 at 235). The raw count is
1009
- // emitted beside it because a silently truncated number is
1010
- // indistinguishable from a real one -- at viewport scope the cap was
1011
- // unreachable, and at full_page the CU Denver homepage hits it.
1012
- choiceCount: Math.min(choiceCount, 100),
1013
- choiceCountRaw: choiceCount,
1014
- choiceCountCapped: choiceCount > 100,
1015
- navigationDepth: navDepth,
1016
- avgWordLength,
1017
- avgSentenceLength,
1018
- lexicalDiversity,
1019
- longWordRatio,
1020
- technicalDensity,
1021
- scriptFamily: isCJKDominant ? 'cjk' : isAbjadDominant ? 'abjad' : 'alphabetic',
1022
- // Emitted so a caller can never again be unable to tell which of these
1023
- // two very different measurements it is holding.
1517
+ format: 2,
1024
1518
  scope: measureScope,
1519
+ viewportWidth: vw,
1520
+ viewportHeight: vh,
1521
+ documentHeight: geoIn.documentHeight,
1025
1522
  // From the shared measurement, so it can never disagree with the screenCount
1026
1523
  // the sequential curve was scored over.
1027
1524
  pageScreens: geoIn.pageScreens,
1525
+ scrollY,
1526
+ textChars: viewportTextLength,
1527
+ interactiveElements: interactiveCount,
1528
+ links: linkCount,
1529
+ selectOptions: optionCount,
1530
+ runningAnimations,
1531
+ autoplayElements,
1532
+ carousel: hasCarousel,
1533
+ breadcrumbLinks: breadcrumbs.length,
1534
+ colours: uniqueColors.size,
1535
+ visibleElements: visibleCount,
1536
+ images: imageCount,
1537
+ text: {
1538
+ script: isCJKDominant ? 'cjk' : isAbjadDominant ? 'abjad' : 'alphabetic',
1539
+ wordLengths: pairs(wordLengths),
1540
+ abjadWordLengths: pairs(abjadWordLengths),
1541
+ cjkChars: isCJKDominant ? cjkCharCount : 0,
1542
+ cjkRunLengths: pairs(cjkRunLengths),
1543
+ uniqueWords: uniqueWords.size,
1544
+ fragmentLengths: pairs(fragmentLengths),
1545
+ lengthBins: P.lengthBins,
1546
+ },
1547
+ prose: { chars: proseChars, words: proseWords },
1548
+ proseInNav,
1549
+ proseNested: { elements: nestedElements, chars: nestedChars, words: nestedWords },
1550
+ kinds,
1551
+ boxes,
1552
+ choiceGroups,
1553
+ choiceGroupCount,
1554
+ navGroupCount,
1555
+ navLinks,
1556
+ groupedLinks,
1557
+ groupedLinksWithText,
1558
+ choiceNested: { groups: nestedGroups, items: nestedItems },
1559
+ textBlocks,
1560
+ textBlockCount: visibleTextParts.length,
1028
1561
  };
1029
- }, [scope, geo]);
1562
+ }, [scope, geo, P]);
1563
+ }
1564
+ /**
1565
+ * Turn a RawPageMeasurement into PageMetrics with the registered normalisers:
1566
+ * exactly what extractPageMetrics returned when it did both in the page. Pure
1567
+ * and synchronous, so a stored measurement can be re-derived under other
1568
+ * constants (the CTC reference set is). A length threshold at or above the
1569
+ * record's lengthBins cannot be re-derived from it and throws.
1570
+ *
1571
+ * Both layouts derive: format 2, and format 1 (RawPageMeasurementV1), whose
1572
+ * fields this reads are the same but for groupedLinks and which derives as it
1573
+ * did, its nested-prose counts included; its ungrouped links come from the
1574
+ * stored nav sizes. A format-2 record without groupedLinks (measured before
1575
+ * the nav totals) and any other format are refused rather than guessed at.
1576
+ */
1577
+ /** Numbers per entry in RawPageMeasurement.boxes and .choiceGroups (the record's layout, not model constants). */
1578
+ const RAW_BOX_FIELDS = 5;
1579
+ const RAW_GROUP_FIELDS = 2;
1580
+ /**
1581
+ * Input types whose field takes typed or picked entry: one KLM mental
1582
+ * preparation each, with textarea and select (a definition; KLM places M by
1583
+ * rules on a task method, which a page does not give).
1584
+ */
1585
+ const ENTRY_INPUT_TYPES = new Set(["text", "email", "password", "search", "tel", "url", "number", "date", "datetime-local", "month", "week", "time", "color", "file", "range"]);
1586
+ function isEntryKind(kind) {
1587
+ const m = /^([a-z0-9-]+)(?::([a-z0-9-]+))?/.exec(kind ?? "");
1588
+ if (!m)
1589
+ return false;
1590
+ if (m[1] === "textarea" || m[1] === "select")
1591
+ return true;
1592
+ return m[1] === "input" && ENTRY_INPUT_TYPES.has(m[2] ?? "text");
1593
+ }
1594
+ /**
1595
+ * Stage B's four law times for a raw page measurement, in seconds for the
1596
+ * reference user, with what they rest on. Everything in scope, once
1597
+ * (research/stage-b-design.md, decision 1):
1598
+ *
1599
+ * pointing every interactive box acquired once: Fitts, SMALLER-OF width,
1600
+ * from the centre of the screen it is seen on; the stored boxes'
1601
+ * sum scaled to interactiveElements when the box list is capped.
1602
+ * choosing every choice group resolved once by novice linear search
1603
+ * (Cockburn et al. 2007); a checkbox group is one yes/no per box;
1604
+ * links in no group form one more set. Groups beyond the stored
1605
+ * ones are scaled to choiceGroupCount. choosingSecondsExpert is
1606
+ * the same groups by Hick-Hyman, the menu model's expert limit
1607
+ * (Stage C: cognitiveLoad's demand interpolates the two by the
1608
+ * run's siteFamiliarity, Cockburn et al.'s Eq. 4).
1609
+ * reading the prose outside every group read once at Brysbaert's rate
1610
+ * (letters of the alphabetic words, abjad words, CJK
1611
+ * characters; format 2 counts each character once), plus a
1612
+ * first read per menu label: law.reading.labelFirstRead per
1613
+ * grouped link with visible text (an icon-only link has nothing
1614
+ * to read), which the novice search above leaves out
1615
+ * (Cockburn et al. fitted it without that one-off cost). Format
1616
+ * 1 has no in-group prose, so it reads all of its prose and
1617
+ * charges no label.
1618
+ * procedure KLM's M per entry step: an entry field (ENTRY_INPUT_TYPES,
1619
+ * textarea, select) or a radio or checkbox group.
1620
+ */
1621
+ export function lawSecondsOf(raw) {
1622
+ const view = { viewportWidth: raw.viewportWidth, viewportHeight: raw.viewportHeight, scrollY: raw.scrollY };
1623
+ const targets = [];
1624
+ let idSum = 0;
1625
+ let entryBoxes = 0;
1626
+ for (let i = 0; i + RAW_BOX_FIELDS <= raw.boxes.length; i += RAW_BOX_FIELDS) {
1627
+ const [kind, x, y, width, height] = raw.boxes.slice(i, i + RAW_BOX_FIELDS);
1628
+ const distance = distanceToScreenCentre({ x, y, width, height }, view, raw.scope);
1629
+ targets.push({ distance, width, height });
1630
+ idSum += fittsIndexOfDifficulty(distance, smallerOfWidth(width, height));
1631
+ if (isEntryKind(raw.kinds[kind]))
1632
+ entryBoxes++;
1633
+ }
1634
+ const storedTargets = targets.length;
1635
+ const totalTargets = Math.max(raw.interactiveElements, storedTargets);
1636
+ const groups = [];
1637
+ let storedNavLinks = 0;
1638
+ let toggleGroups = 0;
1639
+ for (let i = 0; i + RAW_GROUP_FIELDS <= raw.choiceGroups.length; i += RAW_GROUP_FIELDS) {
1640
+ const kind = raw.kinds[raw.choiceGroups[i]];
1641
+ const size = raw.choiceGroups[i + 1];
1642
+ groups.push({ size, each: kind === "checkbox" });
1643
+ if (kind === "nav")
1644
+ storedNavLinks += size;
1645
+ if (kind === "radio" || kind === "checkbox")
1646
+ toggleGroups++;
1647
+ }
1648
+ const totalGroups = Math.max(raw.choiceGroupCount, groups.length);
1649
+ // Format 2 counts every grouped link once. Format 1 has no such count, so its
1650
+ // grouped links are the stored nav groups' sizes. A format-2 record without
1651
+ // the count, or without the in-group prose, predates them and is refused
1652
+ // rather than read as format 1.
1653
+ if (raw.format !== 1) {
1654
+ const missing = [
1655
+ typeof raw.groupedLinks !== "number" && "groupedLinks",
1656
+ !raw.proseInNav && "proseInNav",
1657
+ typeof raw.groupedLinksWithText !== "number" && "groupedLinksWithText",
1658
+ ].filter(Boolean);
1659
+ if (missing.length)
1660
+ throw new Error(`lawSecondsOf: raw format 2 record without ${missing.join(", ")} (measured before the nav totals and the in-group prose existed); re-measure the page`);
1661
+ }
1662
+ const ungroupedLinksFrom = raw.format === 1 ? "navGroupSizes" : "groupedLinks";
1663
+ const ungroupedLinks = Math.max(0, raw.links - (raw.format === 1 ? storedNavLinks : raw.groupedLinks));
1664
+ const choose = (law) => choosingSeconds(groups, law, totalGroups)
1665
+ + (law === "novice" ? noviceSearchSeconds(ungroupedLinks) : hickHymanSeconds(ungroupedLinks));
1666
+ let letters = 0;
1667
+ for (const [len, c] of raw.text.wordLengths)
1668
+ letters += len * c;
1669
+ let abjadWords = 0;
1670
+ for (const [, c] of raw.text.abjadWordLengths)
1671
+ abjadWords += c;
1672
+ // Menu labels (ISC-76): the prose inside a nav or composite group is read as
1673
+ // labels, one first read per grouped link, not at the prose rate.
1674
+ const inGroup = raw.format === 1 ? { letters: 0, abjadWords: 0, cjkChars: 0 } : raw.proseInNav;
1675
+ const labels = raw.format === 1 ? 0 : raw.groupedLinksWithText;
1676
+ const labelSeconds = labelReadingSeconds(labels);
1677
+ // Entry boxes scale with the box cap like targets; toggle groups with the group cap.
1678
+ const entrySteps = (storedTargets > 0 ? entryBoxes * (totalTargets / storedTargets) : 0)
1679
+ + (groups.length > 0 ? toggleGroups * (totalGroups / groups.length) : 0);
1680
+ return {
1681
+ pointingSeconds: pointingSeconds(targets, totalTargets),
1682
+ choosingSeconds: choose("novice"),
1683
+ choosingSecondsExpert: choose("expert"),
1684
+ readingSeconds: readingSeconds({
1685
+ letters: Math.max(0, letters - inGroup.letters),
1686
+ abjadWords: Math.max(0, abjadWords - inGroup.abjadWords),
1687
+ cjkChars: Math.max(0, raw.text.cjkChars - inGroup.cjkChars),
1688
+ }) + labelSeconds,
1689
+ procedureSeconds: klmSeconds(entrySteps),
1690
+ lawDiagnostics: {
1691
+ meanFittsID: storedTargets > 0 ? idSum / storedTargets : 0,
1692
+ targets: totalTargets,
1693
+ entrySteps,
1694
+ choiceGroups: totalGroups + (ungroupedLinks > 0 ? 1 : 0),
1695
+ ungroupedLinks,
1696
+ ungroupedLinksFrom,
1697
+ labels,
1698
+ labelSeconds,
1699
+ },
1700
+ };
1701
+ }
1702
+ export function deriveMetrics(raw) {
1703
+ if (raw.format !== RAW_PAGE_FORMAT && raw.format !== 1) {
1704
+ throw new Error(`deriveMetrics: raw page format ${String(raw.format)} is not one this build reads (1 or ${RAW_PAGE_FORMAT})`);
1705
+ }
1706
+ const P = readDeriveParams();
1707
+ const fullPage = raw.scope === "full_page";
1708
+ const vw = raw.viewportWidth;
1709
+ const vh = raw.viewportHeight;
1710
+ const docHeight = raw.documentHeight;
1711
+ // Deduplicate: rough estimate — child text counted in parent. Use 40% factor.
1712
+ const textLength = Math.round(raw.textChars * P.textNestedShare);
1713
+ // Area to normalise density against. Counting whole-page text and dividing
1714
+ // by one screen would report a density the page does not have.
1715
+ const viewportArea = vw * (fullPage ? docHeight : vh);
1716
+ const choiceCount = raw.links + raw.selectOptions;
1717
+ const animCount = raw.runningAnimations + raw.autoplayElements;
1718
+ const navDepth = Math.max(1, raw.breadcrumbLinks);
1719
+ // Normalize to 0-1 using logarithmic scaling
1720
+ const logScale = (value, midpoint) => {
1721
+ if (value <= 0)
1722
+ return 0;
1723
+ return value / (value + midpoint);
1724
+ };
1725
+ const informationDensity = logScale(textLength, P.informationMidpoint); // Viewport text: 0.5 at 5K chars
1726
+ const visualComplexity = logScale(raw.colours * P.colourWeight + raw.images * P.imageWeight + raw.visibleElements * P.elementWeight, P.visualMidpoint);
1727
+ const textDensity = logScale(textLength / Math.max(1, viewportArea * P.areaUnit), P.textDensityMidpoint);
1728
+ const animationLevel = Math.min(1, (animCount + (raw.carousel ? P.carouselCount : 0)) / P.animationSaturation);
1729
+ // ── Readability, from the length histograms ──
1730
+ const t = raw.text;
1731
+ const resolved = (threshold, id) => {
1732
+ if (threshold >= t.lengthBins) {
1733
+ throw new Error(`pageMetrics.${id} = ${threshold} cannot be re-derived from a measurement whose lengths share one bin from ${t.lengthBins}`);
1734
+ }
1735
+ return threshold;
1736
+ };
1737
+ const countAtLeast = (hist, min) => {
1738
+ let n = 0;
1739
+ for (const [len, c] of hist)
1740
+ if (len >= min)
1741
+ n += c;
1742
+ return n;
1743
+ };
1744
+ const countAbove = (hist, above) => {
1745
+ let n = 0;
1746
+ for (const [len, c] of hist)
1747
+ if (len > above)
1748
+ n += c;
1749
+ return n;
1750
+ };
1751
+ let wordCount = 0;
1752
+ let totalWordLength = 0;
1753
+ let longWords = 0;
1754
+ let techWords = 0;
1755
+ let sentenceLengthAbove;
1756
+ if (t.script === 'abjad') {
1757
+ // Arabic stems are 3-4 chars. Normalize: Arabic 3-char word → equivalent
1758
+ // of 5 Latin chars for readability (higher information per character than Latin)
1759
+ for (const [len, c] of t.abjadWordLengths) {
1760
+ wordCount += c;
1761
+ totalWordLength += Math.round(len * P.abjadLengthScale) * c;
1762
+ // "Long" in abjad: 5+ characters (stems with prefixes/suffixes), read as 8+ Latin
1763
+ if (len >= P.abjadLongWordMin)
1764
+ longWords += c;
1765
+ // "Technical" in abjad: 6+ character words, e.g. المعرفية (7 chars) or الشخصيات (8 chars)
1766
+ if (len >= P.abjadTechWordMin)
1767
+ techWords += c;
1768
+ }
1769
+ for (const [len, c] of t.wordLengths) {
1770
+ wordCount += c;
1771
+ totalWordLength += len * c;
1772
+ if (len >= P.latinLongWordMin)
1773
+ longWords += c;
1774
+ if (len >= P.latinTechWordMin)
1775
+ techWords += c;
1776
+ }
1777
+ sentenceLengthAbove = resolved(P.abjadSentenceLengthAbove, "abjad.sentenceLengthAbove");
1778
+ }
1779
+ else if (t.script === 'cjk') {
1780
+ // Estimate word count: CJK averages ~1.5-2 characters per "word"
1781
+ // (Chinese ~1.5, Japanese ~2 with particles, Korean ~2 with jamo)
1782
+ const cjkWordEstimate = Math.round(t.cjkChars / P.cjkCharsPerWord);
1783
+ let latinWords = 0;
1784
+ let latinLength = 0;
1785
+ for (const [len, c] of t.wordLengths) {
1786
+ latinWords += c;
1787
+ latinLength += len * c;
1788
+ if (len >= P.cjkLatinTechWordMin)
1789
+ techWords += c;
1790
+ }
1791
+ wordCount = cjkWordEstimate + latinWords;
1792
+ // A 2-character CJK word ≈ 5-7 character English word in information density.
1793
+ // Normalize: each estimated CJK word → the equivalent of ~6 Latin chars for readability
1794
+ totalWordLength = cjkWordEstimate * P.cjkLatinEquivalentLength + latinLength;
1795
+ // "Long words" in CJK: compound runs of 3+ characters (equivalent to 7+ Latin);
1796
+ // "technical terms": runs of 4+, among those.
1797
+ const longRunMin = resolved(P.cjkLongRunMin, "cjk.longRunMin");
1798
+ longWords = countAtLeast(t.cjkRunLengths, longRunMin);
1799
+ techWords += countAtLeast(t.cjkRunLengths, Math.max(longRunMin, resolved(P.cjkTechRunMin, "cjk.techRunMin")));
1800
+ sentenceLengthAbove = resolved(P.cjkSentenceLengthAbove, "cjk.sentenceLengthAbove");
1801
+ }
1802
+ else {
1803
+ for (const [len, c] of t.wordLengths) {
1804
+ wordCount += c;
1805
+ totalWordLength += len * c;
1806
+ if (len >= P.latinLongWordMin)
1807
+ longWords += c;
1808
+ if (len >= P.latinTechWordMin)
1809
+ techWords += c;
1810
+ }
1811
+ sentenceLengthAbove = resolved(P.alphabeticSentenceLengthAbove, "alphabetic.sentenceLengthAbove");
1812
+ }
1813
+ // A fragment is a sentence when its trimmed length exceeds the threshold.
1814
+ const sentenceCount = Math.max(1, countAbove(t.fragmentLengths, sentenceLengthAbove));
1815
+ const avgWordsPerSentence = wordCount / sentenceCount;
1816
+ // ── Normalize to 0-1 (script-aware) ──
1817
+ // Average word length: (length - 3) / 7.5, so Latin 4→0.13, 6→0.4, 10.5+→1.
1818
+ // (This comment read 4→0.2, 6→0.5, 9→0.8, which the formula never produced.)
1819
+ // CJK equivalent: normalized through the 6-char equivalence above
1820
+ const rawAvgWordLen = wordCount > 0 ? totalWordLength / wordCount : 0;
1821
+ const avgWordLength = Math.min(1, Math.max(0, (rawAvgWordLen - P.wordLengthOffset) / P.wordLengthSpan));
1822
+ // Average sentence length: words / 40, so 10 words → 0.25, 20 → 0.5, 40+ → 1
1823
+ // (it read 10 → 0.3, 20 → 0.6, 35+ → 0.9).
1824
+ const avgSentenceLength = Math.min(1, avgWordsPerSentence / P.sentenceSaturation);
1825
+ // Lexical diversity: unique items / total (capped at 100)
1826
+ const lexicalDiversity = wordCount > 0 ? Math.min(1, t.uniqueWords / Math.min(wordCount, P.lexicalWindow)) : 0;
1827
+ // Long word ratio
1828
+ const longWordRatio = wordCount > 0 ? longWords / wordCount : 0;
1829
+ // Technical density
1830
+ const technicalDensity = wordCount > 0 ? techWords / wordCount : 0;
1831
+ return {
1832
+ informationDensity,
1833
+ visualComplexity,
1834
+ interactiveElementCount: raw.interactiveElements,
1835
+ textDensity,
1836
+ animationLevel,
1837
+ // Capped for the demand math, which saturates well before 100 anyway
1838
+ // (choices/(choices+10) is 0.91 at 100 and 0.96 at 235). The raw count is
1839
+ // emitted beside it because a silently truncated number is
1840
+ // indistinguishable from a real one -- at viewport scope the cap was
1841
+ // unreachable, and at full_page the CU Denver homepage hits it.
1842
+ choiceCount: Math.min(choiceCount, P.choiceCap),
1843
+ choiceCountRaw: choiceCount,
1844
+ choiceCountCapped: choiceCount > P.choiceCap,
1845
+ navigationDepth: navDepth,
1846
+ avgWordLength,
1847
+ avgSentenceLength,
1848
+ lexicalDiversity,
1849
+ longWordRatio,
1850
+ technicalDensity,
1851
+ scriptFamily: t.script,
1852
+ // Emitted so a caller can never again be unable to tell which of these
1853
+ // two very different measurements it is holding.
1854
+ scope: raw.scope,
1855
+ // From the shared measurement, so it can never disagree with the screenCount
1856
+ // the sequential curve was scored over.
1857
+ pageScreens: raw.pageScreens,
1858
+ ...lawSecondsOf(raw),
1859
+ };
1860
+ }
1861
+ /**
1862
+ * Page metrics of a live page: measurePageRaw, then deriveMetrics. Callers
1863
+ * that keep the measurement (the CTC reference crawl) call the two halves.
1864
+ */
1865
+ export async function extractPageMetrics(page, scope = "viewport",
1866
+ /** Geometry already measured by the caller, so both derivations share one read. */
1867
+ geometry) {
1868
+ return deriveMetrics(await measurePageRaw(page, scope, geometry));
1030
1869
  }
1031
1870
  /**
1032
1871
  * Compute the full pairwise distance matrix between personas.
1033
- * Returns a symmetric matrix where entry [i][j] = W₁(persona_i, persona_j).
1872
+ * Returns a symmetric matrix where entry [i][j] = cognitiveDistance(persona_i,
1873
+ * persona_j).w1, the generalized W₁ (shape and level; see cognitiveDistance).
1034
1874
  */
1035
1875
  export function cognitiveDistanceMatrix(profiles) {
1036
1876
  const n = profiles.length;