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
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* @since v18.28.0
|
|
20
20
|
* @see https://github.com/alexandriashai/cbrowser/issues/159
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { resolvePerceptualProfile } from './perceptual-transport.js';
|
|
23
|
+
import { param } from './model-provenance.js';
|
|
24
|
+
import { tableChecksum, ATTENTION_STATIC_MODIFIER_TYPES, ATTENTION_VALUE_PATTERN_VALUES } from './provenance/attention.js';
|
|
23
25
|
/**
|
|
24
26
|
* Locate where two attention maps differ, as regions rather than cells.
|
|
25
27
|
*
|
|
@@ -52,6 +54,8 @@ import { getPerceptualProfile } from './perceptual-transport.js';
|
|
|
52
54
|
* Pure: no image, no persona. `cellSize` is the cell's size in image pixels.
|
|
53
55
|
*/
|
|
54
56
|
export function rankDivergentRegions(salA, salB, rows, cols, cellSize) {
|
|
57
|
+
const R2 = param("attention.rounding.hundredths");
|
|
58
|
+
const R3 = param("attention.rounding.thousandths");
|
|
55
59
|
const n = Math.min(salA.length, salB.length, rows * cols);
|
|
56
60
|
let totalA = 0;
|
|
57
61
|
let totalB = 0;
|
|
@@ -136,11 +140,11 @@ export function rankDivergentRegions(salA, salB, rows, cols, cellSize) {
|
|
|
136
140
|
},
|
|
137
141
|
cellCount: count,
|
|
138
142
|
direction: positive ? "personaA" : "personaB",
|
|
139
|
-
attentionA: Math.round(shareA *
|
|
140
|
-
attentionB: Math.round(shareB *
|
|
141
|
-
saliencyA: Math.round((sumA / count) *
|
|
142
|
-
saliencyB: Math.round((sumB / count) *
|
|
143
|
-
divergence: Math.round((sum / divergence) *
|
|
143
|
+
attentionA: Math.round(shareA * R3) / R3,
|
|
144
|
+
attentionB: Math.round(shareB * R3) / R3,
|
|
145
|
+
saliencyA: Math.round((sumA / count) * R2) / R2,
|
|
146
|
+
saliencyB: Math.round((sumB / count) * R2) / R2,
|
|
147
|
+
divergence: Math.round((sum / divergence) * R3) / R3,
|
|
144
148
|
_sum: sum,
|
|
145
149
|
_peak: peak,
|
|
146
150
|
_peakIdx: peakIdx,
|
|
@@ -154,25 +158,36 @@ export function rankDivergentRegions(salA, salB, rows, cols, cellSize) {
|
|
|
154
158
|
regions: regions.map(({ _sum: _s, _peak: _p, _peakIdx: _i, ...r }) => r),
|
|
155
159
|
};
|
|
156
160
|
}
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
function labConstants() {
|
|
162
|
+
const P = (id) => param(`attention.${id}`);
|
|
163
|
+
return {
|
|
164
|
+
channelMax: P("srgb.channelMax"), linearKnee: P("srgb.linearKnee"), linearSlope: P("srgb.linearSlope"),
|
|
165
|
+
gammaOffset: P("srgb.gammaOffset"), gammaScale: P("srgb.gammaScale"), gamma: P("srgb.gamma"),
|
|
166
|
+
rX: P("xyz.rX"), gX: P("xyz.gX"), bX: P("xyz.bX"),
|
|
167
|
+
rY: P("xyz.rY"), gY: P("xyz.gY"), bY: P("xyz.bY"),
|
|
168
|
+
rZ: P("xyz.rZ"), gZ: P("xyz.gZ"), bZ: P("xyz.bZ"),
|
|
169
|
+
xn: P("white.x"), yn: P("white.y"), zn: P("white.z"),
|
|
170
|
+
epsilon: P("lab.epsilon"), kappaSlope: P("lab.kappaSlope"),
|
|
171
|
+
lScale: P("lab.lScale"), lOffset: P("lab.lOffset"), aScale: P("lab.aScale"), bScale: P("lab.bScale"),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function srgbToLab(r, g, b, K) {
|
|
159
175
|
// sRGB to linear
|
|
160
176
|
const toLinear = (c) => {
|
|
161
|
-
c /=
|
|
162
|
-
return c <=
|
|
177
|
+
c /= K.channelMax;
|
|
178
|
+
return c <= K.linearKnee ? c / K.linearSlope : Math.pow((c + K.gammaOffset) / K.gammaScale, K.gamma);
|
|
163
179
|
};
|
|
164
180
|
const rl = toLinear(r), gl = toLinear(g), bl = toLinear(b);
|
|
165
181
|
// Linear RGB to XYZ (D65)
|
|
166
|
-
const x =
|
|
167
|
-
const y =
|
|
168
|
-
const z =
|
|
182
|
+
const x = K.rX * rl + K.gX * gl + K.bX * bl;
|
|
183
|
+
const y = K.rY * rl + K.gY * gl + K.bY * bl;
|
|
184
|
+
const z = K.rZ * rl + K.gZ * gl + K.bZ * bl;
|
|
169
185
|
// XYZ to Lab (D65 reference)
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const bLab = 200 * (fy - fz);
|
|
186
|
+
const f = (t) => t > K.epsilon ? Math.cbrt(t) : K.kappaSlope * t + K.lOffset / K.lScale;
|
|
187
|
+
const fx = f(x / K.xn), fy = f(y / K.yn), fz = f(z / K.zn);
|
|
188
|
+
const L = K.lScale * fy - K.lOffset;
|
|
189
|
+
const a = K.aScale * (fx - fy);
|
|
190
|
+
const bLab = K.bScale * (fy - fz);
|
|
176
191
|
return [L, a, bLab];
|
|
177
192
|
}
|
|
178
193
|
// ── Core: Center-Surround Saliency via W₂ ──
|
|
@@ -193,15 +208,27 @@ function srgbToLab(r, g, b) {
|
|
|
193
208
|
* impractical, and re-deriving it in a separate script is how the Study 1
|
|
194
209
|
* Python port drifted from shipped code. (2026-07-29)
|
|
195
210
|
*/
|
|
196
|
-
export async function computeLabSaliency(imagePath, cellSize =
|
|
211
|
+
export async function computeLabSaliency(imagePath, cellSize = param("attention.gaze.cellSize"), surroundRadius = param("attention.lab.surroundRadius")) {
|
|
212
|
+
const K = labConstants();
|
|
213
|
+
const channels = param("attention.lab.channels");
|
|
214
|
+
const alpha = channels - 1; // RGBA: alpha is the last byte
|
|
215
|
+
const alphaCutoff = param("attention.lab.alphaCutoff");
|
|
216
|
+
const varFloor = param("attention.lab.varianceFloor");
|
|
217
|
+
// Chrominance boost: a/b channels weighted 3x relative to L (see the loop below).
|
|
218
|
+
const wL = param("attention.lab.weightL");
|
|
219
|
+
const wA = param("attention.lab.weightA");
|
|
220
|
+
const wB = param("attention.lab.weightB");
|
|
221
|
+
const normFloor = param("attention.normFloor");
|
|
222
|
+
const scale = param("attention.lab.downscale");
|
|
223
|
+
const fallbackWidth = param("attention.lab.fallbackWidth");
|
|
224
|
+
const fallbackHeight = param("attention.lab.fallbackHeight");
|
|
197
225
|
const sharpModule = await import('sharp');
|
|
198
226
|
const sharp = sharpModule.default ?? sharpModule;
|
|
199
227
|
// Load and downscale
|
|
200
|
-
const scale = 0.5;
|
|
201
228
|
const img = sharp(imagePath);
|
|
202
229
|
const metadata = await img.metadata();
|
|
203
|
-
const width = Math.round((metadata.width ||
|
|
204
|
-
const height = Math.round((metadata.height ||
|
|
230
|
+
const width = Math.round((metadata.width || fallbackWidth) * scale);
|
|
231
|
+
const height = Math.round((metadata.height || fallbackHeight) * scale);
|
|
205
232
|
const { data } = await img
|
|
206
233
|
.resize(width, height)
|
|
207
234
|
.ensureAlpha()
|
|
@@ -224,10 +251,10 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
|
|
|
224
251
|
const xEnd = Math.min(xStart + cellSize, width);
|
|
225
252
|
for (let y = yStart; y < yEnd; y++) {
|
|
226
253
|
for (let x = xStart; x < xEnd; x++) {
|
|
227
|
-
const idx = (y * width + x) *
|
|
228
|
-
if (pixels[idx +
|
|
254
|
+
const idx = (y * width + x) * channels;
|
|
255
|
+
if (pixels[idx + alpha] < alphaCutoff)
|
|
229
256
|
continue; // Skip transparent
|
|
230
|
-
const [L, a, b] = srgbToLab(pixels[idx], pixels[idx + 1], pixels[idx + 2]);
|
|
257
|
+
const [L, a, b] = srgbToLab(pixels[idx], pixels[idx + 1], pixels[idx + 2], K);
|
|
231
258
|
sumL += L;
|
|
232
259
|
sumA += a;
|
|
233
260
|
sumB += b;
|
|
@@ -241,14 +268,14 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
|
|
|
241
268
|
const mL = sumL / count, mA = sumA / count, mB = sumB / count;
|
|
242
269
|
cellStats.push({
|
|
243
270
|
meanL: mL, meanA: mA, meanB: mB,
|
|
244
|
-
varL: Math.max(
|
|
245
|
-
varA: Math.max(
|
|
246
|
-
varB: Math.max(
|
|
271
|
+
varL: Math.max(varFloor, sumL2 / count - mL * mL),
|
|
272
|
+
varA: Math.max(varFloor, sumA2 / count - mA * mA),
|
|
273
|
+
varB: Math.max(varFloor, sumB2 / count - mB * mB),
|
|
247
274
|
count,
|
|
248
275
|
});
|
|
249
276
|
}
|
|
250
277
|
else {
|
|
251
|
-
cellStats.push({ meanL: 0, meanA: 0, meanB: 0, varL:
|
|
278
|
+
cellStats.push({ meanL: 0, meanA: 0, meanB: 0, varL: varFloor, varA: varFloor, varB: varFloor, count: 0 });
|
|
252
279
|
}
|
|
253
280
|
}
|
|
254
281
|
}
|
|
@@ -295,14 +322,12 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
|
|
|
295
322
|
// Bures-Wasserstein distance for 3D Gaussians (diagonal covariance)
|
|
296
323
|
// BW² = w_L||ΔL||² + w_a||Δa||² + w_b||Δb||² + variance terms
|
|
297
324
|
//
|
|
298
|
-
// Chrominance boost: a/b channels weighted 3x relative to L
|
|
325
|
+
// Chrominance boost: a/b channels weighted 3x relative to L (wL, wA,
|
|
326
|
+
// wB, read at entry; hand-set, see attention.lab.weightA).
|
|
299
327
|
// This captures the preattentive "pop-out effect" where a uniquely-colored
|
|
300
328
|
// item captures attention even when its luminance is similar to surroundings.
|
|
301
329
|
// Without this, teal text on white scores LOWER than black text on white
|
|
302
330
|
// because teal has less luminance contrast — the opposite of perceptual reality.
|
|
303
|
-
const wL = 1.0; // luminance weight
|
|
304
|
-
const wA = 3.0; // chrominance a* weight (red-green axis)
|
|
305
|
-
const wB = 3.0; // chrominance b* weight (blue-yellow axis)
|
|
306
331
|
const meanDist = wL * (center.meanL - sMeanL) ** 2 +
|
|
307
332
|
wA * (center.meanA - sMeanA) ** 2 +
|
|
308
333
|
wB * (center.meanB - sMeanB) ** 2;
|
|
@@ -313,12 +338,37 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
|
|
|
313
338
|
}
|
|
314
339
|
}
|
|
315
340
|
// Normalize to 0-1
|
|
316
|
-
const maxSal = Math.max(...Array.from(saliency),
|
|
341
|
+
const maxSal = Math.max(...Array.from(saliency), normFloor);
|
|
317
342
|
for (let i = 0; i < numCells; i++)
|
|
318
343
|
saliency[i] /= maxSal;
|
|
319
344
|
return { cells: saliency, rows, cols, width: width / scale, height: height / scale };
|
|
320
345
|
}
|
|
321
346
|
function computeValueAttentionParams(filter, schwartzValues) {
|
|
347
|
+
const P = (id) => param(`attention.${id}`);
|
|
348
|
+
const mode = {
|
|
349
|
+
centerHeavyCenterBias: P("mode.centerHeavy.centerBias"),
|
|
350
|
+
motionAttractedExponent: P("mode.motionAttracted.exponent"),
|
|
351
|
+
textFocusedThreshold: P("mode.textFocused.threshold"),
|
|
352
|
+
textFocusedBias: P("mode.textFocused.bias"),
|
|
353
|
+
largeElementsThreshold: P("mode.largeElements.threshold"),
|
|
354
|
+
};
|
|
355
|
+
const neutral = P("value.neutral");
|
|
356
|
+
// Signed coefficients on (value - neutral): positive raises the parameter.
|
|
357
|
+
const mod = {
|
|
358
|
+
stimulationExponent: P("valueMod.stimulation.exponent"),
|
|
359
|
+
securityBias: P("valueMod.security.bias"),
|
|
360
|
+
securityThreshold: P("valueMod.security.threshold"),
|
|
361
|
+
achievementExponent: P("valueMod.achievement.exponent"),
|
|
362
|
+
conformityCenterBias: P("valueMod.conformity.centerBias"),
|
|
363
|
+
selfDirectionCenterBias: P("valueMod.selfDirection.centerBias"),
|
|
364
|
+
traditionThreshold: P("valueMod.tradition.threshold"),
|
|
365
|
+
};
|
|
366
|
+
const clamp = {
|
|
367
|
+
exponentMin: P("filter.exponentMin"),
|
|
368
|
+
exponentMax: P("filter.exponentMax"),
|
|
369
|
+
biasLimit: P("filter.biasLimit"),
|
|
370
|
+
thresholdMax: P("filter.thresholdMax"),
|
|
371
|
+
};
|
|
322
372
|
// Start with the base attention mode
|
|
323
373
|
let exponent = 1.0;
|
|
324
374
|
let bias = 0.0;
|
|
@@ -327,45 +377,45 @@ function computeValueAttentionParams(filter, schwartzValues) {
|
|
|
327
377
|
// Apply categorical attention mode (legacy — still used for accessibility personas)
|
|
328
378
|
switch (filter.attentionMode) {
|
|
329
379
|
case 'center-heavy':
|
|
330
|
-
centerBias =
|
|
380
|
+
centerBias = mode.centerHeavyCenterBias;
|
|
331
381
|
break;
|
|
332
382
|
case 'motion-attracted':
|
|
333
|
-
exponent =
|
|
383
|
+
exponent = mode.motionAttractedExponent; // square root = more things register
|
|
334
384
|
break;
|
|
335
385
|
case 'text-focused':
|
|
336
|
-
threshold =
|
|
337
|
-
bias =
|
|
386
|
+
threshold = mode.textFocusedThreshold; // suppress low-saliency distractors
|
|
387
|
+
bias = mode.textFocusedBias; // slightly suppress overall
|
|
338
388
|
break;
|
|
339
389
|
case 'large-elements':
|
|
340
|
-
threshold =
|
|
390
|
+
threshold = mode.largeElementsThreshold;
|
|
341
391
|
break;
|
|
342
392
|
}
|
|
343
393
|
// Apply value-driven modulation (v18.54.0)
|
|
344
394
|
if (schwartzValues) {
|
|
345
|
-
const v = (key) => schwartzValues[key] ??
|
|
395
|
+
const v = (key) => schwartzValues[key] ?? neutral;
|
|
346
396
|
// Stimulation: high → dispersed attention (notices novelty everywhere)
|
|
347
397
|
// Anderson (2013): reward-seeking broadens attentional spotlight
|
|
348
|
-
exponent
|
|
398
|
+
exponent += (v('stimulation') - neutral) * mod.stimulationExponent; // 0.9 stim → exponent -0.16
|
|
349
399
|
// Security: high → vigilant scanning (boost everything slightly, lower threshold)
|
|
350
400
|
// Pessoa (2009): threat-sensitive individuals show broadened attention
|
|
351
|
-
bias += (v('security') -
|
|
352
|
-
threshold
|
|
401
|
+
bias += (v('security') - neutral) * mod.securityBias;
|
|
402
|
+
threshold += (v('security') - neutral) * mod.securityThreshold;
|
|
353
403
|
// Achievement: high → focused on high-value targets (increase exponent)
|
|
354
404
|
// Concentrate attention on the most salient elements, ignore distractors
|
|
355
|
-
exponent += (v('achievement') -
|
|
405
|
+
exponent += (v('achievement') - neutral) * mod.achievementExponent;
|
|
356
406
|
// Conformity: high → center-biased (follows expected layout patterns)
|
|
357
407
|
// F-pattern and Z-pattern readers look where convention says to look
|
|
358
|
-
centerBias += (v('conformity') -
|
|
408
|
+
centerBias += (v('conformity') - neutral) * mod.conformityCenterBias;
|
|
359
409
|
// Self-direction: high → less center-biased (explores periphery)
|
|
360
|
-
centerBias
|
|
410
|
+
centerBias += (v('selfDirection') - neutral) * mod.selfDirectionCenterBias;
|
|
361
411
|
// Tradition: high → suppresses novel elements (sticks to familiar patterns)
|
|
362
|
-
threshold += (v('tradition') -
|
|
412
|
+
threshold += (v('tradition') - neutral) * mod.traditionThreshold;
|
|
363
413
|
}
|
|
364
414
|
// Clamp values to sane ranges
|
|
365
|
-
exponent = Math.max(
|
|
366
|
-
bias = Math.max(-
|
|
415
|
+
exponent = Math.max(clamp.exponentMin, Math.min(clamp.exponentMax, exponent));
|
|
416
|
+
bias = Math.max(-clamp.biasLimit, Math.min(clamp.biasLimit, bias));
|
|
367
417
|
centerBias = Math.max(0, Math.min(1.0, centerBias));
|
|
368
|
-
threshold = Math.max(0, Math.min(
|
|
418
|
+
threshold = Math.max(0, Math.min(clamp.thresholdMax, threshold));
|
|
369
419
|
return { exponent, bias, centerBias, threshold };
|
|
370
420
|
}
|
|
371
421
|
/**
|
|
@@ -377,6 +427,11 @@ function computeValueAttentionParams(filter, schwartzValues) {
|
|
|
377
427
|
*/
|
|
378
428
|
export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues) {
|
|
379
429
|
const params = computeValueAttentionParams(filter, schwartzValues);
|
|
430
|
+
const belowThresholdGain = param("attention.filter.belowThresholdGain");
|
|
431
|
+
const slowProcessingBelow = param("attention.filter.slowProcessingBelow");
|
|
432
|
+
const speedThresholdBase = param("attention.filter.speedThresholdBase");
|
|
433
|
+
const speedThresholdSlope = param("attention.filter.speedThresholdSlope");
|
|
434
|
+
const normFloor = param("attention.normFloor");
|
|
380
435
|
const filtered = new Float64Array(saliency.length);
|
|
381
436
|
const centerRow = rows / 2;
|
|
382
437
|
const centerCol = cols / 2;
|
|
@@ -387,7 +442,7 @@ export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues)
|
|
|
387
442
|
let s = saliency[idx];
|
|
388
443
|
// Apply threshold floor
|
|
389
444
|
if (s < params.threshold) {
|
|
390
|
-
s *=
|
|
445
|
+
s *= belowThresholdGain;
|
|
391
446
|
}
|
|
392
447
|
// Apply power curve (exponent controls attention concentration)
|
|
393
448
|
s = Math.pow(Math.max(0, s), params.exponent);
|
|
@@ -402,15 +457,15 @@ export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues)
|
|
|
402
457
|
}
|
|
403
458
|
}
|
|
404
459
|
// Apply processing speed
|
|
405
|
-
if (filter.processingSpeed <
|
|
406
|
-
const speedThreshold =
|
|
460
|
+
if (filter.processingSpeed < slowProcessingBelow) {
|
|
461
|
+
const speedThreshold = speedThresholdBase + (1 - filter.processingSpeed) * speedThresholdSlope;
|
|
407
462
|
for (let i = 0; i < filtered.length; i++) {
|
|
408
463
|
if (filtered[i] < speedThreshold)
|
|
409
464
|
filtered[i] *= filter.processingSpeed;
|
|
410
465
|
}
|
|
411
466
|
}
|
|
412
467
|
// Renormalize
|
|
413
|
-
const max = Math.max(...Array.from(filtered),
|
|
468
|
+
const max = Math.max(...Array.from(filtered), normFloor);
|
|
414
469
|
for (let i = 0; i < filtered.length; i++)
|
|
415
470
|
filtered[i] /= max;
|
|
416
471
|
return filtered;
|
|
@@ -421,60 +476,33 @@ export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues)
|
|
|
421
476
|
* These represent how inherently attention-grabbing each element type is,
|
|
422
477
|
* independent of visual appearance. A search bar is visually subtle but
|
|
423
478
|
* a power user looks there first.
|
|
479
|
+
*
|
|
480
|
+
* The weights (cta 1.0 down to decorative 0.1, price and error 0.9) are
|
|
481
|
+
* registered as attention.type.*; with the spatial prior in place their
|
|
482
|
+
* spread contributes nothing measurable on UEyes (see that entry).
|
|
424
483
|
*/
|
|
425
|
-
const
|
|
426
|
-
cta
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
decorative: 0.1, // Decorative images, spacers
|
|
435
|
-
price: 0.9, // Prices — high commercial relevance
|
|
436
|
-
error: 0.9, // Error messages — demand immediate attention
|
|
437
|
-
};
|
|
484
|
+
const ELEMENT_TYPES = [
|
|
485
|
+
"cta", "heading", "form", "search", "image", "navigation", "link", "content", "decorative", "price", "error",
|
|
486
|
+
];
|
|
487
|
+
function elementTypeWeights() {
|
|
488
|
+
const w = {};
|
|
489
|
+
for (const t of ELEMENT_TYPES)
|
|
490
|
+
w[t] = param(`attention.type.${t}`);
|
|
491
|
+
return w;
|
|
492
|
+
}
|
|
438
493
|
/**
|
|
439
|
-
* Persona
|
|
440
|
-
*
|
|
441
|
-
*
|
|
494
|
+
* Persona name → element type weight modifiers, the fallback when no cognitive
|
|
495
|
+
* traits resolve for the persona (computeTraitModifiers is the normal path).
|
|
496
|
+
* The table lives in the registry as attention.personaMod.<persona>.<type>.
|
|
442
497
|
*/
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
cta: 1.5, heading: 1.0, navigation: 0.8, search: 1.3, content: 0.2, decorative: 0.1, price: 1.3,
|
|
452
|
-
},
|
|
453
|
-
"elderly-user": {
|
|
454
|
-
cta: 1.0, heading: 1.4, navigation: 1.3, search: 0.8, content: 0.8, form: 0.9, decorative: 0.3,
|
|
455
|
-
},
|
|
456
|
-
"cognitive-adhd": {
|
|
457
|
-
cta: 0.8, heading: 0.7, navigation: 0.5, image: 1.5, decorative: 1.2, content: 0.3, search: 0.6,
|
|
458
|
-
},
|
|
459
|
-
"anxious-user": {
|
|
460
|
-
cta: 0.7, heading: 1.0, price: 1.5, error: 1.5, form: 0.8, decorative: 0.3,
|
|
461
|
-
},
|
|
462
|
-
"motor-impairment-tremor": {
|
|
463
|
-
cta: 1.0, heading: 0.8, navigation: 0.6, form: 0.7, search: 0.9, content: 0.5,
|
|
464
|
-
},
|
|
465
|
-
"dyslexic-user": {
|
|
466
|
-
cta: 1.1, heading: 1.3, image: 1.3, content: 0.4, navigation: 0.9, decorative: 0.5,
|
|
467
|
-
},
|
|
468
|
-
"low-vision-magnified": {
|
|
469
|
-
cta: 1.2, heading: 1.5, navigation: 1.0, content: 0.5, image: 0.8, decorative: 0.2,
|
|
470
|
-
},
|
|
471
|
-
"mobile-user": {
|
|
472
|
-
cta: 1.3, heading: 1.0, search: 1.2, navigation: 0.7, content: 0.4, decorative: 0.2,
|
|
473
|
-
},
|
|
474
|
-
"confident-user": {
|
|
475
|
-
cta: 0.7, heading: 0.5, navigation: 1.3, search: 1.4, form: 1.2, content: 0.3, decorative: 0.1,
|
|
476
|
-
},
|
|
477
|
-
};
|
|
498
|
+
function staticPersonaModifiers(personaName) {
|
|
499
|
+
const out = {};
|
|
500
|
+
if (!Object.hasOwn(ATTENTION_STATIC_MODIFIER_TYPES, personaName))
|
|
501
|
+
return out;
|
|
502
|
+
for (const t of ATTENTION_STATIC_MODIFIER_TYPES[personaName])
|
|
503
|
+
out[t] = param(`attention.personaMod.${personaName}.${t}`);
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
478
506
|
/**
|
|
479
507
|
* How many ranked cells to keep as the hotspot pool.
|
|
480
508
|
*
|
|
@@ -497,7 +525,7 @@ const PERSONA_ELEMENT_MODIFIERS = {
|
|
|
497
525
|
* `attentionCompetitors`, a different field, so response size is unchanged.
|
|
498
526
|
*/
|
|
499
527
|
function hotspotPoolSize(totalCells) {
|
|
500
|
-
return Math.min(
|
|
528
|
+
return Math.min(param("attention.hotspotPool.max"), Math.max(param("attention.hotspotPool.min"), Math.round(totalCells * param("attention.hotspotPool.share"))));
|
|
501
529
|
}
|
|
502
530
|
/**
|
|
503
531
|
* Compute trait-driven element modifiers from cognitive traits.
|
|
@@ -505,15 +533,18 @@ function hotspotPoolSize(totalCells) {
|
|
|
505
533
|
* ANY persona's trait values, not just the 11 hardcoded ones.
|
|
506
534
|
*/
|
|
507
535
|
function computeTraitModifiers(traits) {
|
|
508
|
-
const
|
|
536
|
+
const P = (id) => param(`attention.traitMod.${id}`);
|
|
537
|
+
const missing = P("missingTrait");
|
|
538
|
+
const reading = { threshold: P("reading.threshold"), base: P("reading.base"), slope: P("reading.slope") };
|
|
539
|
+
const t = (key) => traits[key] ?? missing;
|
|
509
540
|
return {
|
|
510
541
|
// Low patience → fixate on CTAs and search, skip content
|
|
511
|
-
cta:
|
|
512
|
-
search:
|
|
513
|
-
content:
|
|
542
|
+
cta: P("cta.base") + (1.0 - t("patience")) * P("cta.slope"),
|
|
543
|
+
search: P("search.base") + (1.0 - t("patience")) * P("search.slope"),
|
|
544
|
+
content: P("content.base") + t("patience") * P("content.slope"),
|
|
514
545
|
// High curiosity → explore headings, images, more elements
|
|
515
|
-
heading:
|
|
516
|
-
image:
|
|
546
|
+
heading: P("heading.base") + t("curiosity") * P("heading.slope"),
|
|
547
|
+
image: P("image.base") + t("curiosity") * P("image.slope"),
|
|
517
548
|
// Navigation weight is deliberately FAMILIARITY-INDEPENDENT.
|
|
518
549
|
//
|
|
519
550
|
// This read `0.4 + (1.0 - t("siteFamiliarity")) * 0.6`, which made the
|
|
@@ -533,32 +564,36 @@ function computeTraitModifiers(traits) {
|
|
|
533
564
|
//
|
|
534
565
|
// 0.7 is the previous formula evaluated at familiarity 0.5, so a persona
|
|
535
566
|
// with a neutral (or absent) value sees no change at all.
|
|
536
|
-
navigation:
|
|
567
|
+
navigation: P("navigation"),
|
|
537
568
|
// High readingTendency → content draws attention
|
|
538
569
|
// (overrides patience for reading-heavy personas)
|
|
539
|
-
...(t("readingTendency") >
|
|
570
|
+
...(t("readingTendency") > reading.threshold ? { content: reading.base + t("readingTendency") * reading.slope } : {}),
|
|
540
571
|
// Low selfEfficacy → error messages, help text draw attention
|
|
541
|
-
error:
|
|
572
|
+
error: P("error.base") + (1.0 - t("selfEfficacy")) * P("error.slope"),
|
|
542
573
|
// High riskTolerance → prices don't scare, CTAs are natural
|
|
543
|
-
price:
|
|
574
|
+
price: P("price.base") + (1.0 - t("riskTolerance")) * P("price.slope"),
|
|
544
575
|
// Decorative: ADHD-like attention (low metacognitivePlanning = distracted by visuals)
|
|
545
|
-
decorative:
|
|
546
|
-
form:
|
|
576
|
+
decorative: P("decorative.base") + (1.0 - t("metacognitivePlanning")) * P("decorative.slope"),
|
|
577
|
+
form: P("form.base") + t("proceduralFluency") * P("form.slope"),
|
|
547
578
|
};
|
|
548
579
|
}
|
|
580
|
+
/** Goal words that never count as keywords. */
|
|
581
|
+
const GOAL_STOP_WORDS = [
|
|
582
|
+
"the", "a", "an", "to", "for", "of", "in", "on", "at", "is", "it", "my", "i", "me", "and", "or", "how", "do", "can",
|
|
583
|
+
];
|
|
549
584
|
/**
|
|
550
585
|
* Compute goal relevance boost for an element.
|
|
551
586
|
* If the element's text matches the user's goal, massively boost its weight.
|
|
552
587
|
* This is the "task-driven attention" component (Yarbus 1967).
|
|
553
588
|
*/
|
|
554
|
-
function computeGoalRelevance(elementText, goal) {
|
|
589
|
+
function computeGoalRelevance(elementText, goal, minKeywordLength, gain) {
|
|
555
590
|
if (!goal || !elementText)
|
|
556
591
|
return 0;
|
|
557
592
|
const goalLower = goal.toLowerCase();
|
|
558
593
|
const textLower = elementText.toLowerCase();
|
|
559
594
|
// Extract goal keywords (remove stop words)
|
|
560
|
-
const stopWords = new Set(
|
|
561
|
-
const goalWords = goalLower.split(/\s+/).filter(w => w.length
|
|
595
|
+
const stopWords = new Set(GOAL_STOP_WORDS);
|
|
596
|
+
const goalWords = goalLower.split(/\s+/).filter(w => w.length >= minKeywordLength && !stopWords.has(w));
|
|
562
597
|
if (goalWords.length === 0)
|
|
563
598
|
return 0;
|
|
564
599
|
// Score based on keyword matches
|
|
@@ -571,9 +606,41 @@ function computeGoalRelevance(elementText, goal) {
|
|
|
571
606
|
return 0;
|
|
572
607
|
// Partial match: some keywords found
|
|
573
608
|
const matchRatio = matches / goalWords.length;
|
|
574
|
-
// 2.0x boost for full match, proportional for partial
|
|
575
|
-
return matchRatio *
|
|
609
|
+
// 2.0x boost for full match (attention.relevance.gain), proportional for partial
|
|
610
|
+
return matchRatio * gain;
|
|
576
611
|
}
|
|
612
|
+
/**
|
|
613
|
+
* The element weights the semantic layer paints with for this persona: base
|
|
614
|
+
* weight per type, the persona's modifier per type (from its cognitive traits,
|
|
615
|
+
* else the static table, as buildSemanticMap chooses), the weight of an unknown
|
|
616
|
+
* type, and the goal-keyword constants.
|
|
617
|
+
*
|
|
618
|
+
* Exported for the in-page journey heatmap (ATTENTION_OVERLAY_SCRIPT in
|
|
619
|
+
* cognitive/index.ts), which draws inside the browser and cannot call this
|
|
620
|
+
* module. It received a hand-written copy of these tables that had drifted:
|
|
621
|
+
* its navigation weight still read siteFamiliarity, dropped here on
|
|
622
|
+
* 2026-08-05. It now receives this. (ISC-48.1, 2026-10-22;
|
|
623
|
+
* tests/attention-overlay-config.test.ts checks it against buildSemanticMap.)
|
|
624
|
+
*/
|
|
625
|
+
export function semanticWeightTable(personaName, cognitiveTraits) {
|
|
626
|
+
return {
|
|
627
|
+
typeWeights: elementTypeWeights(),
|
|
628
|
+
personaMods: cognitiveTraits ? computeTraitModifiers(cognitiveTraits) : staticPersonaModifiers(personaName),
|
|
629
|
+
typeFallback: param("attention.type.fallback"),
|
|
630
|
+
relevanceGain: param("attention.relevance.gain"),
|
|
631
|
+
minKeywordLength: param("attention.goal.minKeywordLength"),
|
|
632
|
+
stopWords: [...GOAL_STOP_WORDS],
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/** Text cues for Schwartz-value relevance, in the order their boosts apply. */
|
|
636
|
+
const VALUE_PATTERN_REGEX = {
|
|
637
|
+
trust: /secur|trust|verif|guarant|privacy|encrypt|safe|protect/i,
|
|
638
|
+
social: /review|rating|star|trusted.?by|\d+[kK]?\+?\s*users|testimonial/i,
|
|
639
|
+
novelty: /\bnew\b|beta|launch|updat|just.?added|latest|early.?access/i,
|
|
640
|
+
metrics: /\d+%|\d+x\s|roi|faster|improv|performance|\$\d/i,
|
|
641
|
+
urgency: /limited|only\s*\d|hurry|expire|last.?chance|act.?now/i,
|
|
642
|
+
community: /community|join|forum|together|open.?source|contrib/i,
|
|
643
|
+
};
|
|
577
644
|
/**
|
|
578
645
|
* Build a semantic attention map from DOM elements.
|
|
579
646
|
*
|
|
@@ -607,20 +674,24 @@ relevanceScores,
|
|
|
607
674
|
*/
|
|
608
675
|
typeWeights) {
|
|
609
676
|
const map = new Float64Array(rows * cols);
|
|
610
|
-
const TYPE_W = typeWeights ??
|
|
677
|
+
const TYPE_W = typeWeights ?? elementTypeWeights();
|
|
678
|
+
const typeFallback = param("attention.type.fallback");
|
|
679
|
+
const relevanceGain = param("attention.relevance.gain");
|
|
680
|
+
const minKeywordLength = param("attention.goal.minKeywordLength");
|
|
681
|
+
const neutral = param("attention.value.neutral");
|
|
682
|
+
const edgeEpsilon = param("attention.grid.edgeEpsilon");
|
|
683
|
+
const normPercentile = param("attention.normPercentile");
|
|
684
|
+
const normFloor = param("attention.normFloor");
|
|
611
685
|
// Use trait-driven modifiers if traits available, else fall back to static table
|
|
612
686
|
const modifiers = cognitiveTraits
|
|
613
687
|
? computeTraitModifiers(cognitiveTraits)
|
|
614
|
-
: (
|
|
615
|
-
// Value-driven semantic patterns (imported from attention-quality)
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
urgency: { pattern: /limited|only\s*\d|hurry|expire|last.?chance|act.?now/i, values: { stimulation: 0.5, achievement: 0.4 } },
|
|
622
|
-
community: { pattern: /community|join|forum|together|open.?source|contrib/i, values: { benevolence: 0.8, universalism: 0.6 } },
|
|
623
|
-
};
|
|
688
|
+
: staticPersonaModifiers(personaName);
|
|
689
|
+
// Value-driven semantic patterns (imported from attention-quality). The
|
|
690
|
+
// weights are attention.valuePattern.<pattern>.<value>, summed in this order.
|
|
691
|
+
const VALUE_PATTERNS = Object.entries(VALUE_PATTERN_REGEX).map(([name, pattern]) => ({
|
|
692
|
+
pattern,
|
|
693
|
+
values: ATTENTION_VALUE_PATTERN_VALUES[name].map((v) => [v, param(`attention.valuePattern.${name}.${v}`)]),
|
|
694
|
+
}));
|
|
624
695
|
for (const [elementIndex, el] of elements.entries()) {
|
|
625
696
|
// Determine element type
|
|
626
697
|
let elType = el.type || "content";
|
|
@@ -633,7 +704,7 @@ typeWeights) {
|
|
|
633
704
|
else if (el.isDecorative)
|
|
634
705
|
elType = "decorative";
|
|
635
706
|
// Layer 1: Base weight by element type
|
|
636
|
-
let weight = TYPE_W[elType] ??
|
|
707
|
+
let weight = TYPE_W[elType] ?? typeFallback;
|
|
637
708
|
// Layer 2: Persona modifier (trait-driven or static lookup)
|
|
638
709
|
const modifier = modifiers[elType] ?? 1.0;
|
|
639
710
|
weight *= modifier;
|
|
@@ -646,22 +717,22 @@ typeWeights) {
|
|
|
646
717
|
// is a floor to degrade to, never the intended path.
|
|
647
718
|
const judged = relevanceScores?.[elementIndex];
|
|
648
719
|
if (judged !== undefined && judged > 0) {
|
|
649
|
-
weight *= (1.0 + judged *
|
|
720
|
+
weight *= (1.0 + judged * relevanceGain); // same 3x ceiling as the keyword path
|
|
650
721
|
}
|
|
651
722
|
else if (goal && el.text) {
|
|
652
|
-
const goalBoost = computeGoalRelevance(el.text, goal);
|
|
723
|
+
const goalBoost = computeGoalRelevance(el.text, goal, minKeywordLength, relevanceGain);
|
|
653
724
|
if (goalBoost > 0) {
|
|
654
725
|
weight *= (1.0 + goalBoost);
|
|
655
726
|
}
|
|
656
727
|
}
|
|
657
728
|
// Layer 4: Schwartz value relevance — elements matching persona values get boost
|
|
658
729
|
if (schwartzValues && el.text) {
|
|
659
|
-
for (const
|
|
730
|
+
for (const pattern of VALUE_PATTERNS) {
|
|
660
731
|
if (pattern.pattern.test(el.text)) {
|
|
661
732
|
let valueBoost = 0;
|
|
662
|
-
for (const [valueName, valueWeight] of
|
|
663
|
-
const personaValue = schwartzValues[valueName] ??
|
|
664
|
-
valueBoost += (personaValue -
|
|
733
|
+
for (const [valueName, valueWeight] of pattern.values) {
|
|
734
|
+
const personaValue = schwartzValues[valueName] ?? neutral;
|
|
735
|
+
valueBoost += (personaValue - neutral) * valueWeight;
|
|
665
736
|
}
|
|
666
737
|
weight *= (1.0 + Math.max(0, valueBoost));
|
|
667
738
|
}
|
|
@@ -691,9 +762,9 @@ typeWeights) {
|
|
|
691
762
|
const x0 = box.x, x1 = box.x + box.width;
|
|
692
763
|
const y0 = box.y, y1 = box.y + box.height;
|
|
693
764
|
const startCol = Math.max(0, Math.floor(x0 / pxPerCol));
|
|
694
|
-
const endCol = Math.min(cols - 1, Math.floor((x1 -
|
|
765
|
+
const endCol = Math.min(cols - 1, Math.floor((x1 - edgeEpsilon) / pxPerCol));
|
|
695
766
|
const startRow = Math.max(0, Math.floor(y0 / pxPerRow));
|
|
696
|
-
const endRow = Math.min(rows - 1, Math.floor((y1 -
|
|
767
|
+
const endRow = Math.min(rows - 1, Math.floor((y1 - edgeEpsilon) / pxPerRow));
|
|
697
768
|
for (let r = startRow; r <= endRow; r++) {
|
|
698
769
|
const cellTop = r * pxPerRow;
|
|
699
770
|
const overlapY = Math.max(0, Math.min(y1, cellTop + pxPerRow) - Math.max(y0, cellTop));
|
|
@@ -726,9 +797,9 @@ typeWeights) {
|
|
|
726
797
|
// exceptional cells clip, which is standard practice for rendering saliency.
|
|
727
798
|
const sorted = Array.from(map).filter((v) => v > 0).sort((a, b) => a - b);
|
|
728
799
|
const p95 = sorted.length > 0
|
|
729
|
-
? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length *
|
|
730
|
-
:
|
|
731
|
-
const denom = Math.max(p95,
|
|
800
|
+
? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * normPercentile))]
|
|
801
|
+
: normFloor;
|
|
802
|
+
const denom = Math.max(p95, normFloor);
|
|
732
803
|
for (let i = 0; i < map.length; i++)
|
|
733
804
|
map[i] = Math.min(1, map[i] / denom);
|
|
734
805
|
return map;
|
|
@@ -769,13 +840,14 @@ typeWeights) {
|
|
|
769
840
|
* attention for a specific persona. 0.05 is the best available estimate, not a
|
|
770
841
|
* validated constant for the goal-directed case.
|
|
771
842
|
*/
|
|
772
|
-
export function blendSaliencyMaps(visual, semantic, visualWeight =
|
|
843
|
+
export function blendSaliencyMaps(visual, semantic, visualWeight = param("attention.blend.legacyVisualWeight"), semanticWeight = param("attention.blend.legacySemanticWeight")) {
|
|
844
|
+
const normFloor = param("attention.normFloor");
|
|
773
845
|
const blended = new Float64Array(visual.length);
|
|
774
846
|
for (let i = 0; i < visual.length; i++) {
|
|
775
847
|
blended[i] = visual[i] * visualWeight + semantic[i] * semanticWeight;
|
|
776
848
|
}
|
|
777
849
|
// Renormalize
|
|
778
|
-
const max = Math.max(...Array.from(blended),
|
|
850
|
+
const max = Math.max(...Array.from(blended), normFloor);
|
|
779
851
|
for (let i = 0; i < blended.length; i++)
|
|
780
852
|
blended[i] /= max;
|
|
781
853
|
return blended;
|
|
@@ -810,8 +882,6 @@ export function blendSaliencyMaps(visual, semantic, visualWeight = 0.05, semanti
|
|
|
810
882
|
*
|
|
811
883
|
* Pass `spatialPrior: 0` to disable it for any of those cases.
|
|
812
884
|
*/
|
|
813
|
-
const WEB_SPATIAL_PRIOR_ROWS = 12;
|
|
814
|
-
const WEB_SPATIAL_PRIOR_COLS = 16;
|
|
815
885
|
const WEB_SPATIAL_PRIOR = [
|
|
816
886
|
0.3438, 0.4761, 0.6428, 0.8103, 0.9375, 1.0000, 0.9920, 0.9220, 0.8109, 0.6831, 0.5571, 0.4409, 0.3344, 0.2406, 0.1686, 0.1216,
|
|
817
887
|
0.3225, 0.4491, 0.6086, 0.7704, 0.8973, 0.9657, 0.9669, 0.9055, 0.8004, 0.6767, 0.5533, 0.4380, 0.3308, 0.2359, 0.1636, 0.1172,
|
|
@@ -826,13 +896,63 @@ const WEB_SPATIAL_PRIOR = [
|
|
|
826
896
|
0.1355, 0.1682, 0.2067, 0.2426, 0.2686, 0.2818, 0.2811, 0.2677, 0.2465, 0.2236, 0.2021, 0.1813, 0.1578, 0.1310, 0.1061, 0.0877,
|
|
827
897
|
0.1150, 0.1385, 0.1658, 0.1908, 0.2086, 0.2173, 0.2160, 0.2056, 0.1899, 0.1736, 0.1587, 0.1442, 0.1278, 0.1094, 0.0923, 0.0795,
|
|
828
898
|
];
|
|
829
|
-
/**
|
|
830
|
-
|
|
899
|
+
/**
|
|
900
|
+
* The fitted table, checked against its registered checksum on every use.
|
|
901
|
+
*
|
|
902
|
+
* The 192 cells are registered as ONE fitted entry (attention.spatialPrior.checksum,
|
|
903
|
+
* with its shape as .rows and .cols) rather than one per cell; provenance/attention.ts
|
|
904
|
+
* says why. Reading the checksum through param() and refusing a table that no
|
|
905
|
+
* longer hashes to it is what ties this literal to the registry: a cell edited
|
|
906
|
+
* here without re-registering stops the model instead of shipping unrecorded.
|
|
907
|
+
*/
|
|
908
|
+
function spatialPrior() {
|
|
909
|
+
const rows = param("attention.spatialPrior.rows");
|
|
910
|
+
const cols = param("attention.spatialPrior.cols");
|
|
911
|
+
const checksum = param("attention.spatialPrior.checksum");
|
|
912
|
+
if (WEB_SPATIAL_PRIOR.length !== rows * cols || WEB_SPATIAL_PRIOR_CHECKSUM !== checksum) {
|
|
913
|
+
throw new Error(`WEB_SPATIAL_PRIOR does not match its registration (${WEB_SPATIAL_PRIOR.length} cells, checksum ${WEB_SPATIAL_PRIOR_CHECKSUM}; ` +
|
|
914
|
+
`registered ${rows}x${cols}, checksum ${checksum}). Re-fit or re-register it in provenance/attention.ts.`);
|
|
915
|
+
}
|
|
916
|
+
return { table: WEB_SPATIAL_PRIOR, rows, cols };
|
|
917
|
+
}
|
|
918
|
+
const WEB_SPATIAL_PRIOR_CHECKSUM = tableChecksum(WEB_SPATIAL_PRIOR);
|
|
919
|
+
/**
|
|
920
|
+
* Fitted operating point, all five CV folds unanimous. See `applyGazeCalibration`.
|
|
921
|
+
*
|
|
922
|
+
* A live view of the registry (attention.gaze.*): each field reads its entry
|
|
923
|
+
* through param() when read, so this object cannot disagree with the values the
|
|
924
|
+
* model runs on.
|
|
925
|
+
*/
|
|
926
|
+
export const GAZE_CALIBRATION = Object.freeze({
|
|
927
|
+
get visualWeight() { return param("attention.gaze.visualWeight"); },
|
|
928
|
+
get sigmaCells() { return param("attention.gaze.sigmaCells"); },
|
|
929
|
+
get spatialPrior() { return param("attention.gaze.spatialPrior"); },
|
|
930
|
+
});
|
|
931
|
+
/**
|
|
932
|
+
* The cell size the calibration was fitted at: research/study1 runs every
|
|
933
|
+
* stimulus at CELL = 16 (run-optimize.ts, run-semantic.ts). computeLabSaliency
|
|
934
|
+
* halves the image before celling, so one fitted cell spans 32 capture px and
|
|
935
|
+
* `sigmaCells` 2.5 is 80 capture px. Registered as attention.gaze.cellSize; this
|
|
936
|
+
* export is that entry's value, read once at load.
|
|
937
|
+
*/
|
|
938
|
+
export const GAZE_CALIBRATION_CELL_SIZE = param("attention.gaze.cellSize");
|
|
939
|
+
/**
|
|
940
|
+
* `GAZE_CALIBRATION.sigmaCells` rescaled to a grid of `cellSize`, so the
|
|
941
|
+
* smoothing keeps its fitted width in pixels.
|
|
942
|
+
*
|
|
943
|
+
* The blur runs in cells, and until 2026-10-21 every caller passed 2.5 cells
|
|
944
|
+
* whatever its grid: attention_analysis (cellSize 4) smoothed at a quarter of the
|
|
945
|
+
* fitted width, journey_heatmap_gif (8) at half. Exactly 2.5 at 16, so the
|
|
946
|
+
* Study 1 numbers do not move.
|
|
947
|
+
*/
|
|
948
|
+
export function gazeSigmaCells(cellSize) {
|
|
949
|
+
return param("attention.gaze.sigmaCells") * (param("attention.gaze.cellSize") / cellSize);
|
|
950
|
+
}
|
|
831
951
|
/** Separable Gaussian blur over the cell grid. */
|
|
832
952
|
export function smoothSaliency(map, rows, cols, sigma) {
|
|
833
953
|
if (sigma <= 0)
|
|
834
954
|
return map;
|
|
835
|
-
const rad = Math.ceil(sigma *
|
|
955
|
+
const rad = Math.ceil(sigma * param("attention.smooth.radiusSigmas")), inv = 1 / (2 * sigma * sigma), k = [];
|
|
836
956
|
for (let d = -rad; d <= rad; d++)
|
|
837
957
|
k.push(Math.exp(-d * d * inv));
|
|
838
958
|
const tmp = new Float64Array(map.length), out = new Float64Array(map.length);
|
|
@@ -877,16 +997,21 @@ export function smoothSaliency(map, rows, cols, sigma) {
|
|
|
877
997
|
* 0.0005. Human inter-observer ceiling on the same data is 0.8112, so this
|
|
878
998
|
* closes about 52% of the reachable gap. All five folds selected the same
|
|
879
999
|
* values.
|
|
1000
|
+
*
|
|
1001
|
+
* `sigma` is in cells and its default is right only on the fitted grid
|
|
1002
|
+
* (GAZE_CALIBRATION_CELL_SIZE); on any other grid pass `gazeSigmaCells(cellSize)`.
|
|
1003
|
+
* The spatial prior needs no rescaling: it is looked up by normalised position.
|
|
880
1004
|
*/
|
|
881
|
-
export function applyGazeCalibration(map, rows, cols, sigma =
|
|
1005
|
+
export function applyGazeCalibration(map, rows, cols, sigma = param("attention.gaze.sigmaCells"), priorStrength = param("attention.gaze.spatialPrior")) {
|
|
882
1006
|
let m = smoothSaliency(map, rows, cols, sigma);
|
|
883
1007
|
if (priorStrength > 0) {
|
|
1008
|
+
const prior = spatialPrior();
|
|
884
1009
|
const out = new Float64Array(m.length);
|
|
885
1010
|
for (let r = 0; r < rows; r++) {
|
|
886
|
-
const pr = Math.min(
|
|
1011
|
+
const pr = Math.min(prior.rows - 1, Math.floor((r / rows) * prior.rows));
|
|
887
1012
|
for (let c = 0; c < cols; c++) {
|
|
888
|
-
const pc = Math.min(
|
|
889
|
-
const p =
|
|
1013
|
+
const pc = Math.min(prior.cols - 1, Math.floor((c / cols) * prior.cols));
|
|
1014
|
+
const p = prior.table[pr * prior.cols + pc];
|
|
890
1015
|
out[r * cols + c] = m[r * cols + c] * (1 - priorStrength + priorStrength * p);
|
|
891
1016
|
}
|
|
892
1017
|
}
|
|
@@ -895,6 +1020,17 @@ export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.s
|
|
|
895
1020
|
return m;
|
|
896
1021
|
}
|
|
897
1022
|
// ── Public API ──
|
|
1023
|
+
/** Metric definitions and display rounding shared by both analysis paths. */
|
|
1024
|
+
function readMetricParams() {
|
|
1025
|
+
return {
|
|
1026
|
+
entropyEpsilon: param("attention.metric.entropyEpsilon"),
|
|
1027
|
+
concentrationShare: param("attention.metric.concentrationShare"),
|
|
1028
|
+
competitors: param("attention.report.competitors"),
|
|
1029
|
+
r2: param("attention.rounding.hundredths"),
|
|
1030
|
+
r3: param("attention.rounding.thousandths"),
|
|
1031
|
+
r4: param("attention.rounding.tenThousandths"),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
898
1034
|
/**
|
|
899
1035
|
* Compute persona-specific attention analysis for a screenshot.
|
|
900
1036
|
*
|
|
@@ -902,7 +1038,8 @@ export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.s
|
|
|
902
1038
|
* 1. Visual saliency — W₂ on CIE-Lab distributions (what POPS visually)
|
|
903
1039
|
* 2. Semantic attention — DOM element classification × persona priorities (what MATTERS)
|
|
904
1040
|
*
|
|
905
|
-
* Blended at
|
|
1041
|
+
* Blended at 20% visual / 80% semantic (GAZE_CALIBRATION, refitted with the spatial
|
|
1042
|
+
* prior on 2026-08-04; 5% was the optimum before the prior existed). The direction is the one the literature
|
|
906
1043
|
* predicts — task-driven attention dominates bottom-up saliency for goal-oriented
|
|
907
1044
|
* users (Yarbus 1967, Henderson 2003, Tatler et al. 2011) — but the number is no
|
|
908
1045
|
* longer read off those papers. It was swept against 495 UEyes web stimuli and
|
|
@@ -921,9 +1058,11 @@ export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.s
|
|
|
921
1058
|
* are interchangeable. Typical runtime: 5-30ms vs 1500-3000ms for the full
|
|
922
1059
|
* version.
|
|
923
1060
|
*/
|
|
924
|
-
export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, domElements, personaName, cellSize =
|
|
1061
|
+
export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, domElements, personaName, cellSize = param("attention.gaze.cellSize"), schwartzValues, goal, cognitiveTraits, relevanceScores, accessibilityTraits) {
|
|
925
1062
|
const startTime = performance.now();
|
|
926
|
-
const
|
|
1063
|
+
const M = readMetricParams();
|
|
1064
|
+
const resolvedProfile = resolvePerceptualProfile(personaName, accessibilityTraits);
|
|
1065
|
+
const profile = resolvedProfile.profile;
|
|
927
1066
|
// Look up Schwartz values from built-in profiles if caller didn't pass any
|
|
928
1067
|
let values = schwartzValues;
|
|
929
1068
|
if (!values) {
|
|
@@ -966,9 +1105,17 @@ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, dom
|
|
|
966
1105
|
//
|
|
967
1106
|
// This path exists so callers are INTERCHANGEABLE with analyzeAttention (its
|
|
968
1107
|
// own docstring says so). Calibrating one and not the other would silently
|
|
969
|
-
// make the fast path and the full path disagree about where a persona looks
|
|
970
|
-
//
|
|
971
|
-
|
|
1108
|
+
// make the fast path and the full path disagree about where a persona looks.
|
|
1109
|
+
//
|
|
1110
|
+
// Sigma keeps the fitted width in PIXELS (80, at device pixel ratio 1). This
|
|
1111
|
+
// grid is not halved, so a cell here spans cellSize px, which is the cell
|
|
1112
|
+
// analyzeAttention gets from cellSize x downscale. gazeSigmaCells(cellSize)
|
|
1113
|
+
// alone smoothed this path at 40 px, half the fitted width, at every
|
|
1114
|
+
// cellSize (ISC-47, fixed 2026-10-22; tests/attention-dom-gaze-width.test.ts).
|
|
1115
|
+
// The live cognitive-journey overlay does NOT run this function: it draws
|
|
1116
|
+
// its own in-page copy (ATTENTION_OVERLAY_SCRIPT in cognitive/index.ts), so
|
|
1117
|
+
// no shipped overlay moved with this fix.
|
|
1118
|
+
const filtered = applyGazeCalibration(applyPersonaFilter(semanticMap, rows, cols, profile.visualFilter, values), rows, cols, gazeSigmaCells(cellSize * param("attention.lab.downscale")));
|
|
972
1119
|
// Hotspots, entropy, concentration — same shape as analyzeAttention
|
|
973
1120
|
const indexed = Array.from(filtered).map((s, i) => ({ s, i }));
|
|
974
1121
|
indexed.sort((a, b) => b.s - a.s);
|
|
@@ -984,15 +1131,16 @@ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, dom
|
|
|
984
1131
|
for (let i = 0; i < filtered.length; i++) {
|
|
985
1132
|
const p = filtered[i] / totalSaliency;
|
|
986
1133
|
if (p > 0)
|
|
987
|
-
entropy -= p * Math.log2(p +
|
|
1134
|
+
entropy -= p * Math.log2(p + M.entropyEpsilon);
|
|
988
1135
|
}
|
|
989
1136
|
entropy = entropy / Math.log2(filtered.length || 1);
|
|
990
1137
|
const sorted = Array.from(filtered).sort((a, b) => b - a);
|
|
991
|
-
const top20Count = Math.ceil(filtered.length *
|
|
1138
|
+
const top20Count = Math.ceil(filtered.length * M.concentrationShare);
|
|
992
1139
|
const top20Sum = sorted.slice(0, top20Count).reduce((a, b) => a + b, 0);
|
|
993
1140
|
const concentration = top20Sum / totalSaliency;
|
|
994
1141
|
return {
|
|
995
1142
|
persona: personaName,
|
|
1143
|
+
perceptualProfile: { resolution: resolvedProfile.resolution, name: resolvedProfile.resolvedProfile },
|
|
996
1144
|
saliencyMap: {
|
|
997
1145
|
cells: filtered,
|
|
998
1146
|
rows, cols,
|
|
@@ -1001,19 +1149,23 @@ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, dom
|
|
|
1001
1149
|
hotspots,
|
|
1002
1150
|
},
|
|
1003
1151
|
alignmentScore: 1, // No base to compare against
|
|
1004
|
-
entropy: Math.round(entropy *
|
|
1005
|
-
concentration: Math.round(concentration *
|
|
1006
|
-
attentionCompetitors: hotspots.slice(0,
|
|
1152
|
+
entropy: Math.round(entropy * M.r3) / M.r3,
|
|
1153
|
+
concentration: Math.round(concentration * M.r3) / M.r3,
|
|
1154
|
+
attentionCompetitors: hotspots.slice(0, M.competitors).map((h) => ({
|
|
1007
1155
|
region: `(${h.x}, ${h.y})`,
|
|
1008
|
-
saliency: Math.round(h.saliency *
|
|
1156
|
+
saliency: Math.round(h.saliency * M.r2) / M.r2,
|
|
1009
1157
|
})),
|
|
1010
1158
|
transportCost: 0,
|
|
1011
1159
|
computeTimeMs: Math.round(performance.now() - startTime),
|
|
1012
1160
|
};
|
|
1013
1161
|
}
|
|
1014
|
-
export async function analyzeAttention(screenshotPath, personaName, cellSize =
|
|
1162
|
+
export async function analyzeAttention(screenshotPath, personaName, cellSize = param("attention.gaze.cellSize"), schwartzValues, domElements, goal, cognitiveTraits, relevanceScores, accessibilityTraits) {
|
|
1015
1163
|
const startTime = performance.now();
|
|
1016
|
-
const
|
|
1164
|
+
const M = readMetricParams();
|
|
1165
|
+
const downscale = param("attention.lab.downscale");
|
|
1166
|
+
const visualWeight = param("attention.gaze.visualWeight");
|
|
1167
|
+
const resolvedProfile = resolvePerceptualProfile(personaName, accessibilityTraits);
|
|
1168
|
+
const profile = resolvedProfile.profile;
|
|
1017
1169
|
// v18.54.0: If no values passed, try to look them up from the built-in value profiles
|
|
1018
1170
|
let values = schwartzValues;
|
|
1019
1171
|
if (!values) {
|
|
@@ -1056,13 +1208,13 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1056
1208
|
// blend to take more from the visual channel. Re-fitted WITH the prior and
|
|
1057
1209
|
// smoothing in place -- all five CV folds selected 0.2. Carrying 0.05 over
|
|
1058
1210
|
// would have been importing half of an optimum.
|
|
1059
|
-
combinedSaliency = blendSaliencyMaps(baseSaliency.cells, semanticMap,
|
|
1211
|
+
combinedSaliency = blendSaliencyMaps(baseSaliency.cells, semanticMap, visualWeight, 1 - visualWeight);
|
|
1060
1212
|
}
|
|
1061
1213
|
else {
|
|
1062
1214
|
// No DOM data — fall back to visual-only.
|
|
1063
1215
|
//
|
|
1064
1216
|
// This fallback changes what the heatmap MEANS, and said so nowhere. With
|
|
1065
|
-
// DOM elements the map is
|
|
1217
|
+
// DOM elements the map is 20% bottom-up saliency + 80% semantic (element
|
|
1066
1218
|
// type x persona priority) — a model of attention. Without them it is the
|
|
1067
1219
|
// bottom-up layer alone: centre-surround colour contrast in CIE-Lab. Those
|
|
1068
1220
|
// are different quantities under one name, and two of the three production
|
|
@@ -1077,16 +1229,17 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1077
1229
|
}
|
|
1078
1230
|
const attentionModel = domElements && domElements.length > 0 ? "visual+semantic" : "visual-only";
|
|
1079
1231
|
// Apply persona perceptual filter + value-driven attention modulation, then
|
|
1080
|
-
// foveal smoothing and the fitted spatial prior (see applyGazeCalibration)
|
|
1081
|
-
|
|
1232
|
+
// foveal smoothing and the fitted spatial prior (see applyGazeCalibration),
|
|
1233
|
+
// the smoothing scaled to this grid so its width in pixels is the fitted one.
|
|
1234
|
+
const filtered = applyGazeCalibration(applyPersonaFilter(combinedSaliency, baseSaliency.rows, baseSaliency.cols, profile.visualFilter, values), baseSaliency.rows, baseSaliency.cols, gazeSigmaCells(cellSize));
|
|
1082
1235
|
// Build saliency map with hotspots
|
|
1083
1236
|
const indexed = Array.from(filtered).map((s, i) => ({ s, i }));
|
|
1084
1237
|
indexed.sort((a, b) => b.s - a.s);
|
|
1085
1238
|
const hotspots = indexed.slice(0, hotspotPoolSize(indexed.length)).map(({ s, i }) => ({
|
|
1086
1239
|
row: Math.floor(i / baseSaliency.cols),
|
|
1087
1240
|
col: i % baseSaliency.cols,
|
|
1088
|
-
x: (i % baseSaliency.cols) * cellSize
|
|
1089
|
-
y: Math.floor(i / baseSaliency.cols) * cellSize
|
|
1241
|
+
x: (i % baseSaliency.cols) * cellSize / downscale, // Scale back to original
|
|
1242
|
+
y: Math.floor(i / baseSaliency.cols) * cellSize / downscale,
|
|
1090
1243
|
saliency: s,
|
|
1091
1244
|
}));
|
|
1092
1245
|
const saliencyMap = {
|
|
@@ -1094,7 +1247,7 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1094
1247
|
/**
|
|
1095
1248
|
* Which model produced `cells`.
|
|
1096
1249
|
*
|
|
1097
|
-
* "visual+semantic" —
|
|
1250
|
+
* "visual+semantic" — 20% centre-surround CIE-Lab saliency blended with 80%
|
|
1098
1251
|
* DOM-semantic attention (element type x persona priority).
|
|
1099
1252
|
* "visual-only" — the bottom-up layer alone, i.e. local colour-contrast
|
|
1100
1253
|
* pop-out. Useful, but NOT a model of where a person looks.
|
|
@@ -1104,7 +1257,7 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1104
1257
|
cols: baseSaliency.cols,
|
|
1105
1258
|
width: baseSaliency.width,
|
|
1106
1259
|
height: baseSaliency.height,
|
|
1107
|
-
cellSize: cellSize
|
|
1260
|
+
cellSize: cellSize / downscale, // Scaled
|
|
1108
1261
|
hotspots,
|
|
1109
1262
|
};
|
|
1110
1263
|
// Compute attention metrics
|
|
@@ -1114,13 +1267,13 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1114
1267
|
for (let i = 0; i < filtered.length; i++) {
|
|
1115
1268
|
const p = filtered[i] / totalSaliency;
|
|
1116
1269
|
if (p > 0)
|
|
1117
|
-
entropy -= p * Math.log2(p +
|
|
1270
|
+
entropy -= p * Math.log2(p + M.entropyEpsilon);
|
|
1118
1271
|
}
|
|
1119
1272
|
const maxEntropy = Math.log2(filtered.length);
|
|
1120
1273
|
entropy = entropy / maxEntropy; // Normalize to 0-1
|
|
1121
1274
|
// Concentration: fraction of total saliency in top 20% of cells
|
|
1122
1275
|
const sorted = Array.from(filtered).sort((a, b) => b - a);
|
|
1123
|
-
const top20Count = Math.ceil(filtered.length *
|
|
1276
|
+
const top20Count = Math.ceil(filtered.length * M.concentrationShare);
|
|
1124
1277
|
const top20Sum = sorted.slice(0, top20Count).reduce((a, b) => a + b, 0);
|
|
1125
1278
|
const concentration = top20Sum / totalSaliency;
|
|
1126
1279
|
// Alignment: how well does the persona's saliency match the base (unfiltered) saliency?
|
|
@@ -1142,18 +1295,19 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1142
1295
|
transportCost += Math.abs(baseSaliency.cells[i] / (totalSaliency || 1) - filtered[i] / (totalSaliency || 1));
|
|
1143
1296
|
}
|
|
1144
1297
|
// Attention competitors: group hotspots into regions
|
|
1145
|
-
const attentionCompetitors = hotspots.slice(0,
|
|
1298
|
+
const attentionCompetitors = hotspots.slice(0, M.competitors).map((h, i) => ({
|
|
1146
1299
|
region: `(${h.x}, ${h.y})`,
|
|
1147
|
-
saliency: Math.round(h.saliency *
|
|
1300
|
+
saliency: Math.round(h.saliency * M.r2) / M.r2,
|
|
1148
1301
|
}));
|
|
1149
1302
|
return {
|
|
1150
1303
|
persona: personaName,
|
|
1304
|
+
perceptualProfile: { resolution: resolvedProfile.resolution, name: resolvedProfile.resolvedProfile },
|
|
1151
1305
|
saliencyMap,
|
|
1152
|
-
alignmentScore: Math.round(alignmentScore *
|
|
1153
|
-
entropy: Math.round(entropy *
|
|
1154
|
-
concentration: Math.round(concentration *
|
|
1306
|
+
alignmentScore: Math.round(alignmentScore * M.r3) / M.r3,
|
|
1307
|
+
entropy: Math.round(entropy * M.r3) / M.r3,
|
|
1308
|
+
concentration: Math.round(concentration * M.r3) / M.r3,
|
|
1155
1309
|
attentionCompetitors,
|
|
1156
|
-
transportCost: Math.round(transportCost *
|
|
1310
|
+
transportCost: Math.round(transportCost * M.r4) / M.r4,
|
|
1157
1311
|
computeTimeMs: Math.round(performance.now() - startTime),
|
|
1158
1312
|
};
|
|
1159
1313
|
}
|
|
@@ -1162,11 +1316,17 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
|
|
|
1162
1316
|
* Returns the L1 divergence between their normalized saliency maps (described
|
|
1163
1317
|
* elsewhere as Wasserstein; it is not, see attentionDivergence) and the
|
|
1164
1318
|
* connected regions where attention differs most.
|
|
1319
|
+
*
|
|
1320
|
+
* `accessibilityTraitsA` / `B` are each persona's own accessibility traits, so
|
|
1321
|
+
* a persona without a built-in perceptual profile is filtered through one
|
|
1322
|
+
* synthesized from them, as analyzeAttention does when given them (ISC-48).
|
|
1165
1323
|
*/
|
|
1166
|
-
export async function compareAttention(screenshotPath, personaA, personaB, cellSize =
|
|
1324
|
+
export async function compareAttention(screenshotPath, personaA, personaB, cellSize = param("attention.gaze.cellSize"), domElements, goal, accessibilityTraitsA, accessibilityTraitsB) {
|
|
1325
|
+
const r4 = param("attention.rounding.tenThousandths");
|
|
1326
|
+
const regionCount = param("attention.report.divergentRegions");
|
|
1167
1327
|
const [analysisA, analysisB] = await Promise.all([
|
|
1168
|
-
analyzeAttention(screenshotPath, personaA, cellSize, undefined, domElements, goal),
|
|
1169
|
-
analyzeAttention(screenshotPath, personaB, cellSize, undefined, domElements, goal),
|
|
1328
|
+
analyzeAttention(screenshotPath, personaA, cellSize, undefined, domElements, goal, undefined, undefined, accessibilityTraitsA),
|
|
1329
|
+
analyzeAttention(screenshotPath, personaB, cellSize, undefined, domElements, goal, undefined, undefined, accessibilityTraitsB),
|
|
1170
1330
|
]);
|
|
1171
1331
|
// One pass computes the aggregate AND the regions from the same normalized
|
|
1172
1332
|
// maps, so the regions are by construction the contributors to the number
|
|
@@ -1179,8 +1339,8 @@ export async function compareAttention(screenshotPath, personaA, personaB, cellS
|
|
|
1179
1339
|
return {
|
|
1180
1340
|
personaA: analysisA,
|
|
1181
1341
|
personaB: analysisB,
|
|
1182
|
-
attentionDivergence: Math.round(divergence *
|
|
1183
|
-
divergentRegions: regions.slice(0,
|
|
1342
|
+
attentionDivergence: Math.round(divergence * r4) / r4,
|
|
1343
|
+
divergentRegions: regions.slice(0, regionCount),
|
|
1184
1344
|
};
|
|
1185
1345
|
}
|
|
1186
1346
|
//# sourceMappingURL=attention-transport.js.map
|