Skip to content

Commit 68e55e0

Browse files
sschabackerclaude
andcommitted
Add frame links to the Measures table (hard rule #6 gap)
Every measure now links back to whatever frame(s) actually produced it: single-event measures (latency, hole deviation) get one "Go to frame" button; counts derived from several visits (errors, revisits) get one button per contributing visit instead of a symbolic anchor, so nothing gets silently collapsed. Trial-wide aggregates (path length, speed, quadrant time) link the trial-start frame, labeled as an anchor rather than implying it's "the" producing frame. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 42d331c commit 68e55e0

4 files changed

Lines changed: 101 additions & 37 deletions

File tree

‎app/index.html‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,13 +656,15 @@
656656
cursor: pointer;
657657
}
658658
#visitsTable .goToFrameBtn:hover { background: var(--panel-bg); }
659-
#measuresTableWrap { overflow-x: auto; margin-top: 1rem; max-width: 32rem; }
659+
#measuresTableWrap { overflow-x: auto; margin-top: 1rem; max-width: 42rem; }
660660
#measuresTable { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
661661
#measuresTable th, #measuresTable td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
662662
#measuresTable td:first-child { color: var(--accent); vertical-align: top; }
663663
#measuresTable summary { cursor: pointer; }
664664
#measuresTable summary::marker { color: var(--accent); }
665665
#measuresTable .measure-def { margin: 0.4rem 0 0 0; color: var(--fg); font-size: 0.85rem; max-width: 26rem; }
666+
#measuresTable .measure-frame-links { display: flex; flex-wrap: wrap; gap: 0.3rem; }
667+
#measuresTable .measure-frame-links button { font-size: 0.8rem; padding: 0.15rem 0.4rem; }
666668
#measuresTable tr.status-not-reached td, #measuresTable tr.status-undetermined td { color: #8a5a00; }
667669
#measuresTable .audited-badge {
668670
display: inline-block;
@@ -927,7 +929,7 @@ <h3>Measures</h3>
927929
<div id="measuresTableWrap" hidden>
928930
<table id="measuresTable">
929931
<thead>
930-
<tr><th>Measure</th><th>Value</th></tr>
932+
<tr><th>Measure</th><th>Value</th><th>Frame</th></tr>
931933
</thead>
932934
<tbody id="measuresTableBody"></tbody>
933935
</table>

‎app/js/app.js‎

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2920,36 +2920,59 @@ const MEASURE_DEFINITIONS = {
29202920
'Quadrant time - adjacent (-1)': '% of time in the quarter next to the target, the other direction around the ring.',
29212921
};
29222922

