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.
- package/README.md +2 -2
- package/dist/analysis/accessibility-empathy.d.ts +29 -1
- package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +69 -35
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +11 -7
- package/dist/browser.js.map +1 -1
- package/dist/cif-score.d.ts +56 -5
- package/dist/cif-score.d.ts.map +1 -1
- package/dist/cif-score.js +595 -346
- package/dist/cif-score.js.map +1 -1
- package/dist/cli.js +10 -8
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/default-traits.d.ts +14 -0
- package/dist/cognitive/default-traits.d.ts.map +1 -0
- package/dist/cognitive/default-traits.js +36 -0
- package/dist/cognitive/default-traits.js.map +1 -0
- package/dist/cognitive/emotions.d.ts.map +1 -1
- package/dist/cognitive/emotions.js +104 -174
- package/dist/cognitive/emotions.js.map +1 -1
- package/dist/cognitive/index.d.ts +28 -0
- package/dist/cognitive/index.d.ts.map +1 -1
- package/dist/cognitive/index.js +353 -287
- package/dist/cognitive/index.js.map +1 -1
- package/dist/cognitive/temporal.d.ts +0 -24
- package/dist/cognitive/temporal.d.ts.map +1 -1
- package/dist/cognitive/temporal.js +45 -35
- package/dist/cognitive/temporal.js.map +1 -1
- package/dist/data/ctc-reference-v2-2026-10-24.d.ts +16 -0
- package/dist/data/ctc-reference-v2-2026-10-24.d.ts.map +1 -0
- package/dist/data/ctc-reference-v2-2026-10-24.js +1004 -0
- package/dist/data/ctc-reference-v2-2026-10-24.js.map +1 -0
- package/dist/ideal-profiles-gap.d.ts +1 -0
- package/dist/ideal-profiles-gap.d.ts.map +1 -1
- package/dist/ideal-profiles-gap.js +9 -1
- package/dist/ideal-profiles-gap.js.map +1 -1
- package/dist/ideal-profiles.d.ts +8 -0
- package/dist/ideal-profiles.d.ts.map +1 -1
- package/dist/ideal-profiles.js +28 -24
- package/dist/ideal-profiles.js.map +1 -1
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +7 -8
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-tools/account-personas.d.ts.map +1 -1
- package/dist/mcp-tools/account-personas.js +3 -1
- package/dist/mcp-tools/account-personas.js.map +1 -1
- package/dist/mcp-tools/base/answer-provenance.d.ts +28 -0
- package/dist/mcp-tools/base/answer-provenance.d.ts.map +1 -0
- package/dist/mcp-tools/base/answer-provenance.js +32 -0
- package/dist/mcp-tools/base/answer-provenance.js.map +1 -0
- package/dist/mcp-tools/base/audit-tools.d.ts +0 -3
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +152 -84
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.js +1 -1
- package/dist/mcp-tools/base/capture-tools.js.map +1 -1
- package/dist/mcp-tools/base/chain-values.d.ts +38 -0
- package/dist/mcp-tools/base/chain-values.d.ts.map +1 -0
- package/dist/mcp-tools/base/chain-values.js +85 -0
- package/dist/mcp-tools/base/chain-values.js.map +1 -0
- package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.js +126 -120
- package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
- package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/gif-tools.js +23 -5
- package/dist/mcp-tools/base/gif-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +229 -172
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/site-familiarity.d.ts +31 -0
- package/dist/mcp-tools/base/site-familiarity.d.ts.map +1 -0
- package/dist/mcp-tools/base/site-familiarity.js +30 -0
- package/dist/mcp-tools/base/site-familiarity.js.map +1 -0
- package/dist/mcp-tools/base/site-knowledge-tools.js +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
- package/dist/mcp-tools/base/story-narrative.d.ts +50 -0
- package/dist/mcp-tools/base/story-narrative.d.ts.map +1 -0
- package/dist/mcp-tools/base/story-narrative.js +88 -0
- package/dist/mcp-tools/base/story-narrative.js.map +1 -0
- package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/values-tools.js +19 -5
- package/dist/mcp-tools/base/values-tools.js.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.d.ts +5 -5
- package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.js +38 -11
- package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.js +6 -6
- package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.js +5 -1
- package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
- package/dist/mcp-tools/tier-gate.js +1 -1
- package/dist/mcp-tools/tool-result-saver.d.ts +7 -0
- package/dist/mcp-tools/tool-result-saver.d.ts.map +1 -1
- package/dist/mcp-tools/tool-result-saver.js +11 -0
- package/dist/mcp-tools/tool-result-saver.js.map +1 -1
- package/dist/mcp-tools/trait-provenance.d.ts +109 -0
- package/dist/mcp-tools/trait-provenance.d.ts.map +1 -0
- package/dist/mcp-tools/trait-provenance.js +187 -0
- package/dist/mcp-tools/trait-provenance.js.map +1 -0
- package/dist/mcp-tools/ui-resources.d.ts +6 -7
- package/dist/mcp-tools/ui-resources.d.ts.map +1 -1
- package/dist/mcp-tools/ui-resources.js +162 -12
- package/dist/mcp-tools/ui-resources.js.map +1 -1
- package/dist/mcp-tools/widget-kit.d.ts +129 -3
- package/dist/mcp-tools/widget-kit.d.ts.map +1 -1
- package/dist/mcp-tools/widget-kit.js +507 -28
- package/dist/mcp-tools/widget-kit.js.map +1 -1
- package/dist/performance/metrics.d.ts.map +1 -1
- package/dist/performance/metrics.js +4 -1
- package/dist/performance/metrics.js.map +1 -1
- package/dist/persona-questionnaire.js +1 -1
- package/dist/persona-questionnaire.js.map +1 -1
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +133 -82
- package/dist/personas.js.map +1 -1
- package/dist/recording/engine.d.ts.map +1 -1
- package/dist/recording/engine.js +5 -2
- package/dist/recording/engine.js.map +1 -1
- package/dist/recording/saliency-overlay.d.ts +8 -3
- package/dist/recording/saliency-overlay.d.ts.map +1 -1
- package/dist/recording/saliency-overlay.js +14 -5
- package/dist/recording/saliency-overlay.js.map +1 -1
- package/dist/recording/types.js +1 -1
- package/dist/testing/nl-test-suite.d.ts.map +1 -1
- package/dist/testing/nl-test-suite.js +2 -1
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/trait-reference.d.ts +19 -2
- package/dist/trait-reference.d.ts.map +1 -1
- package/dist/trait-reference.js +19 -2
- package/dist/trait-reference.js.map +1 -1
- package/dist/types.d.ts +32 -21
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +62 -52
- package/dist/types.js.map +1 -1
- package/dist/values/persona-completeness.js +1 -1
- package/dist/values/persona-completeness.js.map +1 -1
- package/dist/visual/ab-comparison.d.ts.map +1 -1
- package/dist/visual/ab-comparison.js +4 -1
- package/dist/visual/ab-comparison.js.map +1 -1
- package/dist/visual/attention-quality.d.ts +1 -1
- package/dist/visual/attention-quality.js +1 -1
- package/dist/visual/attention-transport.d.ts +74 -10
- package/dist/visual/attention-transport.d.ts.map +1 -1
- package/dist/visual/attention-transport.js +353 -193
- package/dist/visual/attention-transport.js.map +1 -1
- package/dist/visual/cognitive-models.d.ts +77 -55
- package/dist/visual/cognitive-models.d.ts.map +1 -1
- package/dist/visual/cognitive-models.js +231 -104
- package/dist/visual/cognitive-models.js.map +1 -1
- package/dist/visual/cognitive-transport-chain.d.ts +328 -30
- package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
- package/dist/visual/cognitive-transport-chain.js +576 -353
- package/dist/visual/cognitive-transport-chain.js.map +1 -1
- package/dist/visual/cognitive-transport.d.ts +446 -13
- package/dist/visual/cognitive-transport.d.ts.map +1 -1
- package/dist/visual/cognitive-transport.js +1085 -245
- package/dist/visual/cognitive-transport.js.map +1 -1
- package/dist/visual/cross-browser.d.ts.map +1 -1
- package/dist/visual/cross-browser.js +8 -6
- package/dist/visual/cross-browser.js.map +1 -1
- package/dist/visual/ctc-bands.d.ts +91 -0
- package/dist/visual/ctc-bands.d.ts.map +1 -0
- package/dist/visual/ctc-bands.js +117 -0
- package/dist/visual/ctc-bands.js.map +1 -0
- package/dist/visual/ctc-reference.d.ts +287 -0
- package/dist/visual/ctc-reference.d.ts.map +1 -0
- package/dist/visual/ctc-reference.js +376 -0
- package/dist/visual/ctc-reference.js.map +1 -0
- package/dist/visual/demand-laws.d.ts +145 -0
- package/dist/visual/demand-laws.d.ts.map +1 -0
- package/dist/visual/demand-laws.js +220 -0
- package/dist/visual/demand-laws.js.map +1 -0
- package/dist/visual/distance-metrics.d.ts +23 -1
- package/dist/visual/distance-metrics.d.ts.map +1 -1
- package/dist/visual/distance-metrics.js +49 -18
- package/dist/visual/distance-metrics.js.map +1 -1
- package/dist/visual/dro-bound.d.ts +220 -0
- package/dist/visual/dro-bound.d.ts.map +1 -0
- package/dist/visual/dro-bound.js +341 -0
- package/dist/visual/dro-bound.js.map +1 -0
- package/dist/visual/exact-w1.d.ts +60 -0
- package/dist/visual/exact-w1.d.ts.map +1 -0
- package/dist/visual/exact-w1.js +247 -0
- package/dist/visual/exact-w1.js.map +1 -0
- package/dist/visual/human-input.d.ts.map +1 -1
- package/dist/visual/human-input.js +45 -41
- package/dist/visual/human-input.js.map +1 -1
- package/dist/visual/index.d.ts +2 -0
- package/dist/visual/index.d.ts.map +1 -1
- package/dist/visual/index.js +2 -0
- package/dist/visual/index.js.map +1 -1
- package/dist/visual/law-times-report.d.ts +49 -0
- package/dist/visual/law-times-report.d.ts.map +1 -0
- package/dist/visual/law-times-report.js +38 -0
- package/dist/visual/law-times-report.js.map +1 -0
- package/dist/visual/model-provenance.d.ts +151 -0
- package/dist/visual/model-provenance.d.ts.map +1 -0
- package/dist/visual/model-provenance.js +334 -0
- package/dist/visual/model-provenance.js.map +1 -0
- package/dist/visual/normal-cdf.d.ts +14 -0
- package/dist/visual/normal-cdf.d.ts.map +1 -0
- package/dist/visual/normal-cdf.js +26 -0
- package/dist/visual/normal-cdf.js.map +1 -0
- package/dist/visual/perceptual-transport.d.ts +28 -4
- package/dist/visual/perceptual-transport.d.ts.map +1 -1
- package/dist/visual/perceptual-transport.js +66 -32
- package/dist/visual/perceptual-transport.js.map +1 -1
- package/dist/visual/provenance/attention.d.ts +55 -0
- package/dist/visual/provenance/attention.d.ts.map +1 -0
- package/dist/visual/provenance/attention.js +188 -0
- package/dist/visual/provenance/attention.js.map +1 -0
- package/dist/visual/provenance/chain.d.ts +41 -0
- package/dist/visual/provenance/chain.d.ts.map +1 -0
- package/dist/visual/provenance/chain.js +66 -0
- package/dist/visual/provenance/chain.js.map +1 -0
- package/dist/visual/provenance/cif.d.ts +42 -0
- package/dist/visual/provenance/cif.d.ts.map +1 -0
- package/dist/visual/provenance/cif.js +116 -0
- package/dist/visual/provenance/cif.js.map +1 -0
- package/dist/visual/provenance/ctc-reference.d.ts +36 -0
- package/dist/visual/provenance/ctc-reference.d.ts.map +1 -0
- package/dist/visual/provenance/ctc-reference.js +96 -0
- package/dist/visual/provenance/ctc-reference.js.map +1 -0
- package/dist/visual/provenance/distance.d.ts +10 -0
- package/dist/visual/provenance/distance.d.ts.map +1 -0
- package/dist/visual/provenance/distance.js +72 -0
- package/dist/visual/provenance/distance.js.map +1 -0
- package/dist/visual/provenance/dro.d.ts +10 -0
- package/dist/visual/provenance/dro.d.ts.map +1 -0
- package/dist/visual/provenance/dro.js +2 -0
- package/dist/visual/provenance/dro.js.map +1 -0
- package/dist/visual/provenance/emotion.d.ts +26 -0
- package/dist/visual/provenance/emotion.d.ts.map +1 -0
- package/dist/visual/provenance/emotion.js +99 -0
- package/dist/visual/provenance/emotion.js.map +1 -0
- package/dist/visual/provenance/human-input.d.ts +19 -0
- package/dist/visual/provenance/human-input.d.ts.map +1 -0
- package/dist/visual/provenance/human-input.js +84 -0
- package/dist/visual/provenance/human-input.js.map +1 -0
- package/dist/visual/provenance/journey-group.d.ts +15 -0
- package/dist/visual/provenance/journey-group.d.ts.map +1 -0
- package/dist/visual/provenance/journey-group.js +15 -0
- package/dist/visual/provenance/journey-group.js.map +1 -0
- package/dist/visual/provenance/journey-mcp.d.ts +24 -0
- package/dist/visual/provenance/journey-mcp.d.ts.map +1 -0
- package/dist/visual/provenance/journey-mcp.js +54 -0
- package/dist/visual/provenance/journey-mcp.js.map +1 -0
- package/dist/visual/provenance/journey.d.ts +34 -0
- package/dist/visual/provenance/journey.d.ts.map +1 -0
- package/dist/visual/provenance/journey.js +184 -0
- package/dist/visual/provenance/journey.js.map +1 -0
- package/dist/visual/provenance/laws.d.ts +38 -0
- package/dist/visual/provenance/laws.d.ts.map +1 -0
- package/dist/visual/provenance/laws.js +40 -0
- package/dist/visual/provenance/laws.js.map +1 -0
- package/dist/visual/provenance/page-metrics.d.ts +42 -0
- package/dist/visual/provenance/page-metrics.d.ts.map +1 -0
- package/dist/visual/provenance/page-metrics.js +79 -0
- package/dist/visual/provenance/page-metrics.js.map +1 -0
- package/dist/visual/provenance/perceptual.d.ts +63 -0
- package/dist/visual/provenance/perceptual.d.ts.map +1 -0
- package/dist/visual/provenance/perceptual.js +115 -0
- package/dist/visual/provenance/perceptual.js.map +1 -0
- package/dist/visual/provenance/persona-rates.d.ts +84 -0
- package/dist/visual/provenance/persona-rates.d.ts.map +1 -0
- package/dist/visual/provenance/persona-rates.js +112 -0
- package/dist/visual/provenance/persona-rates.js.map +1 -0
- package/dist/visual/provenance/personas.d.ts +122 -0
- package/dist/visual/provenance/personas.d.ts.map +1 -0
- package/dist/visual/provenance/personas.js +298 -0
- package/dist/visual/provenance/personas.js.map +1 -0
- package/dist/visual/provenance/profiles.d.ts +39 -0
- package/dist/visual/provenance/profiles.d.ts.map +1 -0
- package/dist/visual/provenance/profiles.js +37 -0
- package/dist/visual/provenance/profiles.js.map +1 -0
- package/dist/visual/provenance/temporal.d.ts +21 -0
- package/dist/visual/provenance/temporal.d.ts.map +1 -0
- package/dist/visual/provenance/temporal.js +56 -0
- package/dist/visual/provenance/temporal.js.map +1 -0
- package/dist/visual/provenance/visual-distance.d.ts +22 -0
- package/dist/visual/provenance/visual-distance.d.ts.map +1 -0
- package/dist/visual/provenance/visual-distance.js +30 -0
- package/dist/visual/provenance/visual-distance.js.map +1 -0
- package/dist/visual/regression.d.ts.map +1 -1
- package/dist/visual/regression.js +14 -9
- package/dist/visual/regression.js.map +1 -1
- package/dist/visual/responsive.d.ts.map +1 -1
- package/dist/visual/responsive.js +4 -1
- package/dist/visual/responsive.js.map +1 -1
- package/dist/visual/scope-prose.js +2 -2
- package/dist/visual/scope-prose.js.map +1 -1
- package/dist/visual/seeded-random.d.ts +16 -0
- package/dist/visual/seeded-random.d.ts.map +1 -0
- package/dist/visual/seeded-random.js +25 -0
- package/dist/visual/seeded-random.js.map +1 -0
- package/dist/visual/stored-accessibility-traits.d.ts +27 -0
- package/dist/visual/stored-accessibility-traits.d.ts.map +1 -0
- package/dist/visual/stored-accessibility-traits.js +35 -0
- package/dist/visual/stored-accessibility-traits.js.map +1 -0
- 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) —
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* -
|
|
76
|
-
* -
|
|
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
|
|
131
|
+
return param("distance.groundUnknownTrait");
|
|
123
132
|
if (domainA === domainB)
|
|
124
|
-
return
|
|
133
|
+
return param("distance.groundSameDomain");
|
|
125
134
|
if (related.has(`${domainA}-${domainB}`))
|
|
126
|
-
return
|
|
127
|
-
return
|
|
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
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
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
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* the
|
|
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
|
|
222
|
-
//
|
|
223
|
-
//
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
for (let j = 0; j <
|
|
231
|
-
if (
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
//
|
|
503
|
-
|
|
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 =
|
|
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
|
|
560
|
-
*
|
|
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,
|
|
938
|
+
choiceCount: Math.min(fresh.newChoices, choiceCap),
|
|
706
939
|
choiceCountRaw: fresh.newChoices,
|
|
707
|
-
choiceCountCapped: fresh.newChoices >
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1012
|
+
await page.waitForTimeout(P.walkSettleMs);
|
|
734
1013
|
}
|
|
735
1014
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
736
|
-
await page.waitForTimeout(
|
|
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
|
-
//
|
|
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
|
-
|
|
780
|
-
|
|
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
|
|
1085
|
+
let linkCount = 0;
|
|
1086
|
+
let optionCount = 0;
|
|
785
1087
|
links.forEach(el => { if (inViewport(el))
|
|
786
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 <=
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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-\
|
|
864
|
-
const abjadCharRegex = /[\u0600-\
|
|
865
|
-
|
|
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
|
|
869
|
-
|
|
870
|
-
|
|
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 >=
|
|
881
|
-
(keyElementsTotal > 0 && keyElementsCJK / keyElementsTotal >
|
|
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 >=
|
|
887
|
-
(keyElementsTotal > 0 && keyElementsAbjad / keyElementsTotal >
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
|
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
|
|
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(
|
|
910
|
-
|
|
911
|
-
//
|
|
912
|
-
|
|
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
|
-
|
|
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
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
//
|
|
942
|
-
// CJK characters
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
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
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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] =
|
|
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;
|