2923+
// Every measure links back to whatever frame(s) actually produced it (hard
2924+
// rule #6) - a single event (latency, hole deviation) gets one button; a
2925+
// count derived from several visits (errors, revisits) gets one button per
2926+
// contributing visit rather than one symbolic link, so nothing is silently
2927+
// collapsed. Trial-wide aggregates (path length, speed, quadrant time)
2928+
// aren't tied to one event, so they link the trial-start frame instead,
2929+
// labeled honestly as an anchor rather than "the" producing frame.
2930+
function frameLinksHtml(frames, label) {
2931+
const list = (Array.isArray(frames) ? frames : [frames]).filter(f => f !== null && f !== undefined);
2932+
if (list.length === 0) return '-';
2933+
return `<span class="measure-frame-links">${list.map(f =>
2934+
`<button type="button" class="goToFrameBtn" data-frame="${f}">${label ? label : `Go to frame ${f}`}</button>`
2935+
).join('')}</span>`;
2936+
}
2937+
29232938
// name="measure-detail" makes these a native accordion (Chrome 120+,
29242939
// Firefox 125+, already assumed by the WebCodecs requirement) - opening
29252940
// one closes whichever other definition was open, no JS needed.
2926-
function measureRow(label, valueHtml, statusClass, audited) {
2941+
function measureRow(label, valueHtml, statusClass, audited, frameHtml) {
29272942
const def = MEASURE_DEFINITIONS[label];
29282943
const labelHtml = def
29292944
? `<details name="measure-detail"><summary>${label}</summary><p class="measure-def">${def}</p></details>`
29302945
: label;
2931-
return `<tr class="${statusClass || ''}"><td>${labelHtml}</td><td>${valueHtml}${audited ? ' <span class="audited-badge">audited</span>' : ''}</td></tr>`;
2946+
return `<tr class="${statusClass || ''}"><td>${labelHtml}</td><td>${valueHtml}${audited ? ' <span class="audited-badge">audited</span>' : ''}</td><td>${frameHtml || '-'}</td></tr>`;
29322947
}
29332948

29342949
function renderMeasuresTable(m) {
2950+
const trialStartLink = frameLinksHtml(m.trialStartFrame, 'Go to trial start');
29352951
const rows = [
2936-
measureRow('Primary latency', formatLatency(m.primaryLatency), m.primaryLatency.status !== 'ok' ? 'status-not-reached' : '', m.primaryLatency.audited),
2937-
measureRow('Total latency', formatLatency(m.totalLatency), m.totalLatency.status !== 'ok' ? `status-${m.totalLatency.status}` : '', m.totalLatency.audited),
2938-
measureRow('Primary errors', m.primaryErrors.value, '', m.primaryErrors.audited),
2939-
measureRow('Total errors', m.totalErrors.value, '', m.totalErrors.audited),
2940-
measureRow('Working memory errors', m.workingMemoryErrors.value, '', m.workingMemoryErrors.audited),
2941-
measureRow('Perseverative errors', m.perseverativeErrors.value, '', m.perseverativeErrors.audited),
2942-
measureRow('Hole deviation score', m.holeDeviationScore.value !== null ? m.holeDeviationScore.value : '-', '', m.holeDeviationScore.audited),
2943-
measureRow('Commitment delay', formatCommitmentDelay(m.commitmentDelay), m.commitmentDelay.status !== 'ok' ? 'status-not-reached' : '', m.commitmentDelay.audited),
2944-
measureRow('Target re-visit count', m.targetRevisitCount.value !== null ? m.targetRevisitCount.value : '-', '', m.targetRevisitCount.audited),
2945-
measureRow('Path length', `${m.pathLengthCm.toFixed(1)} cm`),
2946-
measureRow('Average speed', m.avgSpeedCmS !== null ? `${m.avgSpeedCmS.toFixed(2)} cm/s` : '-'),
2947-
measureRow('Quadrant time - target', m.quadrantPct[0] !== null ? `${m.quadrantPct[0].toFixed(1)}%` : '-'),
2948-
measureRow('Quadrant time - adjacent (+1)', m.quadrantPct[1] !== null ? `${m.quadrantPct[1].toFixed(1)}%` : '-'),
2949-
measureRow('Quadrant time - opposite', m.quadrantPct[2] !== null ? `${m.quadrantPct[2].toFixed(1)}%` : '-'),
2950-
measureRow('Quadrant time - adjacent (-1)', m.quadrantPct[3] !== null ? `${m.quadrantPct[3].toFixed(1)}%` : '-'),
2952+
measureRow('Primary latency', formatLatency(m.primaryLatency), m.primaryLatency.status !== 'ok' ? 'status-not-reached' : '', m.primaryLatency.audited, frameLinksHtml(m.primaryLatency.frame)),
2953+
measureRow('Total latency', formatLatency(m.totalLatency), m.totalLatency.status !== 'ok' ? `status-${m.totalLatency.status}` : '', m.totalLatency.audited, frameLinksHtml(m.totalLatency.frame)),
2954+
measureRow('Primary errors', m.primaryErrors.value, '', m.primaryErrors.audited, frameLinksHtml(m.primaryErrors.frames)),
2955+
measureRow('Total errors', m.totalErrors.value, '', m.totalErrors.audited, frameLinksHtml(m.totalErrors.frames)),
2956+
measureRow('Working memory errors', m.workingMemoryErrors.value, '', m.workingMemoryErrors.audited, frameLinksHtml(m.workingMemoryErrors.frames)),
2957+
measureRow('Perseverative errors', m.perseverativeErrors.value, '', m.perseverativeErrors.audited, frameLinksHtml(m.perseverativeErrors.frames)),
2958+
measureRow('Hole deviation score', m.holeDeviationScore.value !== null ? m.holeDeviationScore.value : '-', '', m.holeDeviationScore.audited, frameLinksHtml(m.holeDeviationScore.frame)),
2959+
measureRow('Commitment delay', formatCommitmentDelay(m.commitmentDelay), m.commitmentDelay.status !== 'ok' ? 'status-not-reached' : '', m.commitmentDelay.audited, frameLinksHtml(m.commitmentDelay.frames)),
2960+
measureRow('Target re-visit count', m.targetRevisitCount.value !== null ? m.targetRevisitCount.value : '-', '', m.targetRevisitCount.audited, frameLinksHtml(m.targetRevisitCount.frames)),
2961+
measureRow('Path length', `${m.pathLengthCm.toFixed(1)} cm`, '', false, trialStartLink),
2962+
measureRow('Average speed', m.avgSpeedCmS !== null ? `${m.avgSpeedCmS.toFixed(2)} cm/s` : '-', '', false, trialStartLink),
2963+
measureRow('Quadrant time - target', m.quadrantPct[0] !== null ? `${m.quadrantPct[0].toFixed(1)}%` : '-', '', false, trialStartLink),
2964+
measureRow('Quadrant time - adjacent (+1)', m.quadrantPct[1] !== null ? `${m.quadrantPct[1].toFixed(1)}%` : '-', '', false, trialStartLink),
2965+
measureRow('Quadrant time - opposite', m.quadrantPct[2] !== null ? `${m.quadrantPct[2].toFixed(1)}%` : '-', '', false, trialStartLink),
2966+
measureRow('Quadrant time - adjacent (-1)', m.quadrantPct[3] !== null ? `${m.quadrantPct[3].toFixed(1)}%` : '-', '', false, trialStartLink),
29512967
];
29522968
measuresTableBody.innerHTML = rows.join('');
2969+
2970+
measuresTableBody.querySelectorAll('.goToFrameBtn').forEach((btn) => {
2971+
btn.addEventListener('click', async () => {
2972+
await showFrame(parseInt(btn.dataset.frame, 10));
2973+
canvas.scrollIntoView({ behavior: 'smooth', block: 'center' });
2974+
});
2975+
});
29532976
}
29542977

29552978
classifierBtn.addEventListener('click', runClassification);

‎app/js/measures.js‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,18 @@ function computeMeasures(trackResult, visits, confirmedROI, fps, eventOverrides)
141141
const nonTargetVisits = realVisits.filter(v => v.holeIndex !== targetHoleIndex).sort((a, b) => a.startFrame - b.startFrame);
142142

143143
const primaryLatency = targetVisits.length > 0
144-
? { status: 'ok', value: (targetVisits[0].startFrame - trialStartFrame) / fps, audited: anyOverrideBefore(targetVisits[0].startFrame + 1) }
145-
: { status: 'not-reached', value: null, audited: anyOverrideBefore(Infinity) };
144+
? { status: 'ok', value: (targetVisits[0].startFrame - trialStartFrame) / fps, audited: anyOverrideBefore(targetVisits[0].startFrame + 1), frame: targetVisits[0].startFrame }
145+
: { status: 'not-reached', value: null, audited: anyOverrideBefore(Infinity), frame: null };
146146
const primaryErrorFrame = targetVisits.length > 0 ? targetVisits[0].startFrame : Infinity;
147-
const primaryErrors = { value: nonTargetVisits.filter(v => v.startFrame < primaryErrorFrame).length, audited: anyOverrideBefore(primaryErrorFrame) };
147+
const primaryErrorVisits = nonTargetVisits.filter(v => v.startFrame < primaryErrorFrame);
148+
const primaryErrors = { value: primaryErrorVisits.length, audited: anyOverrideBefore(primaryErrorFrame), frames: primaryErrorVisits.map(v => v.startFrame) };
148149

149150
const totalLatencyResolved = resolveTotalLatency(classified, targetHoleIndex);
150151
const totalLatency = {
151152
status: totalLatencyResolved.status,
152153
value: totalLatencyResolved.status === 'ok' ? (totalLatencyResolved.frame - trialStartFrame) / fps : null,
153154
audited: totalLatencyResolved.audited,
155+
frame: totalLatencyResolved.frame,
154156
};
155157
// No defined total-latency frame (not reached, or undetermined) ->
156158
// whole-trial fallback, same reasoning as primary's never-reached case.
@@ -161,33 +163,34 @@ function computeMeasures(trackResult, visits, confirmedROI, fps, eventOverrides)
161163
// overridden (e.g. excluded as thigmotaxis, which changes the count
162164
// without moving the boundary).
163165
const totalAudited = totalLatencyResolved.audited || anyOverrideBefore(totalErrorFrame);
164-
const totalErrors = { value: totalErrorVisits.length, audited: totalAudited };
166+
const totalErrors = { value: totalErrorVisits.length, audited: totalAudited, frames: totalErrorVisits.map(v => v.startFrame) };
165167

166168
// Working memory error (Gawel et al. 2019, Table 1, citing O'Leary &
167169
// Brown): re-checking a wrong hole already visited this trial, ANY
168170
// number of other holes in between allowed - this is what was
169171
// originally built here under the more confusing name "perseverative."
170172
const visitCountByHole = new Map();
173+
const workingMemoryFrames = [];
171174
for (const v of totalErrorVisits) {
172-
visitCountByHole.set(v.holeIndex, (visitCountByHole.get(v.holeIndex) || 0) + 1);
175+
const seen = (visitCountByHole.get(v.holeIndex) || 0) + 1;
176+
visitCountByHole.set(v.holeIndex, seen);
177+
if (seen > 1) workingMemoryFrames.push(v.startFrame); // the re-check itself, not the original visit
173178
}
174-
let workingMemoryCount = 0;
175-
for (const count of visitCountByHole.values()) workingMemoryCount += Math.max(0, count - 1);
176-
const workingMemoryErrors = { value: workingMemoryCount, audited: totalAudited };
179+
const workingMemoryErrors = { value: workingMemoryFrames.length, audited: totalAudited, frames: workingMemoryFrames };
177180

178181
// Perseverative error, strict definition (same source): re-checking
179182
// the SAME wrong hole with NO other hole - target included - visited
180183
// in between. A back-to-back fixation, not just "forgot eventually."
181184
// Needs the full ordered sequence (target visits count as "another
182185
// hole" for breaking the streak), not just non-target visits.
183186
const fullSequence = realVisits.filter(v => v.startFrame < totalErrorFrame).sort((a, b) => a.startFrame - b.startFrame);
184-
let strictPerseverativeCount = 0;
187+
const perseverativeFrames = [];
185188
for (let i = 1; i < fullSequence.length; i++) {
186189
if (fullSequence[i].holeIndex === fullSequence[i - 1].holeIndex && fullSequence[i].holeIndex !== targetHoleIndex) {
187-
strictPerseverativeCount++;
190+
perseverativeFrames.push(fullSequence[i].startFrame); // the repeat check itself
188191
}
189192
}
190-
const perseverativeErrors = { value: strictPerseverativeCount, audited: totalAudited };
193+
const perseverativeErrors = { value: perseverativeFrames.length, audited: totalAudited, frames: perseverativeFrames };
191194

192195
// Hole deviation score (Gawel et al. 2019, Table 1): circular distance
193196
// from the target to whichever hole was checked FIRST in the trial -
@@ -207,8 +210,8 @@ function computeMeasures(trackResult, visits, confirmedROI, fps, eventOverrides)
207210
// independent correctness audit, see
208211
// audits/2026-09-04-correctness-audit.md finding #5.
209212
const holeDeviationScore = firstVisitOverall
210-
? { value: circularHoleDistance(firstVisitOverall.holeIndex, targetHoleIndex, holes.length), audited: anyOverrideBefore(firstVisitOverall.startFrame + 1) }
211-
: { value: null, audited: false };
213+
? { value: circularHoleDistance(firstVisitOverall.holeIndex, targetHoleIndex, holes.length), audited: anyOverrideBefore(firstVisitOverall.startFrame + 1), frame: firstVisitOverall.startFrame }
214+
: { value: null, audited: false, frame: null };
212215

213216
// Post-arrival "commitment" - her own observation from test53's real
214217
// event trace (early pokes at the target hole, then a later one that
@@ -225,21 +228,22 @@ function computeMeasures(trackResult, visits, confirmedROI, fps, eventOverrides)
225228
// AND entered) - not-applicable otherwise, same "say so honestly"
226229
// posture as every other status here, rather than a misleading 0/null.
227230
const commitmentDelay = (primaryLatency.status === 'ok' && totalLatency.status === 'ok')
228-
? { status: 'ok', value: totalLatency.value - primaryLatency.value, audited: primaryLatency.audited || totalLatency.audited }
229-
: { status: 'not-applicable', value: null, audited: primaryLatency.audited || totalLatency.audited };
231+
? { status: 'ok', value: totalLatency.value - primaryLatency.value, audited: primaryLatency.audited || totalLatency.audited, frames: [primaryLatency.frame, totalLatency.frame] }
232+
: { status: 'not-applicable', value: null, audited: primaryLatency.audited || totalLatency.audited, frames: [] };
230233

231234
// How many separate visits to the target hole happened before the one
232235
// that finally resolved to entry - reuses totalErrorFrame (already the
233236
// entry visit's own frame, or Infinity if never entered) so this can
234237
// never disagree with total latency/errors about which window counts.
238+
const targetRevisitFrames = targetVisits.filter(v => v.startFrame < totalErrorFrame).map(v => v.startFrame);
235239
const targetRevisitCount = totalLatencyResolved.status === 'ok'
236-
? { value: targetVisits.filter(v => v.startFrame < totalErrorFrame).length, audited: totalAudited }
237-
: { value: null, audited: totalAudited };
240+
? { value: targetRevisitFrames.length, audited: totalAudited, frames: targetRevisitFrames }
241+
: { value: null, audited: totalAudited, frames: [] };
238242

239243
return {
240244
available: true,
241245
primaryLatency, totalLatency, primaryErrors, totalErrors, workingMemoryErrors, perseverativeErrors, holeDeviationScore,
242246
commitmentDelay, targetRevisitCount,
243-
pathLengthCm, avgSpeedCmS, quadrantPct,
247+
pathLengthCm, avgSpeedCmS, quadrantPct, trialStartFrame,
244248
};
245249
}

‎tests/measures.test.js‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,41 @@ test('computeMeasures does not dilute average speed with time after the mouse st
182182
assert.ok(Math.abs(measures.avgSpeedCmS - 45) < 1e-9, `expected ~45 cm/s, got ${measures.avgSpeedCmS}`);
183183
});
184184

185+
// Hard rule #6: every reported number links back to the frame that
186+
// produced it. Latency/hole-deviation are single-event derived, so they
187+
// carry one frame; error/revisit counts are derived from several visits,
188+
// so they carry the frames array of every contributing visit rather than
189+
// one symbolic anchor.
190+
test('measures carry the frame(s) that actually produced each number', () => {
191+
const measures = commitmentFixture([
192+
{ holeIndex: 1, startFrame: 2, endFrame: 3, events: [{ holeIndex: 1, startFrame: 2, endFrame: 3 }], type: 'poke' }, // error before primary latency window closes
193+
{ holeIndex: 2, startFrame: 5, endFrame: 6, events: [{ holeIndex: 2, startFrame: 5, endFrame: 6 }], type: 'poke' }, // primary latency frame
194+
{ holeIndex: 0, startFrame: 10, endFrame: 11, events: [{ holeIndex: 0, startFrame: 10, endFrame: 11 }], type: 'poke' }, // total-only error
195+
{ holeIndex: 0, startFrame: 15, endFrame: 16, events: [{ holeIndex: 0, startFrame: 15, endFrame: 16 }], type: 'poke' }, // working-memory re-check of hole 0
196+
{ holeIndex: 2, startFrame: 20, endFrame: 21, events: [{ holeIndex: 2, startFrame: 20, endFrame: 21 }], type: 'entry' }, // total latency frame
197+
]);
198+
assert.equal(measures.primaryLatency.frame, 5);
199+
assert.equal(measures.totalLatency.frame, 20);
200+
assert.deepEqual(toPlain(measures.primaryErrors.frames), [2]); // only the wrong-hole check before the target was first reached
201+
assert.deepEqual(toPlain(measures.totalErrors.frames), [2, 10, 15]);
202+
assert.deepEqual(toPlain(measures.workingMemoryErrors.frames), [15]); // only the re-check, not hole 0's first visit
203+
assert.equal(measures.holeDeviationScore.frame, 2); // the very first visit overall, not the first target visit
204+
assert.deepEqual(toPlain(measures.commitmentDelay.frames), [5, 20]); // both endpoints of the delta
205+
assert.deepEqual(toPlain(measures.targetRevisitCount.frames), [5]); // the earlier target visit before entry
206+
assert.equal(measures.trialStartFrame, 0);
207+
});
208+
209+
test('frame fields are null/empty (not missing) when a measure has nothing to link to', () => {
210+
const measures = commitmentFixture([]); // nothing happened this trial
211+
assert.equal(measures.primaryLatency.frame, null);
212+
assert.equal(measures.totalLatency.frame, null);
213+
assert.deepEqual(toPlain(measures.primaryErrors.frames), []);
214+
assert.deepEqual(toPlain(measures.totalErrors.frames), []);
215+
assert.equal(measures.holeDeviationScore.frame, null);
216+
assert.deepEqual(toPlain(measures.commitmentDelay.frames), []);
217+
assert.deepEqual(toPlain(measures.targetRevisitCount.frames), []);
218+
});
219+
185220
test('quadrantIndex centers quadrant 0 on the target angle, straddling the wraparound boundary', () => {
186221
assert.equal(quadrantIndex(0, 0), 0);
187222
assert.equal(quadrantIndex(44, 0), 0);

0 commit comments

Comments
 (0)