diff --git a/README.md b/README.md index 4b5b9bf..72911ed 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Sports Academy. It helps a guide or coach decide **who to see first, why, and wh adaptive academic software carries practice and adults carry motivation and intervention. The portfolio demo is deliberately narrow: sign in, upload one fixed CSV, validate and commit it, -refresh, inspect the ranked Evidence Desk, open exact evidence, and see the report acknowledged. It is -not a diagnosis product and makes **no accuracy claim**. +refresh, inspect the ranked Evidence Desk, review one teacher-friendly evidence list, and see the +report acknowledged. It is not a diagnosis product and makes **no accuracy claim**. ## Why this is useful at TSA @@ -141,8 +141,10 @@ walkthrough below; the smoke command does not pretend to replace it. 3. **0:40–0:55 — Commit and refresh.** Commit the same bytes, then choose **Refresh board now**. The deterministic engine publishes one immutable ranked run; no model key is needed. 4. **0:55–1:30 — Use the Evidence Desk.** Open the top report. Read the cause-specific opener, compare - it with the student's own baseline, and expand exact attempts/sessions. Severity determines rank; - evidence confidence is shown separately. + it with the student's own baseline, and scan the single deduplicated evidence list. Attempt + outcomes, selected answers, and timing-aware work/session durations remain readable in seconds + without exposing internal record IDs. Severity determines rank; evidence confidence is shown + separately. 5. **1:30–1:45 — Show trust behavior.** Point to “Deterministic fallback · degraded” and the visible “Seen” acknowledgment. Refresh/back navigation keeps the report and seen state. 6. **1:45–2:00 — Show engineering evidence.** Run `npm run eval:portfolio`: eight fixed cases, hard-fail diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx index 6e673f1..79239ff 100644 --- a/apps/web/app/board/evidence-desk.tsx +++ b/apps/web/app/board/evidence-desk.tsx @@ -1,10 +1,12 @@ -import type { - AdditionalEvidenceView, - BoardEntryView, - EvidenceView, - RefreshView, -} from '@huddle/application'; +import type { BoardEntryView, EvidenceView, RefreshView } from '@huddle/application'; import type { EvidenceDeskState } from './lib/evidence-desk-state'; +import { + confidenceLabel, + humanizeCause, + priorityBand, + teacherReportView, + type TeacherReportView, +} from './lib/teacher-report-view'; import { evidenceDeskStateFocusId, RailLink, @@ -17,35 +19,6 @@ import { type VisibleOpenAuthorizationAction, } from './visible-open-acknowledgment'; -const causeLabels: Record = { - guessing: 'Guessing pattern', - prerequisite_gap: 'Prerequisite gap', - grinding: 'Sustained effort signal', - hint_farming: 'Hint reliance', - no_read_retry: 'Retry pattern', - decay: 'Recent decay', - disengagement: 'Engagement change', - fine: 'No action needed', -}; - -export function humanizeCause(cause: BoardEntryView['cause']): string { - return causeLabels[cause]; -} - -export function priorityBand( - severity: number -): 'Urgent priority' | 'Elevated priority' | 'Watch priority' { - if (severity >= 0.75) return 'Urgent priority'; - if (severity >= 0.5) return 'Elevated priority'; - return 'Watch priority'; -} - -export function confidenceLabel(confidence: number): 'High' | 'Medium' | 'Low' { - if (confidence >= 0.8) return 'High'; - if (confidence >= 0.55) return 'Medium'; - return 'Low'; -} - function priorityIcon(severity: number): string { if (severity >= 0.75) return '◆'; if (severity >= 0.5) return '▲'; @@ -56,18 +29,11 @@ function formatMetric(value: number | null): string { return value == null ? 'Not available' : String(value); } -function exactTiming(value: number | null): string { - return value == null ? 'Not recorded' : `${value} ms`; -} - function readableTiming(value: number | null): string { if (value == null) return 'Not recorded'; - if (value < 1_000) return `${value} ms`; const seconds = value / 1_000; - if (seconds < 60) return `${Number(seconds.toFixed(3))} sec`; - const minutes = Math.floor(seconds / 60); - const remainder = Number((seconds % 60).toFixed(3)); - return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; + const precision = seconds < 1 ? 3 : seconds < 10 ? 1 : 0; + return `${Number(seconds.toFixed(precision))} sec`; } function formatTimestamp(value: string): string { @@ -83,16 +49,6 @@ function formatTimestamp(value: string): string { }).format(date); } -function scopeLabel(scope: BoardEntryView['scope']): string { - return scope.kind === 'skill' - ? `${scope.skill.code} · ${scope.skill.name}` - : 'Cross-skill evidence'; -} - -function scopeShortLabel(scope: BoardEntryView['scope']): string { - return scope.kind === 'skill' ? scope.skill.code : 'Across skills'; -} - export function refreshDescription(refresh: RefreshView, hasCommittedBoard: boolean): string { switch (refresh.state) { case 'idle': @@ -108,15 +64,22 @@ export function refreshDescription(refresh: RefreshView, hasCommittedBoard: bool } } -function StatusChip({ entry }: { entry: BoardEntryView }) { +export function StatusCue({ + entry, + className = 'report-status-line', +}: { + entry: BoardEntryView; + className?: string; +}) { const band = priorityBand(entry.severity); + const confidence = `${confidenceLabel(entry.finalConfidence)} confidence`; return ( -
+
{band} #{entry.rank} today - {confidenceLabel(entry.finalConfidence)} confidence + {confidence}
); } @@ -166,283 +129,45 @@ function Rail({

    - {board.entries.map((entry) => { - const band = priorityBand(entry.severity).replace(' priority', ''); - return ( -
  1. - - - {entry.rank} + {board.entries.map((entry) => ( +
  2. + + +
    + {entry.student.firstName} + + {humanizeCause(entry.cause)} ·{' '} + {entry.scope.kind === 'skill' ? entry.scope.skill.code : 'Across skills'} - - {entry.student.firstName} - - {humanizeCause(entry.cause)} · {scopeShortLabel(entry.scope)} - + + {entry.additionalCauseCount > 0 ? ( - {band} · {confidenceLabel(entry.finalConfidence)} confidence - {entry.additionalCauseCount > 0 - ? ` · ${entry.additionalCauseCount} more ${entry.additionalCauseCount === 1 ? 'cause' : 'causes'}` - : ''} - - - {entry.acknowledgedAt ? '✓ Seen' : 'Not yet seen'} + {entry.additionalCauseCount} more{' '} + {entry.additionalCauseCount === 1 ? 'cause' : 'causes'} - - - -
  3. - ); - })} + ) : null} + + {entry.acknowledgedAt ? '✓ Seen' : 'Not yet seen'} + + + + + + ))}
); } -type EvidenceDisclosure = Pick< - AdditionalEvidenceView, - | 'signalId' - | 'cause' - | 'scope' - | 'summary' - | 'computed' - | 'derived' - | 'prerequisiteCheck' - | 'conflicts' - | 'attempts' - | 'sessions' ->; - -function AttemptTable({ attempts }: { attempts: EvidenceDisclosure['attempts'] }) { - return ( -
- - - - - - - - - - - {attempts.map((attempt) => ( - - - - - - - ))} - -
WhenEvidenceResultTiming
{attempt.submittedAt} - {attempt.skill.code} · {attempt.skill.name} - - Attempt ID {attempt.attemptId ?? 'null'} · Activity {attempt.activityId} · Ordinal{' '} - {attempt.ordinal} - - - {attempt.itemType} · {attempt.timingProfile} - - - {attempt.isCorrect ? 'Correct' : 'Incorrect'} - Chosen label: {attempt.chosenLabel ?? 'null'} - Misconception: {attempt.misconception ?? 'null'} - Hints used: {attempt.hintsUsed} - - Wall-clock: {exactTiming(attempt.elapsedMs)} - Engaged: {exactTiming(attempt.engagedMs)} - Quality: {attempt.timingQuality} -
-
- ); -} - -function SessionTable({ sessions }: { sessions: EvidenceDisclosure['sessions'] }) { - if (!sessions.length) return null; - return ( -
- - - - - - - - - - - {sessions.map((session) => ( - - - - - - - ))} - -
Session IDWindowTotal elapsedSource quality
{session.sessionId} - {session.startedAt} - to {session.endedAt} - {exactTiming(session.totalElapsedMs)} - {session.vendorAttemptCount ?? 'No source attempt count'} - {session.timingQuality} -
-
- ); -} - -function RuleAndQuality({ evidence }: { evidence: EvidenceDisclosure }) { - const { summary, computed, derived } = evidence; - return ( -
- Rule and quality details -
-
-
-
Signal ID
-
{evidence.signalId}
-
-
-
Cause
-
{humanizeCause(evidence.cause)}
-
-
-
Scope
-
{scopeLabel(evidence.scope)}
-
-
-
Rule
-
- {summary.ruleId} · {summary.ruleVersion} -
-
-
-
Severity
-
{summary.severity}
-
-
-
Confidence
-
- {summary.rawConfidence} raw · {summary.finalConfidence} final -
-
-
-
Confidence multipliers
-
- timing {summary.confidenceBreakdown.timingMultiplier} · winsorization{' '} - {summary.confidenceBreakdown.winsorizationMultiplier} · conflict{' '} - {summary.confidenceBreakdown.conflictMultiplier} -
-
-
-
Attempts
-
- {computed.wrongCount} wrong of {computed.attemptCount} · {computed.winsorizedOutCount}{' '} - outside timing bound -
-
-
-
Durations
-
- wrong median {exactTiming(computed.medianWrongDurationMs)} · personal correct{' '} - {exactTiming(computed.personalCorrectBaselineMs)} · session mean{' '} - {exactTiming(computed.personalSessionMeanBaselineMs)} -
-
-
-
Pattern values
-
- distractor {formatMetric(computed.distractorConcentration)} · speed{' '} - {formatMetric(derived.speedRatio)} · consecutive wrong{' '} - {formatMetric(derived.consecutiveWrong)} · days observed{' '} - {formatMetric(derived.daysSinceFirstAttempt)} -
-
-
-
Wrong of last N
-
- {derived.wrongOfLastN - ? `${derived.wrongOfLastN.wrong} of ${derived.wrongOfLastN.of}` - : 'Not available'} -
-
-
-
-
- ); -} - -export function EvidenceContext({ evidence }: { evidence: EvidenceDisclosure }) { - const prerequisite = evidence.prerequisiteCheck; - const conflicts = evidence.conflicts - .map( - (conflict) => - `${conflict.family} · ${humanizeCause(conflict.suggestedCause)} · ${conflict.ruleId}` - ) - .join('; '); - return ( -
-
-
Prerequisite check
-
- {prerequisite - ? `${prerequisite.skillCode} · ${prerequisite.skillName} · mastery ${formatMetric(prerequisite.masteryValue)} · known ${String(prerequisite.isKnown)} · ${prerequisite.verdict}` - : 'Not applicable'} -
-
-
-
Conflict adjustments
-
{conflicts || 'No conflict adjustments.'}
-
-
- ); -} - -export function EvidenceDetails({ - label, - evidence, -}: { - label: string; - evidence: EvidenceDisclosure; -}) { - return ( -
- {label} -
- -

Exact contributing attempts

- - {evidence.sessions.length ?

Exact contributing sessions

: null} - -
-
- ); -} - -export function EvidenceRecordDisclosure({ - heading, - evidence, -}: { - heading?: string; - evidence: EvidenceDisclosure; -}) { - return ( -
- {heading ?

{heading}

: null} - - -
- ); -} - export function narrationProvenance(entry: BoardEntryView): string { const mode = entry.narration.mode === 'deterministic-fallback' @@ -486,9 +211,10 @@ export function NarrationTrust({ entry }: { entry: BoardEntryView }) { export function ReportHeading({ entry }: { entry: BoardEntryView }) { return (
- +

- {entry.student.firstName} · {scopeShortLabel(entry.scope)} + {entry.student.firstName} ·{' '} + {entry.scope.kind === 'skill' ? entry.scope.skill.code : 'Across skills'}

{humanizeCause(entry.cause)} ·{' '} @@ -498,6 +224,142 @@ export function ReportHeading({ entry }: { entry: BoardEntryView }) { ); } +export function TeacherEvidenceList({ + studentName, + report, +}: { + studentName: string; + report: TeacherReportView; +}) { + return ( +

+

Evidence from {studentName}’s work

+

+ Each attempt and work session appears once, even when it supports more than one pattern. +

+
    + {report.evidence.map((attempt) => ( +
  1. +
    + + {attempt.timing.kind === 'active' ? ( + <> + Active work: {readableTiming(attempt.timing.engagedMs)} + {attempt.timing.elapsedMs == null ? null : ( + Elapsed: {readableTiming(attempt.timing.elapsedMs)} + )} + + ) : attempt.timing.kind === 'elapsed' ? ( + Completed in {readableTiming(attempt.timing.elapsedMs)} + ) : ( + Duration not recorded + )} +
    +
    + {attempt.skill} +
    +
    +
    Result
    +
    + {attempt.result} +
    +
    +
    +
    Answer selected
    +
    {attempt.chosenAnswer}
    +
    + {attempt.misconception ? ( +
    +
    Useful misconception
    +
    {attempt.misconception}
    +
    + ) : null} + {attempt.hintsUsed > 0 ? ( +
    +
    Hints
    +
    {attempt.hintsUsed} used
    +
    + ) : null} +
    +
    +
  2. + ))} + {report.sessions.map((session) => ( +
  3. +
    + + + Through + + {readableTiming(session.durationMs)} total +
    +
    + Work session +
    +
    +
    Activities recorded
    +
    {session.attemptCount}
    +
    +
    +
    +
  4. + ))} +
+
+ ); +} + +export function ReportContext({ + evidence, + report, +}: { + evidence: EvidenceView; + report: TeacherReportView; +}) { + const comparison = evidence.comparison; + return ( +
+

Context for this report

+
+
+
Usual correct pace
+
{readableTiming(comparison.computed.personalCorrectBaselineMs)}
+
+
+
Recent attempt pace
+
{readableTiming(comparison.computed.medianWrongDurationMs)}
+
+
+
Consecutive incorrect responses
+
{formatMetric(comparison.derived.consecutiveWrong)}
+
+
+ {report.prerequisite ? ( +
+

Prerequisite checked

+

{report.prerequisite}

+
+ ) : null} + {report.additionalContexts.length ? ( +
+

Also considered

+
    + {report.additionalContexts.map((context) => ( +
  • + {context.cause} + {context.priority} + {context.confidence} +
  • + ))} +
+
+ ) : null} +
+ ); +} + function Workspace({ evidence, grant, @@ -514,20 +376,8 @@ function Workspace({ action?: VisibleAcknowledgmentAction; }) { const { entry } = evidence; - const comparison = evidence.comparison; - const dominantEvidence: EvidenceDisclosure = { - signalId: evidence.signalId, - cause: entry.cause, - scope: entry.scope, - summary: evidence.summary, - computed: comparison.computed, - derived: comparison.derived, - prerequisiteCheck: comparison.prerequisiteCheck, - conflicts: comparison.conflicts, - attempts: evidence.exact.attempts, - sessions: evidence.exact.sessions, - }; - const report = ( + const report = teacherReportView(evidence); + const content = (
@@ -541,36 +391,8 @@ function Workspace({ -
-

Compared with {entry.student.firstName}’s own pattern

-
-
-
Personal correct baseline
-
{readableTiming(comparison.computed.personalCorrectBaselineMs)}
-
-
-
Recent wrong duration
-
{readableTiming(comparison.computed.medianWrongDurationMs)}
-
-
-
Consecutive wrong
-
{formatMetric(comparison.derived.consecutiveWrong)}
-
-
-
- - {comparison.additionalEvidence.length ? ( -
-

Additional evidence

- {comparison.additionalEvidence.map((additional) => ( - - ))} -
- ) : null} + +
); return ( @@ -581,7 +403,7 @@ function Workspace({ authorizationAction={authorizationAction} action={action} > - {report} + {content} ); } @@ -675,8 +497,8 @@ export function EvidenceDesk({ ◆ Start here

Choose a student

- Review why they were ranked, compare with their own pattern, and open exact - evidence without losing the queue. + Review why they were ranked, compare with their own pattern, and open evidence + without losing the queue.

)} diff --git a/apps/web/app/board/lib/teacher-report-view.ts b/apps/web/app/board/lib/teacher-report-view.ts new file mode 100644 index 0000000..9583ac5 --- /dev/null +++ b/apps/web/app/board/lib/teacher-report-view.ts @@ -0,0 +1,144 @@ +import type { EvidenceBundle } from '@huddle/core'; +import type { BoardEntryView, EvidenceView } from '@huddle/application'; + +const causeLabels: Record = { + guessing: 'Guessing pattern', + prerequisite_gap: 'Prerequisite gap', + grinding: 'Sustained effort signal', + hint_farming: 'Hint reliance', + no_read_retry: 'Retry pattern', + decay: 'Recent decay', + disengagement: 'Engagement change', + fine: 'No action needed', +}; + +export function humanizeCause(cause: BoardEntryView['cause']): string { + return causeLabels[cause]; +} + +export function priorityBand( + severity: number +): 'Urgent priority' | 'Elevated priority' | 'Watch priority' { + if (severity >= 0.75) return 'Urgent priority'; + if (severity >= 0.5) return 'Elevated priority'; + return 'Watch priority'; +} + +export function confidenceLabel(confidence: number): 'High' | 'Medium' | 'Low' { + if (confidence >= 0.8) return 'High'; + if (confidence >= 0.55) return 'Medium'; + return 'Low'; +} + +export interface TeacherEvidenceItem { + key: string; + submittedAt: string; + skill: string; + result: 'Correct' | 'Incorrect'; + chosenAnswer: string; + misconception: string | null; + hintsUsed: number; + timing: + | { kind: 'active'; engagedMs: number; elapsedMs: number | null } + | { kind: 'elapsed'; elapsedMs: number } + | { kind: 'unavailable' }; +} + +export interface TeacherSessionItem { + key: string; + startedAt: string; + endedAt: string; + durationMs: number; + attemptCount: number; +} + +export interface TeacherReportView { + evidence: TeacherEvidenceItem[]; + sessions: TeacherSessionItem[]; + prerequisite: string | null; + additionalContexts: Array<{ + cause: string; + priority: ReturnType; + confidence: string; + }>; +} + +/** + * The report can contain the same activity through several deterministic signals. + * Combine it once at the teacher-facing boundary; internal activity identifiers remain only keys. + */ +export function teacherReportView(evidence: EvidenceView): TeacherReportView { + const attempts = new Map(); + const sessions = new Map(); + const additionalContexts = new Map(); + const attemptSources = [ + evidence.exact.attempts, + ...evidence.comparison.additionalEvidence.map((additional) => additional.attempts), + ]; + const sessionSources = [ + evidence.exact.sessions, + ...evidence.comparison.additionalEvidence.map((additional) => additional.sessions), + ]; + + for (const source of attemptSources) { + for (const attempt of source) attempts.set(attempt.activityId, attempt); + } + + for (const source of sessionSources) { + for (const session of source) { + if ( + session.timingQuality === 'session_only' && + session.totalElapsedMs != null && + session.vendorAttemptCount != null + ) { + sessions.set(session.sessionId, session); + } + } + } + + for (const additional of evidence.comparison.additionalEvidence) { + const cause = humanizeCause(additional.cause); + if (!additionalContexts.has(cause)) { + additionalContexts.set(cause, { + cause, + priority: priorityBand(additional.summary.severity), + confidence: `${confidenceLabel(additional.summary.finalConfidence)} confidence`, + }); + } + } + + const prerequisite = evidence.comparison.prerequisiteCheck; + return { + evidence: Array.from(attempts.entries(), ([key, attempt]) => ({ + key, + submittedAt: attempt.submittedAt, + skill: + attempt.skill.name === attempt.skill.code + ? attempt.skill.code + : `${attempt.skill.code} · ${attempt.skill.name}`, + result: attempt.isCorrect ? 'Correct' : 'Incorrect', + chosenAnswer: attempt.chosenLabel ?? 'No answer recorded', + misconception: attempt.misconception, + hintsUsed: attempt.hintsUsed, + timing: + attempt.timingQuality === 'engaged' && attempt.engagedMs != null + ? { kind: 'active', engagedMs: attempt.engagedMs, elapsedMs: attempt.elapsedMs } + : attempt.timingQuality === 'wallclock' && attempt.elapsedMs != null + ? { kind: 'elapsed', elapsedMs: attempt.elapsedMs } + : { kind: 'unavailable' }, + })), + sessions: Array.from(sessions.entries(), ([key, session]) => ({ + key: String(key), + startedAt: session.startedAt, + endedAt: session.endedAt, + durationMs: session.totalElapsedMs!, + attemptCount: session.vendorAttemptCount!, + })), + prerequisite: prerequisite + ? prerequisite.masteryValue == null + ? `${prerequisite.skillCode} · ${prerequisite.skillName} — mastery not available` + : `${prerequisite.skillCode} · ${prerequisite.skillName} — ${Math.round(prerequisite.masteryValue * 100)}% mastery (${prerequisite.verdict})` + : null, + additionalContexts: Array.from(additionalContexts.values()), + }; +} diff --git a/apps/web/app/board/lib/triage.ts b/apps/web/app/board/lib/triage.ts index 3f110c7..1c7f048 100644 --- a/apps/web/app/board/lib/triage.ts +++ b/apps/web/app/board/lib/triage.ts @@ -191,6 +191,7 @@ export async function getSyntheticFixtureBoardEntriesForTest(): Promise span, @@ -542,6 +541,19 @@ button:hover { font-weight: 750; } +.rail-status-line { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 3px; +} + +.rail-status-line .priority, +.rail-status-line .confidence, +.rail-status-line .rank-label { + font-size: 0.69rem; +} + .rail-chevron { align-self: center; color: var(--huddle-muted); @@ -641,14 +653,15 @@ button:hover { } .finding-intro, -.comparison-section { +.report-context, +.teacher-evidence { padding: 20px 0; border-top: 1px solid var(--huddle-line); } .finding-intro h3, -.comparison-section h3, -.additional-evidence h3 { +.report-context h3, +.teacher-evidence h3 { margin: 0 0 10px; font-size: 1.05rem; } @@ -727,108 +740,113 @@ blockquote { font-weight: 800; } -.evidence-disclosure { - border-top: 1px solid var(--huddle-line); -} - -.evidence-disclosure > summary, -.rule-disclosure > summary { - padding: 16px 0; - cursor: pointer; - font-weight: 780; +.prerequisite-context, +.additional-context { + margin-top: 18px; } -.disclosure-body { - padding-bottom: 18px; -} - -.disclosure-body h4 { - margin: 18px 0 8px; +.prerequisite-context h4, +.additional-context h4 { + margin: 0 0 8px; font-size: 0.9rem; } -.disclosure-body h4:first-child { - margin-top: 0; +.prerequisite-context p { + margin: 0; + color: var(--huddle-muted); } -.table-scroll { - width: 100%; - overflow-x: auto; +.additional-context ul { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; } -.exact-table { - width: 100%; - min-width: 680px; - border-collapse: collapse; - font-size: 0.79rem; +.additional-context li { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + align-items: baseline; + padding: 6px 8px; + border: 1px solid var(--huddle-line); + border-radius: 8px; + background: var(--huddle-surface); + font-size: 0.78rem; } -.exact-table th, -.exact-table td { - padding: 9px 7px; - border-bottom: 1px solid var(--huddle-line); - text-align: left; - vertical-align: top; +.additional-context span { + color: var(--huddle-muted); } -.exact-table th { +.teacher-evidence > p { + margin: -2px 0 12px; color: var(--huddle-muted); + font-size: 0.86rem; } -.exact-table small { - display: block; - margin-top: 3px; - color: var(--huddle-muted); +.teacher-evidence-list { + margin: 0; + padding: 0; + list-style: none; } -.rule-disclosure { - margin-top: 14px; +.teacher-evidence-item { + display: grid; + grid-template-columns: minmax(132px, 0.35fr) minmax(0, 1fr); + gap: 14px; + padding: 14px 0; border-top: 1px solid var(--huddle-line); } -.rule-facts { +.teacher-evidence-when { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 20px; - margin: 0; + align-content: start; + gap: 3px; + color: var(--huddle-muted); + font-size: 0.8rem; } -.rule-facts div { - padding: 10px 0; - border-bottom: 1px solid var(--huddle-line); +.teacher-evidence-when time { + color: var(--huddle-ink); + font-weight: 700; } -.rule-facts dd { - margin: 3px 0 0; - overflow-wrap: anywhere; +.teacher-evidence-content > strong { + display: block; } -.rule-body h5 { - margin: 18px 0 6px; - font-size: 0.82rem; +.teacher-evidence-content dl { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + margin: 6px 0 0; } -.facts { - margin: 0; - padding-left: 18px; - color: var(--huddle-muted); +.teacher-evidence-content dl > div { + display: flex; + gap: 4px; + font-size: 0.82rem; } -.additional-evidence { - padding-top: 20px; +.teacher-evidence-content dt { + color: var(--huddle-muted); } -.evidence-context { - margin-top: 12px; +.teacher-evidence-content dd { + margin: 0; } -.evidence-record + .evidence-record { - margin-top: 20px; +.result-correct { + color: var(--huddle-green); + font-weight: 750; } -.evidence-record > h4 { - margin: 0; - font-size: 0.9rem; +.result-incorrect { + color: #942532; + font-weight: 750; } .acknowledgment { @@ -958,11 +976,20 @@ blockquote { padding: 18px 14px; } - .metrics, - .rule-facts { + .metrics { grid-template-columns: 1fr; } + .teacher-evidence-item { + grid-template-columns: 1fr; + gap: 6px; + } + + .teacher-evidence-content dl { + display: grid; + gap: 4px; + } + .provenance { grid-template-columns: auto minmax(0, 1fr); } diff --git a/apps/web/lib/operations.ts b/apps/web/lib/operations.ts index 29f7c69..6786861 100644 --- a/apps/web/lib/operations.ts +++ b/apps/web/lib/operations.ts @@ -10,7 +10,7 @@ import { type Importer, type SyntheticImportFile, } from '@huddle/application'; -import type { Attempt, EvidenceBundle } from '@huddle/core'; +import type { Attempt, EvidenceBundle, LearningSession } from '@huddle/core'; import { items, skillPrereqs, skills } from '@huddle/core/seed'; import { deterministicFallback } from '@huddle/narrator/catalog.js'; import { allRules, compareSignals, runEngine } from '@huddle/signal-engine'; @@ -67,7 +67,6 @@ async function compileExactSnapshot( submittedAt: new Date(row.submitted_at), elapsedMs: row.elapsed_ms, engagedMs: row.engaged_ms, - sessionTotalMs: row.total_elapsed_ms, timingQuality: row.timing_quality, timingWasWinsorized: row.timing_was_winsorized, isCorrect: row.is_correct, @@ -75,6 +74,23 @@ async function compileExactSnapshot( hintsUsed: Number(row.hints_used), ingestedAt: new Date(row.ingested_at), })); + const sessionsById = snapshot.attempts.reduce>((byId, row) => { + const id = String(row.learning_session_id); + if (!byId.has(id)) { + byId.set(id, { + id, + studentId: row.student_id, + startedAt: new Date(row.session_started_at), + endedAt: new Date(row.session_ended_at), + totalElapsedMs: row.total_elapsed_ms == null ? null : Number(row.total_elapsed_ms), + vendorAttemptCount: + row.vendor_attempt_count == null ? null : Number(row.vendor_attempt_count), + timingQuality: row.session_timing_quality, + }); + } + return byId; + }, new Map()); + const sessions = Array.from(sessionsById.values()); const masteryByAnchor = new Map( [ [window.end, snapshot.currentMastery], @@ -108,6 +124,7 @@ async function compileExactSnapshot( students: [{ id: student.id, firstName: student.first_name }], skills, attempts: attempts.filter((attempt) => attempt.studentId === student.id), + sessions: sessions.filter((session) => session.studentId === student.id), items, skillPrereqs, mastery: { diff --git a/apps/web/public/synthetic-huddle-sample.csv b/apps/web/public/synthetic-huddle-sample.csv index e0d0990..cb24f1c 100644 --- a/apps/web/public/synthetic-huddle-sample.csv +++ b/apps/web/public/synthetic-huddle-sample.csv @@ -20,11 +20,11 @@ synthetic,blake-05,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-05-sess synthetic,blake-06,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-06-session,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,,,1,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,60000,60000,engaged,true,B,0 synthetic,blake-07,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-07-session,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,,,1,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,60500,60500,engaged,true,B,0 synthetic,blake-08,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-08-session,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,,,1,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,61500,61500,engaged,true,B,0 -synthetic,blake-09,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-09-session,2026-07-22T08:00:00Z,2026-07-22T08:00:50Z,,,1,2026-07-22T08:00:00Z,2026-07-22T08:00:50Z,54500,54500,engaged,false,A,0 -synthetic,blake-10,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-10-session,2026-07-22T08:10:00Z,2026-07-22T08:10:50Z,,,1,2026-07-22T08:10:00Z,2026-07-22T08:10:50Z,55000,55000,engaged,false,A,0 -synthetic,blake-11,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-11-session,2026-07-23T08:00:00Z,2026-07-23T08:00:50Z,,,1,2026-07-23T08:00:00Z,2026-07-23T08:00:50Z,55500,55500,engaged,false,A,0 -synthetic,blake-12,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-12-session,2026-07-24T08:00:00Z,2026-07-24T08:01:00Z,,,1,2026-07-24T08:00:00Z,2026-07-24T08:01:00Z,56000,56000,engaged,false,A,0 -synthetic,blake-13,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-13-session,2026-07-25T08:00:00Z,2026-07-25T08:01:00Z,,,1,2026-07-25T08:00:00Z,2026-07-25T08:01:00Z,56500,56500,engaged,false,A,0 +synthetic,blake-09,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-09-session,2026-07-22T08:00:00Z,2026-07-22T08:01:28Z,,,1,2026-07-22T08:00:00Z,2026-07-22T08:01:28Z,88000,88000,engaged,false,A,0 +synthetic,blake-10,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-10-session,2026-07-22T08:10:00Z,2026-07-22T08:11:50Z,,,1,2026-07-22T08:10:00Z,2026-07-22T08:11:50Z,110000,110000,engaged,false,A,0 +synthetic,blake-11,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-11-session,2026-07-23T08:00:00Z,2026-07-23T08:01:35Z,,,1,2026-07-23T08:00:00Z,2026-07-23T08:01:35Z,95000,95000,engaged,false,A,0 +synthetic,blake-12,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-12-session,2026-07-24T08:00:00Z,2026-07-24T08:02:10Z,,,1,2026-07-24T08:00:00Z,2026-07-24T08:02:10Z,130000,130000,engaged,false,A,0 +synthetic,blake-13,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-13-session,2026-07-25T08:00:00Z,2026-07-25T08:01:45Z,,,1,2026-07-25T08:00:00Z,2026-07-25T08:01:45Z,105000,105000,engaged,false,A,0 synthetic,casey-baseline-01,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-01-session,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,,,1,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,60000,60000,engaged,true,A,0 synthetic,casey-baseline-02,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-02-session,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,,,1,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,62000,62000,engaged,true,A,0 synthetic,casey-baseline-03,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-03-session,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,,,1,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,58000,58000,engaged,true,A,0 @@ -33,11 +33,11 @@ synthetic,casey-baseline-05,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,case synthetic,casey-baseline-06,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-06-session,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,,,1,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,60000,60000,engaged,true,A,0 synthetic,casey-baseline-07,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-07-session,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,,,1,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,60500,60500,engaged,true,A,0 synthetic,casey-baseline-08,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-08-session,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,,,1,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,61500,61500,engaged,true,A,0 -synthetic,casey-current-01,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-01-session,2026-07-22T14:00:00.000Z,2026-07-22T14:01:00.000Z,,,1,2026-07-22T14:00:00.000Z,2026-07-22T14:01:00.000Z,60000,60000,engaged,false,B,2 -synthetic,casey-current-02,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-02-session,2026-07-23T14:00:00.000Z,2026-07-23T14:01:00.000Z,,,1,2026-07-23T14:00:00.000Z,2026-07-23T14:01:00.000Z,60000,60000,engaged,false,B,2 -synthetic,casey-current-03,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-03-session,2026-07-24T14:00:00.000Z,2026-07-24T14:01:00.000Z,,,1,2026-07-24T14:00:00.000Z,2026-07-24T14:01:00.000Z,60000,60000,engaged,false,B,2 -synthetic,casey-current-04,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-04-session,2026-07-25T14:00:00.000Z,2026-07-25T14:01:00.000Z,,,1,2026-07-25T14:00:00.000Z,2026-07-25T14:01:00.000Z,60000,60000,engaged,false,B,2 -synthetic,casey-current-05,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-05-session,2026-07-26T14:00:00.000Z,2026-07-26T14:01:00.000Z,,,1,2026-07-26T14:00:00.000Z,2026-07-26T14:01:00.000Z,60000,60000,engaged,false,B,2 +synthetic,casey-current-01,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-01-session,2026-07-22T14:00:00.000Z,2026-07-22T14:00:45.000Z,,,1,2026-07-22T14:00:00.000Z,2026-07-22T14:00:45.000Z,45000,45000,engaged,false,B,1 +synthetic,casey-current-02,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-02-session,2026-07-23T14:00:00.000Z,2026-07-23T14:01:08.000Z,,,1,2026-07-23T14:00:00.000Z,2026-07-23T14:01:08.000Z,68000,68000,engaged,false,B,2 +synthetic,casey-current-03,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-03-session,2026-07-24T14:00:00.000Z,2026-07-24T14:01:22.000Z,,,1,2026-07-24T14:00:00.000Z,2026-07-24T14:01:22.000Z,82000,82000,engaged,false,B,3 +synthetic,casey-current-04,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-04-session,2026-07-25T14:00:00.000Z,2026-07-25T14:01:10.000Z,,,1,2026-07-25T14:00:00.000Z,2026-07-25T14:01:10.000Z,70000,70000,engaged,false,C,2 +synthetic,casey-current-05,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-05-session,2026-07-26T14:00:00.000Z,2026-07-26T14:00:55.000Z,,,1,2026-07-26T14:00:00.000Z,2026-07-26T14:00:55.000Z,55000,55000,engaged,false,C,1 synthetic,drew-baseline-01,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-01-session,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,,,1,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,60000,60000,engaged,true,A,0 synthetic,drew-baseline-02,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-02-session,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,,,1,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,62000,62000,engaged,true,A,0 synthetic,drew-baseline-03,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-03-session,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,,,1,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,58000,58000,engaged,true,A,0 diff --git a/apps/web/test/evidence-desk-rendering.test.ts b/apps/web/test/evidence-desk-rendering.test.ts index 4a1ef15..7f15971 100644 --- a/apps/web/test/evidence-desk-rendering.test.ts +++ b/apps/web/test/evidence-desk-rendering.test.ts @@ -1,16 +1,19 @@ +import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; -import type { AdditionalEvidenceView, BoardEntryView, RefreshView } from '@huddle/application'; +import type { BoardEntryView, EvidenceView, RefreshView } from '@huddle/application'; import { EvidenceDesk, - EvidenceDetails, - EvidenceRecordDisclosure, NarrationTrust, + ReportContext, ReportHeading, + StatusCue, + TeacherEvidenceList, narrationProvenance, refreshDescription, } from '../app/board/evidence-desk'; +import { teacherReportView } from '../app/board/lib/teacher-report-view'; import type { EvidenceDeskState } from '../app/board/lib/evidence-desk-state'; import { acknowledgmentLabel, @@ -19,10 +22,68 @@ import { VisibleOpenAcknowledgment, } from '../app/board/visible-open-acknowledgment'; -const evidence: AdditionalEvidenceView = { - signalId: 47, +const entry = { + triageEntryId: 'entry-1', + findingFingerprint: 'finding-1', + student: { id: 'student-1', firstName: 'Avery' }, + rank: 1, cause: 'guessing', scope: { kind: 'skill', skill: { code: '4.4A', name: 'Add and subtract' } }, + severity: 0.81, + finalConfidence: 0.5796, + diagnosis: 'Evidence-backed diagnosis.', + opener: 'Show me your first step.', + narration: { + mode: 'deterministic-fallback', + status: 'degraded', + degradedReason: 'model-unavailable', + catalogVersion: 'catalog-3', + renderVersion: 'renderer-8', + }, + acknowledgedAt: null, + additionalCauseCount: 2, +} satisfies BoardEntryView; + +const attempt = { + attemptId: 91, + activityId: 'activity-hash-1', + ordinal: 4, + skill: { code: '4.4A', name: 'Add and subtract' }, + itemType: 'multiple_choice', + timingProfile: 'word_problem', + submittedAt: '2026-07-29T07:31:22.125Z', + isCorrect: false, + elapsedMs: 1501, + engagedMs: 1499, + timingQuality: 'engaged', + chosenLabel: 'B', + misconception: 'reversed operation', + hintsUsed: 2, +} as const; + +const session = { + sessionId: 12, + startedAt: '2026-07-29T07:30:00.000Z', + endedAt: '2026-07-29T07:40:00.000Z', + totalElapsedMs: 600_001, + vendorAttemptCount: 7, + timingQuality: 'session_only', +} as const; + +const emptySession = { + sessionId: 13, + startedAt: '2026-07-29T07:31:00.000Z', + endedAt: '2026-07-29T07:32:00.000Z', + totalElapsedMs: null, + vendorAttemptCount: null, + timingQuality: 'none', +} as const; + +const evidence = { + kind: 'evidence', + boardRunId: 'run-1', + signalId: 47, + entry, summary: { dominantCause: 'guessing', severity: 0.81, @@ -36,92 +97,224 @@ const evidence: AdditionalEvidenceView = { ruleId: 'guessing.fast-wrong', ruleVersion: '3', }, - computed: { - attemptCount: 4, - wrongCount: 3, - medianWrongDurationMs: 1501, - personalCorrectBaselineMs: 3200, - personalSessionMeanBaselineMs: 6400, - distractorConcentration: 0.625, - winsorizedOutCount: 1, - }, - derived: { - wrongOfLastN: { wrong: 3, of: 4 }, - speedRatio: 0.4690625, - consecutiveWrong: 2, - daysSinceFirstAttempt: 6, - }, - prerequisiteCheck: { - skillCode: '4.3E', - skillName: 'Fractions', - masteryValue: 0.73, - isKnown: true, - verdict: 'adequate', - }, - conflicts: [{ family: 'mastery', suggestedCause: 'prerequisite_gap', ruleId: 'prereq-v2' }], - attempts: [ - { - attemptId: 91, - activityId: 'activity-hash-1', - ordinal: 4, - skill: { code: '4.4A', name: 'Add and subtract' }, - itemType: 'multiple_choice', - timingProfile: 'word_problem', - submittedAt: '2026-07-29T07:31:22.125Z', - isCorrect: false, - elapsedMs: 1501, - engagedMs: 1499, - timingQuality: 'engaged', - chosenLabel: 'B', - misconception: 'reversed operation', - hintsUsed: 2, + comparison: { + computed: { + attemptCount: 4, + wrongCount: 3, + medianWrongDurationMs: 1501, + personalCorrectBaselineMs: 3200, + personalSessionMeanBaselineMs: 6400, + distractorConcentration: 0.625, + winsorizedOutCount: 1, + }, + derived: { + wrongOfLastN: { wrong: 3, of: 4 }, + speedRatio: 0.4690625, + consecutiveWrong: 2, + daysSinceFirstAttempt: 6, + }, + prerequisiteCheck: { + skillCode: '4.3E', + skillName: 'Fractions', + masteryValue: 0.73, + isKnown: true, + verdict: 'adequate', }, - ], - sessions: [ - { - sessionId: 12, - startedAt: '2026-07-29T07:30:00.000Z', - endedAt: '2026-07-29T07:40:00.000Z', - totalElapsedMs: 600001, - vendorAttemptCount: 7, - timingQuality: 'session_only', + conflicts: [{ family: 'mastery', suggestedCause: 'prerequisite_gap', ruleId: 'prereq-v2' }], + additionalCauses: [ + { + signalId: 48, + cause: 'hint_farming', + severity: 0.45, + finalConfidence: 0.7, + ruleId: 'hint.farming', + scope: entry.scope, + }, + ], + additionalEvidence: [ + { + signalId: 48, + cause: 'hint_farming', + scope: entry.scope, + summary: { + dominantCause: 'hint_farming', + severity: 0.45, + rawConfidence: 1, + finalConfidence: 0.7, + confidenceBreakdown: { + timingMultiplier: 1, + winsorizationMultiplier: 1, + conflictMultiplier: 0.7, + }, + ruleId: 'hint.farming', + ruleVersion: '3', + }, + computed: { + attemptCount: 1, + wrongCount: 1, + medianWrongDurationMs: 1501, + personalCorrectBaselineMs: 3200, + personalSessionMeanBaselineMs: 6400, + distractorConcentration: 1, + winsorizedOutCount: 0, + }, + derived: { + wrongOfLastN: { wrong: 1, of: 1 }, + speedRatio: 0.4, + consecutiveWrong: 1, + daysSinceFirstAttempt: 1, + }, + prerequisiteCheck: null, + conflicts: [], + attempts: [attempt], + sessions: [session, emptySession], + }, + ], + }, + exact: { attempts: [attempt], sessions: [session, emptySession] }, +} satisfies EvidenceView; + +function boardState(): EvidenceDeskState { + return { + kind: 'board', + board: { + kind: 'ready', + boardRunId: 'run-1', + requestedBoardDate: '2026-07-29', + boardDate: '2026-07-29', + asOf: '2026-07-29T08:00:00.000Z', + timezone: 'America/Chicago', + completedAt: '2026-07-29T08:00:00.000Z', + inputReceiptSetFingerprint: 'receipts', + entries: [entry], + refresh: { + state: 'succeeded', + requestId: 'internal-request-2', + completedAt: '2026-07-29T08:03:00.000Z', + boardRunId: 'run-1', + }, + narration: { status: 'degraded', degradedCount: 1 }, }, - ], -}; + open: null, + }; +} + +describe('Evidence Desk teacher presentation', () => { + it('makes rank, priority, and confidence explicit on each queue card before selection', () => { + const html = renderToStaticMarkup(createElement(EvidenceDesk, { state: boardState() })); + + expect(html).toContain('Urgent priority'); + expect(html).toContain('#1 today'); + expect(html).toContain('Medium confidence'); + expect(html).toContain('aria-label="Rank #1 today. Urgent priority. Medium confidence."'); + }); + + it('uses one deduplicated, teacher-friendly evidence list', () => { + const report = teacherReportView(evidence); + const html = renderToStaticMarkup( + createElement(TeacherEvidenceList, { studentName: 'Avery', report }) + ); + + expect(report.evidence).toHaveLength(1); + expect(html).toContain('4.4A · Add and subtract'); + expect(html).toContain('Incorrect'); + expect(html).toContain('Answer selected'); + expect(html).toContain('B'); + expect(html).toContain('Useful misconception'); + expect(html).toContain('reversed operation'); + expect(html).toContain('Active work: 1.5 sec'); + expect(html).toContain('Elapsed: 1.5 sec'); + expect(html).toContain('Work session'); + expect(html).toContain('600 sec total'); + expect(html).toContain('Activities recorded'); + expect(html).toContain('7'); + expect(html).toContain('Work session<\/strong>/g)).toHaveLength(1); + expect(report.prerequisite).toBe('4.3E · Fractions — 73% mastery (adequate)'); + expect(report.additionalContexts).toHaveLength(1); + expect(html).not.toMatch(/\bms\b/); + }); + + it('renders teacher-facing durations in seconds', () => { + const wallclockAttempt = { + ...attempt, + activityId: 'activity-hash-2', + elapsedMs: 119_500, + engagedMs: null, + timingQuality: 'wallclock', + } as const; + const wallclockEvidence: EvidenceView = { + ...evidence, + comparison: { ...evidence.comparison, additionalEvidence: [] }, + exact: { attempts: [wallclockAttempt], sessions: [] }, + }; + const html = renderToStaticMarkup( + createElement(TeacherEvidenceList, { + studentName: 'Avery', + report: teacherReportView(wallclockEvidence), + }) + ); + + expect(html).toContain('Completed in 120 sec'); + expect(html).not.toMatch(/\bms\b/); + }); + + it('keeps a prerequisite check reachable as teacher-facing context', () => { + const html = renderToStaticMarkup( + createElement(ReportContext, { evidence, report: teacherReportView(evidence) }) + ); + + expect(html).toContain('Prerequisite checked'); + expect(html).toContain('4.3E · Fractions — 73% mastery (adequate)'); + expect(html).toContain('Usual correct pace
3.2 sec'); + expect(html).toContain('Recent attempt pace
1.5 sec'); + expect(html).not.toMatch(/\bms\b/); + expect(html).not.toContain('conflict'); + }); + + it('keeps engine and storage jargon out of the normal evidence flow', () => { + const html = renderToStaticMarkup( + createElement(TeacherEvidenceList, { + studentName: 'Avery', + report: teacherReportView(evidence), + }) + ); -describe('Evidence Desk truthful presentation', () => { - it('renders complete exact evidence without duration rounding', () => { - const html = renderToStaticMarkup(createElement(EvidenceRecordDisclosure, { evidence })); - for (const value of [ + for (const jargon of [ + 'Attempt ID', + 'activity-hash-1', + 'Ordinal', + 'multiple_choice', + 'word_problem', 'Signal ID', - '47', + 'Rule and quality details', + 'session_only', '0.92', '0.5796', - '0.625', - '0.4690625', - 'Attempt ID 91', - 'activity-hash-1', - 'Ordinal 4', - 'word_problem', - 'reversed operation', - 'Hints used: 2', - '1501 ms', - '1499 ms', - 'Session ID', - '600001 ms', + 'conflict', ]) { - expect(html).toContain(value); + expect(html).not.toContain(jargon); } - const exactDisclosure = html.indexOf('
'); - expect(html.indexOf('Prerequisite check')).toBeLessThan(exactDisclosure); - expect(html.indexOf('Conflict adjustments')).toBeLessThan(exactDisclosure); - expect(exactDisclosure).toBeLessThan(html.indexOf('Exact contributing attempts')); - const exactOnly = renderToStaticMarkup( - createElement(EvidenceDetails, { label: 'Complete evidence', evidence }) - ); - expect(exactOnly).not.toContain('Prerequisite check'); - expect(exactOnly).not.toContain('Conflict adjustments'); - expect(html).not.toContain('2 sec'); + }); + + it('uses the same full priority language in the queue and report header', () => { + const rail = renderToStaticMarkup(createElement(EvidenceDesk, { state: boardState() })); + const heading = renderToStaticMarkup(createElement(ReportHeading, { entry })); + const cue = renderToStaticMarkup(createElement(StatusCue, { entry })); + + expect(rail).toContain('Urgent priority'); + expect(heading).toContain('Urgent priority'); + expect(cue).toContain('#1 today'); + }); + + it('keeps the evidence list usable at narrow widths', () => { + const styles = readFileSync(new URL('../app/globals.css', import.meta.url), 'utf8'); + + expect(styles).toContain('@media (max-width: 760px)'); + expect(styles).toContain('.teacher-evidence-item {\n grid-template-columns: 1fr;'); + expect(styles).toContain('.teacher-evidence-content dl {\n display: grid;'); }); it('distinguishes every refresh state, including first-build failure', () => { @@ -147,144 +340,25 @@ describe('Evidence Desk truthful presentation', () => { ]); }); - it('includes catalog and renderer versions in narration provenance', () => { - const entry = { - narration: { - mode: 'deterministic-fallback', - status: 'degraded', - degradedReason: 'pending', - catalogVersion: 'catalog-3', - renderVersion: 'renderer-8', - }, - } as BoardEntryView; + it('keeps narration provenance behind its existing technical boundary', () => { expect(narrationProvenance(entry)).toBe( - 'Deterministic fallback · degraded: pending · catalog catalog-3 · renderer renderer-8' + 'Deterministic fallback · degraded: model-unavailable · catalog catalog-3 · renderer renderer-8' ); - }); - - it('keeps the dominant cause and degraded reason visible in the report', () => { - const entry = { - triageEntryId: 'entry-1', - findingFingerprint: 'finding-1', - student: { id: 'student-1', firstName: 'Avery' }, - rank: 1, - cause: 'guessing', - scope: { kind: 'skill', skill: { code: '4.4A', name: 'Add and subtract' } }, - severity: 0.81, - finalConfidence: 0.5796, - diagnosis: 'Evidence-backed diagnosis.', - opener: 'Show me your first step.', - narration: { - mode: 'deterministic-fallback', - status: 'degraded', - degradedReason: 'model-unavailable', - catalogVersion: 'catalog-3', - renderVersion: 'renderer-8', - }, - acknowledgedAt: null, - additionalCauseCount: 2, - } satisfies BoardEntryView; - const heading = renderToStaticMarkup(createElement(ReportHeading, { entry })); const trust = renderToStaticMarkup(createElement(NarrationTrust, { entry })); - - expect(heading).toContain('Guessing pattern · Add and subtract'); - expect(trust).toContain('Deterministic fallback · degraded'); + expect(trust).toContain('Technical provenance'); expect(trust).toContain('Reason: model unavailable.'); - expect(trust.indexOf('Reason: model unavailable.')).toBeLessThan(trust.indexOf('
')); - }); - - it('shows partial board narration and its degraded count', () => { - const state: EvidenceDeskState = { - kind: 'board', - board: { - kind: 'ready', - boardRunId: 'run-1', - requestedBoardDate: '2026-07-29', - boardDate: '2026-07-29', - asOf: '2026-07-29T08:00:00.000Z', - timezone: 'America/Chicago', - completedAt: '2026-07-29T08:00:00.000Z', - inputReceiptSetFingerprint: 'receipts', - entries: [], - refresh: { state: 'running', requestId: 'refresh-2', requestedAt: '08:03' }, - narration: { status: 'degraded', degradedCount: 2 }, - }, - open: null, - }; - const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); - expect(html).toContain('2 deterministic fallbacks'); - expect(html).toContain('Refreshing since 08:03; current board stays available'); - }); - - it('keeps the ranked rail focused on the guide decision instead of implementation metadata', () => { - const entry: BoardEntryView = { - triageEntryId: 'entry-1', - findingFingerprint: 'finding-1', - student: { id: 'student-1', firstName: 'Avery' }, - rank: 1, - cause: 'guessing', - scope: { kind: 'skill', skill: { code: '4.4A', name: 'Add and subtract' } }, - severity: 0.81, - finalConfidence: 0.5796, - diagnosis: 'Evidence-backed diagnosis.', - opener: 'Show me your first step.', - narration: { - mode: 'deterministic-fallback', - status: 'degraded', - degradedReason: 'model-unavailable', - catalogVersion: 'catalog-3', - renderVersion: 'renderer-8', - }, - acknowledgedAt: null, - additionalCauseCount: 2, - }; - const state: EvidenceDeskState = { - kind: 'board', - board: { - kind: 'ready', - boardRunId: 'run-1', - requestedBoardDate: '2026-07-29', - boardDate: '2026-07-29', - asOf: '2026-07-29T08:00:00.000Z', - timezone: 'America/Chicago', - completedAt: '2026-07-29T08:00:00.000Z', - inputReceiptSetFingerprint: 'receipts', - entries: [entry], - refresh: { - state: 'succeeded', - requestId: 'internal-request-2', - completedAt: '2026-07-29T08:03:00.000Z', - boardRunId: 'run-1', - }, - narration: { status: 'degraded', degradedCount: 1 }, - }, - open: null, - }; - - const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); - expect(html).toContain('Today’s attention queue'); - expect(html).toContain('Guessing pattern · 4.4A'); - expect(html).toContain('Urgent · Medium confidence · 2 more causes'); - expect(html).toContain('Not yet seen'); - expect(html).not.toContain('internal-request-2'); - expect(html).not.toContain('2026-07-29T08:00:00.000Z'); }); it('provides a focusable successful-empty restoration target', () => { + const readyState = boardState(); + if (readyState.kind !== 'board') throw new Error('Expected a board state'); const state: EvidenceDeskState = { kind: 'board', board: { + ...readyState.board, kind: 'successful-empty', - boardRunId: 'run-empty', - requestedBoardDate: '2026-07-29', - boardDate: '2026-07-29', - asOf: '2026-07-29T08:00:00.000Z', - timezone: 'America/Chicago', - completedAt: '2026-07-29T08:00:00.000Z', - inputReceiptSetFingerprint: 'receipts', entries: [], refresh: { state: 'idle' }, - narration: { status: 'complete', degradedCount: 0 }, }, open: null, }; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 4d8cfcf..dff5541 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -68,32 +68,25 @@ export const AnswerChoiceSchema = z.object({ misconceptionId: z.string().optional(), }); -export const AttemptSchema = z - .object({ - id: z.number().nullable(), - activityId: z.string().regex(/^[a-f0-9]{64}$/), - studentId: z.string().uuid(), - skillId: z.string(), - itemId: z.string(), - sessionId: z.string().uuid(), - attemptIndex: z.number().int().min(1), - startedAt: z.coerce.date(), - submittedAt: z.coerce.date(), - elapsedMs: z.number().int().nullable(), - engagedMs: z.number().int().nullable(), - sessionTotalMs: z.number().int().nullable(), - timingQuality: TimingQualitySchema, - timingWasWinsorized: z.boolean().default(false), - isCorrect: z.boolean(), - answerGiven: AnswerChoiceSchema.nullable(), - hintsUsed: z.number().int().min(0).default(0), - ingestedAt: z.coerce.date(), - }) - .refine( - (attempt) => attempt.timingQuality !== 'session_only' || attempt.sessionTotalMs != null, - // Mirrors the session_total_for_session_only CHECK in db/migrations/002_attempt.sql. - { message: 'session_only timing requires a session total', path: ['sessionTotalMs'] } - ); +export const AttemptSchema = z.object({ + id: z.number().nullable(), + activityId: z.string().regex(/^[a-f0-9]{64}$/), + studentId: z.string().uuid(), + skillId: z.string(), + itemId: z.string(), + sessionId: z.string().uuid(), + attemptIndex: z.number().int().min(1), + startedAt: z.coerce.date(), + submittedAt: z.coerce.date(), + elapsedMs: z.number().int().nullable(), + engagedMs: z.number().int().nullable(), + timingQuality: TimingQualitySchema, + timingWasWinsorized: z.boolean().default(false), + isCorrect: z.boolean(), + answerGiven: AnswerChoiceSchema.nullable(), + hintsUsed: z.number().int().min(0).default(0), + ingestedAt: z.coerce.date(), +}); export const SignalSchema = z.object({ id: z.number().nullable(), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1fa5ed6..37f49b0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -113,7 +113,6 @@ export interface Attempt { submittedAt: Date; elapsedMs: number | null; engagedMs: number | null; - sessionTotalMs: number | null; timingQuality: TimingQuality; timingWasWinsorized: boolean; isCorrect: boolean; @@ -122,6 +121,16 @@ export interface Attempt { ingestedAt: Date; } +export interface LearningSession { + id: string; + studentId: string; + startedAt: Date; + endedAt: Date; + totalElapsedMs: number | null; + vendorAttemptCount: number | null; + timingQuality: 'session_only' | 'none'; +} + export interface ConfidenceBreakdown { timingMultiplier: number; winsorizationMultiplier: number; diff --git a/packages/db/src/boards.ts b/packages/db/src/boards.ts index cd8d378..98014db 100644 --- a/packages/db/src/boards.ts +++ b/packages/db/src/boards.ts @@ -84,7 +84,8 @@ export async function loadCompilerSnapshot( a.attempt_index,a.started_at,a.submitted_at,a.elapsed_ms,a.engaged_ms,a.timing_quality, a.timing_was_winsorized, a.is_correct,a.answer_given,a.hints_used,a.source,a.source_event_id,a.ingested_at, - s.total_elapsed_ms + s.started_at AS session_started_at,s.ended_at AS session_ended_at, + s.total_elapsed_ms,s.vendor_attempt_count,s.timing_quality AS session_timing_quality FROM attempt a JOIN learning_session s ON s.id=a.learning_session_id JOIN import_run r ON r.id=a.import_run_id JOIN student student_scope ON student_scope.id=a.student_id diff --git a/packages/db/test/operations-boundaries.test.ts b/packages/db/test/operations-boundaries.test.ts index 24ed9c4..94b4684 100644 --- a/packages/db/test/operations-boundaries.test.ts +++ b/packages/db/test/operations-boundaries.test.ts @@ -157,6 +157,10 @@ describe('Operations data boundaries', () => { ); expect(attemptRead?.text).toMatch(/student_scope\.guide_id=\$2::uuid/); expect(attemptRead?.text).toMatch(/student_scope\.is_synthetic IS TRUE/); + expect(attemptRead?.text).toMatch(/s\.started_at AS session_started_at/); + expect(attemptRead?.text).toMatch(/s\.ended_at AS session_ended_at/); + expect(attemptRead?.text).toMatch(/s\.vendor_attempt_count/); + expect(attemptRead?.text).toMatch(/s\.timing_quality AS session_timing_quality/); expect(attemptRead?.values).toEqual(['7', 'guide']); }); diff --git a/packages/ingest/test/portfolio-csv.test.ts b/packages/ingest/test/portfolio-csv.test.ts index 7a5afe0..1929e85 100644 --- a/packages/ingest/test/portfolio-csv.test.ts +++ b/packages/ingest/test/portfolio-csv.test.ts @@ -40,4 +40,44 @@ describe('fixed portfolio CSV', () => { analysis.acceptedAttempts.every((attempt) => attempt.source === 'synthetic-csv-v1') ).toBe(true); }); + + it('gives each ranked synthetic student a distinct, reviewable evidence story', async () => { + const content = await readFile(join(root, 'apps/web/public/synthetic-huddle-sample.csv')); + const analysis = analyzeSyntheticCsv( + { + name: 'synthetic-huddle-sample.csv', + mediaType: 'text/csv', + sizeBytes: content.byteLength, + content, + }, + { + students: new Map( + [1, 2, 3, 4].map((number) => [ + `synthetic-student-0${number}`, + { id: `00000000-0000-0000-0000-00000000000${number}` }, + ]) + ), + skills: new Set(skills.map((skill) => skill.id)), + items: new Map(items.map((item) => [item.id, item])), + } + ); + const current = (student: string) => + analysis.acceptedAttempts.filter((attempt) => attempt.sourceEventId.startsWith(student)); + const avery = current('avery-').filter( + (attempt) => Number(attempt.sourceEventId.slice(-2)) >= 9 + ); + const blake = current('blake-').filter( + (attempt) => Number(attempt.sourceEventId.slice(-2)) >= 9 + ); + const casey = current('casey-current-'); + + expect(avery.map((attempt) => attempt.answerGiven?.key)).toEqual(['A', 'C', 'D', 'A', 'B']); + expect(blake.map((attempt) => attempt.elapsedMs)).toEqual([ + 88_000, 110_000, 95_000, 130_000, 105_000, + ]); + expect(blake.every((attempt) => attempt.hintsUsed === 0)).toBe(true); + expect(casey.map((attempt) => attempt.answerGiven?.key)).toEqual(['B', 'B', 'B', 'C', 'C']); + expect(casey.map((attempt) => attempt.hintsUsed)).toEqual([1, 2, 3, 2, 1]); + expect(new Set(casey.map((attempt) => attempt.elapsedMs)).size).toBeGreaterThan(1); + }); }); diff --git a/packages/narrator/test/no-pii.test.ts b/packages/narrator/test/no-pii.test.ts index 5da6c53..aa972c5 100644 --- a/packages/narrator/test/no-pii.test.ts +++ b/packages/narrator/test/no-pii.test.ts @@ -32,6 +32,7 @@ async function assembledBundle(): Promise { students: [student], skills, attempts: fixture.attempts, + sessions: [], items, skillPrereqs, mastery: handAuthoredMasteryLookup(profile), diff --git a/packages/signal-engine/src/baselines/index.ts b/packages/signal-engine/src/baselines/index.ts index c94009c..02db881 100644 --- a/packages/signal-engine/src/baselines/index.ts +++ b/packages/signal-engine/src/baselines/index.ts @@ -1,6 +1,6 @@ -import type { Attempt, PersonalBaseline } from '@huddle/core'; +import type { Attempt, LearningSession, PersonalBaseline } from '@huddle/core'; import type { RuleConfig } from '../contract.js'; -import { perAttemptDurationMs } from '../timing.js'; +import { hasUsableSessionAggregate, perAttemptDurationMs } from '../timing.js'; import { chicagoDaysBefore } from '../windows.js'; function median(values: number[]): number | null { @@ -12,8 +12,6 @@ function median(values: number[]): number | null { interface SessionTiming { perAttemptDurations: number[]; - sessionTotals: number[]; - sessionOnlyAttemptCount: number; } /** @@ -24,6 +22,7 @@ interface SessionTiming { export function computePersonalBaseline( studentId: string, attempts: readonly Attempt[], + sessionFacts: readonly LearningSession[], windowEnd: Date, config: RuleConfig ): PersonalBaseline | null { @@ -39,25 +38,25 @@ export function computePersonalBaseline( const correctDurations: number[] = []; const wrongChoiceCounts: Record = {}; - const sessions = new Map(); + const sessionTimings = new Map(); + const sessions = new Map( + sessionFacts + .filter((session) => session.studentId === studentId) + .map((session) => [session.id, session]) + ); let wrongCount = 0; for (const attempt of windowAttempts) { const duration = perAttemptDurationMs(attempt); if (attempt.isCorrect && duration != null) correctDurations.push(duration); - const session = sessions.get(attempt.sessionId) ?? { + const session = sessionTimings.get(attempt.sessionId) ?? { perAttemptDurations: [], - sessionTotals: [], - sessionOnlyAttemptCount: 0, }; - if (attempt.timingQuality === 'session_only') { - if (attempt.sessionTotalMs != null) session.sessionTotals.push(attempt.sessionTotalMs); - session.sessionOnlyAttemptCount += 1; - } else if (duration != null) { + if (duration != null) { session.perAttemptDurations.push(duration); } - sessions.set(attempt.sessionId, session); + sessionTimings.set(attempt.sessionId, session); if (!attempt.isCorrect && attempt.answerGiven) { wrongCount += 1; @@ -67,14 +66,14 @@ export function computePersonalBaseline( } const sessionMeans: number[] = []; - for (const session of sessions.values()) { - if (session.sessionTotals.length > 0 && session.sessionOnlyAttemptCount > 0) { - // Vendors repeat the same session total on member attempts; use the total once. - sessionMeans.push(Math.max(...session.sessionTotals) / session.sessionOnlyAttemptCount); - } else if (session.perAttemptDurations.length > 0) { + for (const [sessionId, timing] of sessionTimings) { + const aggregate = sessions.get(sessionId); + if (aggregate && hasUsableSessionAggregate(aggregate)) { + sessionMeans.push(aggregate.totalElapsedMs / aggregate.vendorAttemptCount); + } else if (timing.perAttemptDurations.length > 0) { sessionMeans.push( - session.perAttemptDurations.reduce((sum, value) => sum + value, 0) / - session.perAttemptDurations.length + timing.perAttemptDurations.reduce((sum, value) => sum + value, 0) / + timing.perAttemptDurations.length ); } } diff --git a/packages/signal-engine/src/confidence.ts b/packages/signal-engine/src/confidence.ts index e710dfa..5d3b951 100644 --- a/packages/signal-engine/src/confidence.ts +++ b/packages/signal-engine/src/confidence.ts @@ -1,5 +1,6 @@ -import type { Attempt, ConfidenceBreakdown, TimingQuality } from '@huddle/core'; +import type { Attempt, ConfidenceBreakdown, LearningSession, TimingQuality } from '@huddle/core'; import type { InputRequirement, RuleConfig } from './contract.js'; +import { hasUsableSessionAggregate } from './timing.js'; type TimingRequirement = Extract; @@ -11,6 +12,7 @@ function timingRequirement( function worstUsableTimingQuality( attempts: readonly Attempt[], + sessions: readonly LearningSession[], requirement: TimingRequirement ): TimingQuality { const order: TimingQuality[] = ['engaged', 'wallclock', 'session_only', 'none']; @@ -23,16 +25,18 @@ function worstUsableTimingQuality( (attempt.engagedMs != null || attempt.elapsedMs != null) ); return ( - (attempt.timingQuality === 'session_only' && - attempt.sessionTotalMs != null && - attempt.sessionTotalMs > 0) || - ((attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') && - (attempt.engagedMs != null || attempt.elapsedMs != null)) + (attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') && + (attempt.engagedMs != null || attempt.elapsedMs != null) ); }); - return usable.reduce( - (worst, attempt) => - order.indexOf(attempt.timingQuality) > order.indexOf(worst) ? attempt.timingQuality : worst, + const qualities: TimingQuality[] = usable.map((attempt) => attempt.timingQuality); + if (requirement === 'timing.sessionAggregate') { + qualities.push( + ...sessions.filter(hasUsableSessionAggregate).map((session) => session.timingQuality) + ); + } + return qualities.reduce( + (worst, quality) => (order.indexOf(quality) > order.indexOf(worst) ? quality : worst), 'engaged' ); } @@ -57,13 +61,14 @@ function winsorizationMultiplier( export function confidenceBreakdown( attempts: readonly Attempt[], + sessions: readonly LearningSession[], requiredInputs: readonly InputRequirement[], hasConflict: boolean, config: RuleConfig ): ConfidenceBreakdown { const requirement = timingRequirement(requiredInputs); const timingMultiplier = requirement - ? config.confidence.tierFactors[worstUsableTimingQuality(attempts, requirement)] + ? config.confidence.tierFactors[worstUsableTimingQuality(attempts, sessions, requirement)] : 1; return { timingMultiplier, @@ -75,10 +80,11 @@ export function confidenceBreakdown( export function attenuateConfidence( baseConfidence: number, attempts: readonly Attempt[], + sessions: readonly LearningSession[], config: RuleConfig, requiredInputs: readonly InputRequirement[] = [] ): number { - const breakdown = confidenceBreakdown(attempts, requiredInputs, false, config); + const breakdown = confidenceBreakdown(attempts, sessions, requiredInputs, false, config); return Math.max( 0, Math.min(1, baseConfidence * breakdown.timingMultiplier * breakdown.winsorizationMultiplier) diff --git a/packages/signal-engine/src/config/thresholds.ts b/packages/signal-engine/src/config/thresholds.ts index 05bbb50..a4349c1 100644 --- a/packages/signal-engine/src/config/thresholds.ts +++ b/packages/signal-engine/src/config/thresholds.ts @@ -95,7 +95,7 @@ export const RULE_CONFIG = { } satisfies Record, } as const; -export const RULE_IMPLEMENTATION_VERSION = 3; +export const RULE_IMPLEMENTATION_VERSION = 4; /** Canonical JSON: recursively sorted object keys, array order preserved. */ export function stableStringify(value: unknown): string { diff --git a/packages/signal-engine/src/contract.ts b/packages/signal-engine/src/contract.ts index aeec4bf..80a89fd 100644 --- a/packages/signal-engine/src/contract.ts +++ b/packages/signal-engine/src/contract.ts @@ -4,6 +4,7 @@ import type { RootCause, EvidenceFamily, ItemType, + LearningSession, Skill, SkillPrereq, MasterySnapshot, @@ -81,6 +82,7 @@ export interface RuleContext { readonly window: { start: Date; end: Date }; readonly attempts: readonly Attempt[]; readonly sessionAttempts: readonly Attempt[]; + readonly sessions: readonly LearningSession[]; readonly items: Record< string, { diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index 6443170..f99153c 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -7,6 +7,7 @@ import type { EvidenceBundle, EvidenceScope, Item, + LearningSession, Signal, Skill, SkillPrereq, @@ -29,11 +30,13 @@ import { chicagoDateOnlyBoundary, isInHalfOpenWindow, } from './windows.js'; +import { hasUsableSessionAggregate } from './timing.js'; export interface EngineInput { students: { id: string; firstName: string }[]; skills: Skill[]; attempts: Attempt[]; + sessions: LearningSession[]; items: Item[]; skillPrereqs: SkillPrereq[]; mastery: MasteryLookup; @@ -96,7 +99,13 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp for (const student of input.students) { baselineByStudent.set( student.id, - computePersonalBaseline(student.id, input.attempts, input.window.start, input.config) + computePersonalBaseline( + student.id, + input.attempts, + input.sessions, + input.window.start, + input.config + ) ); } @@ -138,6 +147,10 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp classifications.push(evaluated.classification); pendingSignals.push(...evaluated.pendingSignals); }; + const sessionsForAttempts = (attempts: readonly Attempt[]) => { + const ids = new Set(attempts.map((attempt) => attempt.sessionId)); + return input.sessions.filter((session) => ids.has(session.id)); + }; if (perSkillRules.length > 0) { for (const [key, attempts] of attemptsByStudentSkill) { @@ -159,6 +172,7 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp skill, attempts, sessionAttempts: attemptsByStudent.get(studentId) ?? attempts, + sessions: sessionsForAttempts(attemptsByStudent.get(studentId) ?? attempts), baseline: baselineByStudent.get(studentId) ?? null, }, perSkillRules @@ -177,6 +191,7 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp skill: null, attempts: attemptsByStudent.get(student.id) ?? [], sessionAttempts: attemptsByStudent.get(student.id) ?? [], + sessions: sessionsForAttempts(attemptsByStudent.get(student.id) ?? []), baseline: baselineByStudent.get(student.id) ?? null, }, perStudentRules @@ -190,6 +205,7 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp ); const breakdown = confidenceBreakdown( pending.ctx.attempts, + pending.ctx.sessions, pending.rule.requiredInputs, conflicts.length > 0, pending.ctx.config @@ -443,14 +459,13 @@ function inputSatisfied( (attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') ); case 'timing.sessionAggregate': - // A session aggregate is honest from an explicit session total or from genuine per-attempt durations. - return ctx.sessionAttempts.some( - (attempt) => - (attempt.timingQuality === 'session_only' && - attempt.sessionTotalMs != null && - attempt.sessionTotalMs > 0) || - ((attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') && - (attempt.engagedMs != null || attempt.elapsedMs != null)) + return ( + ctx.sessions.some(hasUsableSessionAggregate) || + ctx.sessionAttempts.some( + (attempt) => + (attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') && + (attempt.engagedMs != null || attempt.elapsedMs != null) + ) ); case 'answerChoices': return resolvedAnswerChoices(ctx).length > 0; diff --git a/packages/signal-engine/src/evidence.ts b/packages/signal-engine/src/evidence.ts index 1247af5..f2d7728 100644 --- a/packages/signal-engine/src/evidence.ts +++ b/packages/signal-engine/src/evidence.ts @@ -2,13 +2,14 @@ import type { EvidenceBundle, EvidenceFamily, EvidenceScope, + LearningSession, RootCause, Signal, } from '@huddle/core'; import { MISCONCEPTIONS } from '@huddle/core/seed'; import { isWinsorizedOut } from './confidence.js'; import type { Evidence, RuleContext, RuleOutcome } from './contract.js'; -import { perAttemptDurationMs } from './timing.js'; +import { hasUsableSessionAggregate, perAttemptDurationMs } from './timing.js'; import { chicagoCalendarDaysBetween } from './windows.js'; export interface Conflict { @@ -21,6 +22,7 @@ export interface EvidenceBundleContext extends RuleContext { ruleId: string; additionalCauses: EvidenceBundle['additionalCauses']; conflicts: Conflict[]; + sessions: readonly LearningSession[]; } const MS_PER_DAY = 24 * 60 * 60 * 1000; function median(values: number[]): number | null { @@ -92,18 +94,16 @@ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContex ? Math.round((signal.windowEnd.getTime() - attempts[0]!.submittedAt.getTime()) / MS_PER_DAY) : null; const sessionRows = new Map(); - for (const attempt of attempts) { - if ( - (attempt.timingQuality === 'session_only' || attempt.timingQuality === 'none') && - !sessionRows.has(attempt.sessionId) - ) { - sessionRows.set(attempt.sessionId, { - sessionId: stableSessionId(attempt.sessionId), - startedAt: attempt.startedAt.toISOString(), - endedAt: attempt.submittedAt.toISOString(), - totalElapsedMs: attempt.sessionTotalMs, - vendorAttemptCount: null, - timingQuality: attempt.timingQuality, + const contributingSessionIds = new Set(attempts.map((attempt) => attempt.sessionId)); + for (const session of ctx.sessions) { + if (contributingSessionIds.has(session.id) && hasUsableSessionAggregate(session)) { + sessionRows.set(session.id, { + sessionId: stableSessionId(session.id), + startedAt: session.startedAt.toISOString(), + endedAt: session.endedAt.toISOString(), + totalElapsedMs: session.totalElapsedMs, + vendorAttemptCount: session.vendorAttemptCount, + timingQuality: session.timingQuality, }); } } diff --git a/packages/signal-engine/src/rules/grinding-session.ts b/packages/signal-engine/src/rules/grinding-session.ts index 5c6e268..d6115dd 100644 --- a/packages/signal-engine/src/rules/grinding-session.ts +++ b/packages/signal-engine/src/rules/grinding-session.ts @@ -1,7 +1,8 @@ -import type { Attempt } from '@huddle/core'; +import type { Attempt, LearningSession } from '@huddle/core'; import type { RuleContract, RuleContext, RuleOutcome } from '../contract.js'; import { RULE_VERSION } from '../config/thresholds.js'; import { URGENCY, severity } from '../severity.js'; +import { hasUsableSessionAggregate } from '../timing.js'; interface SessionSummary { totalMs: number; @@ -9,8 +10,12 @@ interface SessionSummary { } /** Session-only totals are used once per session and compared as means, never item splits. */ -function sessionSummaries(attempts: readonly Attempt[]): Map { +function sessionSummaries( + attempts: readonly Attempt[], + sessions: readonly LearningSession[] +): Map { const bySession = new Map(); + const sessionFacts = new Map(sessions.map((session) => [session.id, session])); for (const attempt of attempts) { const values = bySession.get(attempt.sessionId) ?? []; values.push(attempt); @@ -19,22 +24,26 @@ function sessionSummaries(attempts: readonly Attempt[]): Map(); for (const [sessionId, attemptsInSession] of bySession) { - const sessionOnly = attemptsInSession.filter( - (attempt) => attempt.timingQuality === 'session_only' && attempt.sessionTotalMs != null - ); + const aggregate = sessionFacts.get(sessionId); const timed = attemptsInSession.filter( (attempt) => (attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') && (attempt.engagedMs != null || attempt.elapsedMs != null) ); - if (sessionOnly.length === 0 && timed.length === 0) continue; - summaries.set(sessionId, { - totalMs: - sessionOnly.length > 0 - ? Math.max(...sessionOnly.map((attempt) => attempt.sessionTotalMs!)) - : timed.reduce((sum, attempt) => sum + (attempt.engagedMs ?? attempt.elapsedMs ?? 0), 0), - count: sessionOnly.length > 0 ? attemptsInSession.length : timed.length, - }); + if (aggregate && hasUsableSessionAggregate(aggregate)) { + summaries.set(sessionId, { + totalMs: aggregate.totalElapsedMs, + count: aggregate.vendorAttemptCount, + }); + } else if (timed.length > 0) { + summaries.set(sessionId, { + totalMs: timed.reduce( + (sum, attempt) => sum + (attempt.engagedMs ?? attempt.elapsedMs ?? 0), + 0 + ), + count: timed.length, + }); + } } return summaries; } @@ -60,7 +69,7 @@ export const contract: RuleContract = { }; } - const summaries = sessionSummaries(ctx.sessionAttempts); + const summaries = sessionSummaries(ctx.sessionAttempts, ctx.sessions); if (summaries.size === 0) return { type: 'clear' }; const ratio = ctx.config.timing.slowWrongRatio; diff --git a/packages/signal-engine/src/timing.ts b/packages/signal-engine/src/timing.ts index 09fe3af..1c55c69 100644 --- a/packages/signal-engine/src/timing.ts +++ b/packages/signal-engine/src/timing.ts @@ -1,4 +1,15 @@ -import type { Attempt } from '@huddle/core'; +import type { Attempt, LearningSession } from '@huddle/core'; + +export function hasUsableSessionAggregate( + session: LearningSession +): session is LearningSession & { totalElapsedMs: number; vendorAttemptCount: number } { + return ( + session.timingQuality === 'session_only' && + session.totalElapsedMs != null && + session.vendorAttemptCount != null && + session.vendorAttemptCount > 0 + ); +} /** * The single definition of a trustworthy per-attempt duration. diff --git a/packages/signal-engine/test/attendance-engine.test.ts b/packages/signal-engine/test/attendance-engine.test.ts index 9cd99f4..b3d757b 100644 --- a/packages/signal-engine/test/attendance-engine.test.ts +++ b/packages/signal-engine/test/attendance-engine.test.ts @@ -22,7 +22,6 @@ function attempt(id: number, submittedAt: Date): Attempt { submittedAt, elapsedMs: 60_000, engagedMs: 60_000, - sessionTotalMs: null, timingQuality: 'engaged', timingWasWinsorized: false, isCorrect: true, @@ -49,6 +48,7 @@ function run(attendanceLoaded: boolean, attendance: import('@huddle/core').Absen students: [{ id: studentId, firstName: 'Taylor' }], skills: [], attempts: [...history, ...current], + sessions: [], items: [ { id: 'mcq:TEKS.4.2A-01', diff --git a/packages/signal-engine/test/classifications.test.ts b/packages/signal-engine/test/classifications.test.ts index 0bfdd09..41ea8da 100644 --- a/packages/signal-engine/test/classifications.test.ts +++ b/packages/signal-engine/test/classifications.test.ts @@ -23,6 +23,7 @@ describe('student-skill classifications', () => { students: [{ id: studentId, firstName: 'Alex' }], skills: [defaultSkill], attempts: [attempt({ id: 1, studentId })], + sessions: [], items: Object.values(defaultItems), skillPrereqs: [], mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, diff --git a/packages/signal-engine/test/confidence-conflict.test.ts b/packages/signal-engine/test/confidence-conflict.test.ts index a1e2010..629ab15 100644 --- a/packages/signal-engine/test/confidence-conflict.test.ts +++ b/packages/signal-engine/test/confidence-conflict.test.ts @@ -3,13 +3,15 @@ import type { InputRequirement } from '../src/contract.js'; import { applyConflictPenalty, confidenceBreakdown } from '../src/confidence.js'; import { RULE_CONFIG } from '../src/config/thresholds.js'; import { attempt } from './rule-context.js'; +import type { LearningSession } from '@huddle/core'; function breakdown( requiredInputs: readonly InputRequirement[], attempts = [attempt({ id: 1 })], + sessions: LearningSession[] = [], hasConflict = false ) { - return confidenceBreakdown(attempts, requiredInputs, hasConflict, RULE_CONFIG); + return confidenceBreakdown(attempts, sessions, requiredInputs, hasConflict, RULE_CONFIG); } describe('frozen confidence policy', () => { @@ -66,15 +68,25 @@ describe('frozen confidence policy', () => { timingQuality: 'session_only', elapsedMs: null, engagedMs: null, - sessionTotalMs: 180_000, }), + ], + [ + { + id: '00000000-0000-0000-0000-000000000001', + studentId: '11111111-1111-1111-1111-111111111111', + startedAt: new Date('2026-07-22T08:00:00Z'), + endedAt: new Date('2026-07-22T08:03:00Z'), + totalElapsedMs: 180_000, + vendorAttemptCount: 3, + timingQuality: 'session_only', + }, ] ).timingMultiplier ).toBe(0.7); }); it('applies the cross-family multiplier once', () => { - expect(breakdown([], undefined, true).conflictMultiplier).toBe(0.7); + expect(breakdown([], undefined, [], true).conflictMultiplier).toBe(0.7); expect(applyConflictPenalty(0.8, 3, RULE_CONFIG)).toBeCloseTo(0.56); }); }); diff --git a/packages/signal-engine/test/config-version.test.ts b/packages/signal-engine/test/config-version.test.ts index 730dc83..4d6aa0e 100644 --- a/packages/signal-engine/test/config-version.test.ts +++ b/packages/signal-engine/test/config-version.test.ts @@ -33,6 +33,11 @@ function changedAt(path: string[], value: boolean | number): unknown { } describe('rule configuration versioning', () => { + it('tracks the current deterministic implementation generation', () => { + expect(RULE_IMPLEMENTATION_VERSION).toBe(4); + expect(RULE_VERSION).toBe('c8e12eb46d053c31'); + }); + it('hashes every behavior-affecting frozen configuration leaf', () => { const versionOf = (config: unknown) => computeRuleVersion({ implementationVersion: RULE_IMPLEMENTATION_VERSION, config }); diff --git a/packages/signal-engine/test/determinism.test.ts b/packages/signal-engine/test/determinism.test.ts index bdd357e..cfaf42f 100644 --- a/packages/signal-engine/test/determinism.test.ts +++ b/packages/signal-engine/test/determinism.test.ts @@ -41,6 +41,7 @@ describe('determinism', () => { { ...defaultSkill, id: 'TEKS.4.3C', name: 'Prerequisite C' }, ], attempts: [attempt({ id: 1 })], + sessions: [], items: Object.values(defaultItems), skillPrereqs: edges, mastery: { at: () => ({ value: 0.3, isKnown: true as const }) }, @@ -84,6 +85,7 @@ describe('determinism', () => { students: [{ id: '11111111-1111-1111-1111-111111111111', firstName: 'Alex' }], skills: [defaultSkill], attempts: [attempt({ id: 1 }), attempt({ id: 2 })], + sessions: [], items: Object.values(defaultItems), skillPrereqs: [], mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, diff --git a/packages/signal-engine/test/disengagement-grain.test.ts b/packages/signal-engine/test/disengagement-grain.test.ts index 703f695..a3760a6 100644 --- a/packages/signal-engine/test/disengagement-grain.test.ts +++ b/packages/signal-engine/test/disengagement-grain.test.ts @@ -24,7 +24,6 @@ function attempt(id: number, studentId: string, skillId: string, submittedAt: Da submittedAt, elapsedMs: 60_000, engagedMs: 60_000, - sessionTotalMs: null, timingQuality: 'engaged', timingWasWinsorized: false, isCorrect: true, @@ -70,6 +69,7 @@ function run() { ], skills: [], attempts: [...history(steady, 1), ...history(stopped, 5_000), ...steadyWeek()], + sessions: [], items: [], skillPrereqs: [], mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, diff --git a/packages/signal-engine/test/grinding-session-engine.test.ts b/packages/signal-engine/test/grinding-session-engine.test.ts index db853e3..1b69d0c 100644 --- a/packages/signal-engine/test/grinding-session-engine.test.ts +++ b/packages/signal-engine/test/grinding-session-engine.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { items, skillPrereqs, skills } from '@huddle/core/seed'; -import type { Attempt } from '@huddle/core'; +import type { Attempt, LearningSession } from '@huddle/core'; import { runEngine } from '../src/engine.js'; import { allRules } from '../src/rules/index.js'; import { RULE_CONFIG } from '../src/config/thresholds.js'; +import type { RuleContract } from '../src/contract.js'; const studentId = 'session-only-student'; const window = { @@ -16,10 +17,9 @@ function sessionAttempt( submittedAt: string, sessionId: string, isCorrect: boolean, - sessionTotalMs: number | null, skillId = 'TEKS.4.2A', itemId = 'mcq:TEKS.4.2A-01' -): Attempt & { sessionTotalMs: number | null } { +): Attempt { return { id, studentId, @@ -38,31 +38,62 @@ function sessionAttempt( hintsUsed: 0, activityId: `session-${id}`, ingestedAt: new Date(submittedAt), - sessionTotalMs, }; } -function runSessionScenario(sessionTotalMs: number | null) { +function sessionFact( + id: string, + totalElapsedMs: number, + vendorAttemptCount: number, + startedAt = '2026-07-22T07:45:00.000Z', + endedAt = '2026-07-22T08:15:00.000Z' +): LearningSession { + return { + id, + studentId, + startedAt: new Date(startedAt), + endedAt: new Date(endedAt), + totalElapsedMs, + vendorAttemptCount, + timingQuality: 'session_only', + }; +} + +function runSessionScenario( + sessionTotalMs: number | null, + vendorAttemptCount = 3, + mappedAttemptCount = 3 +) { const history = Array.from({ length: 8 }, (_, index) => sessionAttempt( index + 1, `2026-06-${String(index + 21).padStart(2, '0')}T08:00:00.000Z`, `history-${index}`, - true, - 60_000 + true + ) + ); + const current = Array.from({ length: mappedAttemptCount }, (_, index) => + sessionAttempt(9 + index, `2026-07-22T08:0${index}:00.000Z`, 'slow-session', false) + ); + const historySessions = history.map((attempt) => + sessionFact( + attempt.sessionId, + 60_000, + 1, + attempt.startedAt.toISOString(), + attempt.submittedAt.toISOString() ) ); - const current = [ - sessionAttempt(9, '2026-07-22T08:00:00.000Z', 'slow-session', false, sessionTotalMs), - sessionAttempt(10, '2026-07-22T08:01:00.000Z', 'slow-session', false, sessionTotalMs), - sessionAttempt(11, '2026-07-22T08:02:00.000Z', 'slow-session', false, sessionTotalMs), - ]; return runEngine( { students: [{ id: studentId, firstName: 'Sam' }], skills, attempts: [...history, ...current], + sessions: + sessionTotalMs == null + ? historySessions + : [...historySessions, sessionFact('slow-session', sessionTotalMs, vendorAttemptCount)], items, skillPrereqs, mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, @@ -83,6 +114,18 @@ describe('grinding.slow-session-vs-baseline through the engine', () => { .find((classification) => classification.skillId === null) ?.outcomes.find((outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline')?.outcome; expect(sessionOutcome?.type).toBe('fired'); + const sessionEvidence = output.signals.find( + (signal) => signal.ruleId === 'grinding.slow-session-vs-baseline' + )?.evidence as import('@huddle/core').EvidenceBundle; + expect(sessionEvidence.sessions).toMatchObject([ + { + startedAt: '2026-07-22T07:45:00.000Z', + endedAt: '2026-07-22T08:15:00.000Z', + totalElapsedMs: 540_000, + vendorAttemptCount: 3, + timingQuality: 'session_only', + }, + ]); }); it('abstains instead of fabricating a signal when session totals are unavailable', () => { @@ -96,20 +139,78 @@ describe('grinding.slow-session-vs-baseline through the engine', () => { }); }); + it('uses the vendor activity count when mapped attempts are only a subset', () => { + const output = runSessionScenario(600_000, 10, 2); + const sessionOutcome = output.classifications + .find((classification) => classification.skillId === null) + ?.outcomes.find((outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline')?.outcome; + + expect(sessionOutcome?.type).toBe('clear'); + }); + + it('accepts a zero total with a positive vendor count', () => { + const current = sessionAttempt(50, '2026-07-22T08:00:00.000Z', 'zero-session', false); + const rule: RuleContract = { + id: 'test.zero-session-aggregate', + version: 'zero-session-v1', + emits: 'grinding', + family: 'timing', + urgency: 0.35, + requiredInputs: ['timing.sessionAggregate'], + unit: 'student', + evaluate: (ctx) => ({ + type: 'fired', + severity: 0.2, + confidence: 1, + evidence: { + attemptActivityIds: ctx.attempts.map((attempt) => attempt.activityId), + summary: 'zero total remains present', + values: {}, + }, + }), + }; + const output = runEngine( + { + students: [{ id: studentId, firstName: 'Sam' }], + skills, + attempts: [current], + sessions: [sessionFact('zero-session', 0, 2)], + items, + skillPrereqs, + mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, + attendance: [], + attendanceLoaded: true, + config: RULE_CONFIG, + now: window.end, + window, + }, + [rule] + ); + const signal = output.signals[0]!; + const bundle = signal.evidence as import('@huddle/core').EvidenceBundle; + + expect(output.classifications[0]?.outcomes[0]?.outcome.type).toBe('fired'); + expect(signal.finalConfidence).toBe(0.7); + expect(bundle.sessions[0]).toMatchObject({ + totalElapsedMs: 0, + vendorAttemptCount: 2, + timingQuality: 'session_only', + }); + }); + it('uses the full multi-skill session attempt count for a session total', () => { const history = Array.from({ length: 8 }, (_, index) => sessionAttempt( index + 1, `2026-06-${String(index + 21).padStart(2, '0')}T08:00:00.000Z`, `history-${index}`, - true, - 60_000 + true ) ); const otherItem = items.find((item) => item.skillId !== 'TEKS.4.2A')!; const target = [ - sessionAttempt(20, '2026-07-22T08:00:00.000Z', 'mixed', false, 600_000), - sessionAttempt(21, '2026-07-22T08:01:00.000Z', 'mixed', false, 600_000), + sessionAttempt(20, '2026-07-22T08:00:00.000Z', 'mixed', false), + sessionAttempt(21, '2026-07-22T08:01:00.000Z', 'mixed', false), ]; const other = Array.from({ length: 8 }, (_, index) => sessionAttempt( @@ -117,7 +218,6 @@ describe('grinding.slow-session-vs-baseline through the engine', () => { `2026-07-22T08:${String(index + 2).padStart(2, '0')}:00.000Z`, 'mixed', true, - 600_000, otherItem.skillId, otherItem.id ) @@ -127,6 +227,18 @@ describe('grinding.slow-session-vs-baseline through the engine', () => { students: [{ id: studentId, firstName: 'Sam' }], skills, attempts: [...history, ...target, ...other], + sessions: [ + ...history.map((attempt) => + sessionFact( + attempt.sessionId, + 60_000, + 1, + attempt.startedAt.toISOString(), + attempt.submittedAt.toISOString() + ) + ), + sessionFact('mixed', 600_000, 10), + ], items, skillPrereqs, mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, diff --git a/packages/signal-engine/test/half-open-engine.test.ts b/packages/signal-engine/test/half-open-engine.test.ts index 79b8648..55bbd67 100644 --- a/packages/signal-engine/test/half-open-engine.test.ts +++ b/packages/signal-engine/test/half-open-engine.test.ts @@ -45,6 +45,7 @@ describe('half-open board/mastery boundary contract', () => { students: [context.student], skills: [defaultSkill], attempts: [...context.attempts], + sessions: [], items: Object.values(defaultItems), skillPrereqs: [], mastery: { diff --git a/packages/signal-engine/test/helpers.ts b/packages/signal-engine/test/helpers.ts index de06284..a4f1f50 100644 --- a/packages/signal-engine/test/helpers.ts +++ b/packages/signal-engine/test/helpers.ts @@ -76,6 +76,7 @@ export function runScenario( students, skills: skills as Skill[], attempts, + sessions: [], items: itemBank as Item[], skillPrereqs, mastery: makeMasteryLookup(masteryOverrides), diff --git a/packages/signal-engine/test/per-fired-rule-evidence.test.ts b/packages/signal-engine/test/per-fired-rule-evidence.test.ts index cdc896d..a78cc1b 100644 --- a/packages/signal-engine/test/per-fired-rule-evidence.test.ts +++ b/packages/signal-engine/test/per-fired-rule-evidence.test.ts @@ -27,6 +27,7 @@ describe('per-fired-rule evidence identity', () => { students: [context.student], skills: [defaultSkill], attempts: [...context.attempts], + sessions: [], items: Object.values(defaultItems), skillPrereqs: [], mastery: context.mastery, @@ -111,6 +112,7 @@ describe('per-fired-rule evidence identity', () => { students: [context.student], skills: [defaultSkill], attempts: [...context.attempts], + sessions: [], items: Object.values(defaultItems), skillPrereqs: [], mastery: context.mastery, @@ -213,6 +215,7 @@ describe('per-fired-rule evidence identity', () => { students: [context.student], skills: [defaultSkill], attempts: [...context.attempts], + sessions: [], items: [item], skillPrereqs: [], mastery: context.mastery, @@ -277,6 +280,7 @@ describe('per-fired-rule evidence identity', () => { students: [context.student], skills: [defaultSkill], attempts: [...context.attempts], + sessions: [], items: Object.values(defaultItems), skillPrereqs: [], mastery: context.mastery, diff --git a/packages/signal-engine/test/rule-context.ts b/packages/signal-engine/test/rule-context.ts index 712baab..923231b 100644 --- a/packages/signal-engine/test/rule-context.ts +++ b/packages/signal-engine/test/rule-context.ts @@ -49,6 +49,7 @@ export function makeContext(overrides: Partial = {}): RuleContext { window, attempts, sessionAttempts: overrides.sessionAttempts ?? attempts, + sessions: overrides.sessions ?? [], items: defaultItems, baseline: null, skillGraph: { @@ -78,7 +79,6 @@ export function attempt(props: Partial & { id: number | null }): Attemp submittedAt: new Date('2026-07-22T08:01:00Z'), elapsedMs: 60000, engagedMs: 60000, - sessionTotalMs: null, timingQuality: 'engaged', timingWasWinsorized: false, isCorrect: true, diff --git a/packages/signal-engine/test/rules.test.ts b/packages/signal-engine/test/rules.test.ts index 042ad11..8676b2e 100644 --- a/packages/signal-engine/test/rules.test.ts +++ b/packages/signal-engine/test/rules.test.ts @@ -369,7 +369,6 @@ describe('Rule: grinding.slow-session-vs-baseline', () => { timingQuality: 'session_only', elapsedMs: null, engagedMs: null, - sessionTotalMs: 250000, isCorrect: false, answerGiven: { key: 'A' }, }), @@ -379,11 +378,21 @@ describe('Rule: grinding.slow-session-vs-baseline', () => { timingQuality: 'session_only', elapsedMs: null, engagedMs: null, - sessionTotalMs: 250000, isCorrect: false, answerGiven: { key: 'C' }, }), ], + sessions: [ + { + id: 'sess-1', + studentId: '11111111-1111-1111-1111-111111111111', + startedAt: new Date('2026-07-22T08:00:00Z'), + endedAt: new Date('2026-07-22T08:04:10Z'), + totalElapsedMs: 250000, + vendorAttemptCount: 2, + timingQuality: 'session_only', + }, + ], }); const out = grindingSession.evaluate(ctx); expect(out.type).toBe('fired'); diff --git a/packages/signal-engine/test/timing-quality.test.ts b/packages/signal-engine/test/timing-quality.test.ts index 093a475..460fd88 100644 --- a/packages/signal-engine/test/timing-quality.test.ts +++ b/packages/signal-engine/test/timing-quality.test.ts @@ -28,7 +28,6 @@ describe('per-attempt duration honours timing quality', () => { attempt({ id, timingQuality: 'session_only', - sessionTotalMs: 600000, elapsedMs: 1000, engagedMs: null, isCorrect: false, diff --git a/specs/001-huddle-triage-board/checklists/requirements.md b/specs/001-huddle-triage-board/checklists/requirements.md index 5a41f55..ff6d2c9 100644 --- a/specs/001-huddle-triage-board/checklists/requirements.md +++ b/specs/001-huddle-triage-board/checklists/requirements.md @@ -115,8 +115,9 @@ quick-demo direction. The delta adds testable acceptance for: - Supabase sign-in and server-resolved synthetic guide/studio authorization, with browser Auth-only and direct Data API/credential boundaries; -- Variant B Evidence Desk, three progressive evidence levels, exact attempt/session traceability, - URL/mobile behavior, and focus/scroll restoration; +- Variant B Evidence Desk, explicit queue rank/priority/confidence, visible cause → context → evidence, + one deduplicated teacher evidence list with exact attempt/session traceability, URL/mobile behavior, + and focus/scroll restoration; - automatic first-open acknowledgment with grant-free prefetch, fresh opening authorization, same-opening expiry renewal, one replacement per expired source under concurrent replay, per-grant replay prevention, prefetch/list/denial zero-write, and idempotency; diff --git a/specs/001-huddle-triage-board/contracts/application-interfaces.md b/specs/001-huddle-triage-board/contracts/application-interfaces.md index 83d0b0a..ee6f09c 100644 --- a/specs/001-huddle-triage-board/contracts/application-interfaces.md +++ b/specs/001-huddle-triage-board/contracts/application-interfaces.md @@ -277,6 +277,8 @@ Variant B — **Evidence Desk** is the quick-demo shell: - desktop: ranked rail on the left, selected evidence workspace on the right; - mobile: the same URL-addressable queue and detail states shown one at a time; +- before selection, every queue card exposes its rank for today, full priority label, and confidence + label through both visible text and one accessible status description; - `/board?entry=&run=` is the selected-report state, so refresh, Back, and deep links are deterministic; - returning from detail restores scroll position and resolves the same student in the current rail, @@ -286,15 +288,17 @@ Variant B — **Evidence Desk** is the quick-demo shell: unauthorized, cross-guide, missing, or superseded/unselected runs return the same `not-found` result and expose no student fact. -Evidence is progressively disclosed without changing its source: +Evidence is presented in three ordered, visible layers without changing its source: 1. cause, severity, final confidence, narration/degraded status, and concrete opener; -2. personal-baseline comparison, prerequisite check, conflicts, and evidence-linked additional causes; -3. exact contributing attempts and sessions: IDs, skills, item types, timestamps, outcomes, genuine - durations/nulls, timing quality, and computed values. - -Every displayed fact points to a stored record or deterministic bundle path. Collapsing a level hides -presentation only; it never fetches broader ambient student context. +2. personal-baseline comparison, prerequisite check, and concise evidence-linked additional causes; +3. one visible, deduplicated evidence list with attempted skills, timestamps, outcomes, selected + answers, useful misconceptions, and compact work/session duration context. The presentation names + active versus elapsed time when that distinction is available, renders durations in seconds, keeps + missing duration explicit, and uses record identity only as an unexposed rendering key. + +Every displayed fact points to a stored record or deterministic bundle path. The visible layers never +fetch broader ambient student context. ### Automatic acknowledgment diff --git a/specs/001-huddle-triage-board/plan.md b/specs/001-huddle-triage-board/plan.md index 254b9ea..5898a4a 100644 --- a/specs/001-huddle-triage-board/plan.md +++ b/specs/001-huddle-triage-board/plan.md @@ -244,13 +244,14 @@ cause-specific fallbacks, a restricted runtime database role, and redacted opera repository physically names the dedicated data-access package `packages/db`; this implementation keeps that path. -The Evidence Desk shell, route state, progressive evidence presentation, and framework-neutral -`EvidenceReader` visible-open boundary are composed through guide-scoped PostgreSQL adapters. The -board reads only the selected immutable head; evidence opens receive signed one-use grants, acquire a -current-head reveal lease, and write replay-safe first-open acknowledgments to PostgreSQL. The fixed -portfolio corpus verifies deterministic fallback and grounding with zero model calls and no accuracy -claim. Optional model-backed narration attachment, deployment scheduler wiring, and the full -simulator/accuracy gates remain implementation gaps, not alternate contracts. +The Evidence Desk shell combines route state and ordered, always-visible cause, context, and +deduplicated evidence layers with the framework-neutral `EvidenceReader` visible-open boundary +through guide-scoped PostgreSQL adapters. The board reads only the selected immutable head; evidence +opens receive signed one-use grants, acquire a current-head reveal lease, and write replay-safe +first-open acknowledgments to PostgreSQL. The fixed portfolio corpus verifies deterministic fallback +and grounding with zero model calls and no accuracy claim. Optional model-backed narration +attachment, deployment scheduler wiring, and the full simulator/accuracy gates remain implementation +gaps, not alternate contracts. ### Future gates preserved diff --git a/specs/001-huddle-triage-board/quickstart.md b/specs/001-huddle-triage-board/quickstart.md index 830de7a..6992d67 100644 --- a/specs/001-huddle-triage-board/quickstart.md +++ b/specs/001-huddle-triage-board/quickstart.md @@ -18,8 +18,9 @@ npm run dev ``` Then sign in at `/login`, open `/import`, upload `apps/web/public/synthetic-huddle-sample.csv`, -validate, commit, refresh, return to `/board`, open a report, expand exact evidence, and observe the -visible-open acknowledgment. All roster/activity data in this path is synthetic. +validate, commit, refresh, return to `/board`, open a report, review the ordered visible cause, +context, and single deduplicated evidence layers, and observe the visible-open acknowledgment. All +roster/activity data in this path is synthetic. Explicit reset for the fixed synthetic scope: diff --git a/specs/001-huddle-triage-board/research.md b/specs/001-huddle-triage-board/research.md index c44952f..cbd579a 100644 --- a/specs/001-huddle-triage-board/research.md +++ b/specs/001-huddle-triage-board/research.md @@ -416,8 +416,10 @@ visible placeholder calls the storage-neutral `authorizeVisibleOpen` boundary be same credentials are submitted for acknowledgment only after visibility. A protected internal job handler accepts scheduled dispatch; no public product API or generic repository layer is added. -Variant B — Evidence Desk is the quick-demo shell. On desktop it is a ranked rail plus progressive -evidence workspace; on mobile the same URL-addressable queue/detail states are mutually exclusive. +Variant B — Evidence Desk is the quick-demo shell. On desktop it is a ranked rail plus an ordered +cause → context → deduplicated-evidence workspace; on mobile the same URL-addressable queue/detail +states are mutually exclusive. The current presentation contract is owned by +[`contracts/application-interfaces.md`](./contracts/application-interfaces.md). Prefetch reads are side-effect free and grant-free. When navigation actually opens a report, the adapter bypasses prefetched evidence and performs a fresh authorized `openEntry` read, which returns evidence plus a signed five-minute, one-use grant bound to the exact user, scope, run, entry, and @@ -440,9 +442,9 @@ The bounded Operations slice replaced the prior destructive date-keyed rewrite w service boundary. **Rationale**: The accepted demo needs truthful loading/failure/freshness states, a guide-owned import, -and exact evidence without weakening the deterministic engine. Deep application interfaces contain -those workflows while keeping SQL, Next, Supabase, and models at adapters. An immutable head swap is -the smallest model that can prove refresh failure preservation. +and exact traceability without weakening the deterministic engine. Deep application interfaces +contain those workflows while keeping SQL, Next, Supabase, and models at adapters. An immutable head +swap is the smallest model that can prove refresh failure preservation. --- diff --git a/specs/001-huddle-triage-board/spec.md b/specs/001-huddle-triage-board/spec.md index 4c10f55..55910b2 100644 --- a/specs/001-huddle-triage-board/spec.md +++ b/specs/001-huddle-triage-board/spec.md @@ -46,7 +46,9 @@ confidence value, and an opening line. Fully testable without any other story im student, cause, confidence, or rank changes. 6. **Given** an authorized synthetic guide, **When** they open the board on desktop, **Then** Variant B — Evidence Desk displays a ranked rail beside the selected evidence workspace; on mobile, the - same URL-addressable queue/detail states appear one at a time. + same URL-addressable queue/detail states appear one at a time. Before any report is selected, every + queue card exposes its rank for today, full priority label, and confidence label in visible text + and one accessible status description. 7. **Given** a current committed board while manual or nightly refresh is queued or running, **When** the guide continues reviewing, **Then** the committed board remains visible with the refresh state; a failed refresh preserves that board and reports failure instead of replacing it @@ -56,33 +58,37 @@ confidence value, and an opening line. Fully testable without any other story im ### User Story 2 - Evidence Drill-Down (Priority: P2) -A guide reads an entry claiming a student is guessing and wants to know why the system believes -that before acting on it. They open the entry and see the specific activity records that triggered -the conclusion: which attempts, on which skill, when, how long each took, what the student's own -baseline is, and which prerequisite was checked and found adequate. Nothing on the screen is -unsourced. +A guide reads an entry claiming a student is guessing and wants to know why the system believes that +before acting on it. They open the entry and see cause, context, and one teacher-friendly, +deduplicated list of the specific attempts and sessions that support the conclusion. Each attempt +names the skill, result, selected answer, useful misconception when available, readable date/time, +and an honest work duration or missing-duration label. Nothing on the screen is unsourced. **Why this priority**: A guide who cannot verify a claim will stop trusting the board within a week, and an unverifiable claim about a child is not acceptable to act on. This converts the board from an oracle into an instrument. **Independent Test**: For any board entry, open the drill-down and confirm every number and claim -displayed in the summary maps to a specific underlying activity record that is displayed alongside -it. Testable against fixed records with no other story implemented. +maps to its exact stored activity or deterministic evidence-bundle path, while each attempt/session +appears only once in the teacher-facing evidence list. Testable against fixed records with no other +story implemented. **Acceptance Scenarios**: 1. **Given** a board entry with root cause `guessing`, **When** the guide opens its drill-down, - **Then** every attempt that contributed to the signal is listed with its timestamp, outcome, - and duration, alongside the student's own baseline used for comparison. + **Then** every contributing attempt is listed once with its attempted skill, result, selected + answer, useful misconception when available, Chicago-local date/time, and readable active, + elapsed, or unavailable duration, alongside the student's own baseline used for comparison. 2. **Given** a board entry that cites a prerequisite check, **When** the guide opens its drill-down, **Then** the prerequisite skill and its mastery value are shown. 3. **Given** any generated or deterministic-fallback sentence on the board, **When** it is compared against the drill-down evidence, **Then** every factual value and qualitative proposition is authorized by that evidence, including every quantity, date, skill, and student attribute. 4. **Given** a guide selects a ranked entry, **When** the evidence workspace opens, **Then** it first - shows the cause, confidence, narration status, and opener; then baseline/prerequisite/conflict - evidence; then exact contributing attempts and sessions through explicit progressive disclosure. + shows the cause, full priority, confidence, narration status, and opener; then baseline, + prerequisite, and concise additional-cause context; then one visible deduplicated evidence list. + Normal flow does not expose attempt/activity/rule/signal identifiers, ordinals, hashes, confidence + math, multipliers, raw pattern dumps, or conflict-adjustment jargon. 5. **Given** an authorized report becomes visibly open, **When** the automatic acknowledgment action submits the fresh opening-read-issued short-lived one-use grant and its same-opening renewal capability, **Then** expiry after visibility or in flight uniquely claims the expired source nonce, @@ -260,8 +266,8 @@ denied. - **Student who is genuinely fine**: must be classifiable as `fine` and excluded from the board. Producing an intervention for a student who needs none is a defect, not a harmless extra. - **Contradictory evidence** (timing suggests guessing, answer choices suggest a specific - misconception): confidence is reduced and the conflict is surfaced rather than one signal - silently winning. + misconception): confidence is reduced deterministically and the teacher sees concise + additional-cause context rather than confidence math or conflict-adjustment jargon. - **A single attempt with an implausibly long recorded duration**: treated as unknown, not as evidence of effort. - **Every student is fine**: the board renders an explicit empty state, not a blank screen. @@ -459,9 +465,13 @@ denied. reads/writes MUST remain server-side through the dedicated `packages/db` data-access package; no database/service-role credential, SQL client, unrestricted Supabase data client, or direct Data API access may reach the browser. -- **FR-054**: The quick demo MUST use Variant B — Evidence Desk: a ranked rail and progressive, - URL-addressable evidence workspace on desktop, and equivalent queue/detail states on mobile. - Returning MUST restore the selected row's focus and position. +- **FR-054**: The quick demo MUST use Variant B — Evidence Desk: a ranked rail and URL-addressable + evidence workspace on desktop, and equivalent queue/detail states on mobile. Every queue card MUST + expose rank for today, full priority, and confidence accessibly before selection. Each selected + report MUST keep cause → context → evidence visible and end in one teacher-friendly, deduplicated + attempt/session list; internal IDs, ordinals, hashes, rule-quality internals, confidence math, and + conflict-adjustment jargon MUST stay out of normal flow. Returning MUST restore the selected row's + focus and position. - **FR-055**: System MUST automatically acknowledge the exact finding after its authorized evidence report is visibly opened. Prefetch MUST create and carry no acknowledgment grant. Actual opening MUST bypass prefetched evidence and perform a fresh authorized read that returns evidence plus a @@ -736,7 +746,8 @@ quickstart artifacts. The current captain direction additionally fixes the implementation path without reopening the Slice 0 diagnosis contracts: -1. **Shell**: Variant B — Evidence Desk, with progressive exact evidence and route-addressable +1. **Shell**: Variant B — Evidence Desk, with explicit queue rank/priority/confidence, visible + cause → context → evidence, one deduplicated teacher evidence list, and route-addressable master/detail behavior. 2. **Acknowledgment**: authorize reveal atomically at the visible placeholder, then automatically persist first open after the report is shown; no separate guide action and no prefetch/list side @@ -751,8 +762,8 @@ The current captain direction additionally fixes the implementation path without `BoardCompiler`, and `Importer` interfaces; no generic repositories or microservices. 7. **Next.js boundary**: Server Components for reads and narrow authenticated actions/internal job handler for mutations; no premature public API. -8. **Operations**: nightly plus manual refresh, last-success preservation, progressive evidence, and - no notifications in v1. +8. **Operations**: nightly plus manual refresh, last-success preservation, the Evidence Desk contract, + and no notifications in v1. The executable interface/state contract is [`contracts/application-interfaces.md`](./contracts/application-interfaces.md). diff --git a/specs/001-huddle-triage-board/tasks.md b/specs/001-huddle-triage-board/tasks.md index 443b41f..f597c31 100644 --- a/specs/001-huddle-triage-board/tasks.md +++ b/specs/001-huddle-triage-board/tasks.md @@ -266,29 +266,31 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra selected Evidence Desk workspace, Server Component reads only. - [ ] T058 [US1] Truthful loading/not-built/successful-empty/stale/unavailable states in `apps/web/app/board/board-state.tsx`; only a completed full-roster run may be successful-empty. -- [ ] T059 [US1] Entry card always renders non-empty fallback/generated diagnosis and opener with - text/icon narration provenance; degraded narration loses no entry. +- [ ] T059 [US1] Entry card exposes rank for today, full priority, and confidence accessibly before + selection, and always renders non-empty fallback/generated diagnosis and opener with text/icon + narration provenance; degraded narration loses no entry. ## Phase 4 — US2 evidence drill-down -- [ ] T060 [P] [US2] Evidence Desk level 3 lists exact contributing attempts with separate - `elapsedMs`/`engagedMs` nulls, session aggregates, and personal baselines in - `apps/web/test/evidence-desk-exact.test.ts`. -- [ ] T061 [P] [US2] Progressive level 2 shows prerequisite/mastery with unknown handled honestly in - `apps/web/test/evidence-desk-prereq.test.ts`. +- [ ] T060 [P] [US2] Evidence Desk lists contributing attempts/sessions once with teacher-friendly + skill, result, selected answer, useful misconception, date/time, honest duration, and personal + baseline context in `apps/web/test/evidence-desk-rendering.test.ts`. +- [ ] T061 [P] [US2] Context shows prerequisite/mastery with unknown handled honestly in + `apps/web/test/evidence-desk-rendering.test.ts`. - [ ] T062 [US2] Whole-board deterministic traceability verifier in `scripts/verify-traceability.ts`. - [ ] T063 [US2] `EvidenceReader` use case in `packages/application/src/evidence-reader.ts`: allow the exact current head selected by `BoardReader` even when presented as stale; return not-found for unauthorized/cross-guide/missing/superseded runs; expose dominant and ordered-additional exact bundle-backed DTOs; keep `readEntry` grant-free for prefetch and require a fresh `openEntry` authorization result before visible rendering. -- [ ] T064 [P] [US2] URL-addressable Evidence Desk detail and exact attempt/session timeline in - `apps/web/app/board/evidence-workspace.tsx`. -- [ ] T065 [P] [US2] Baseline/prerequisite/conflict progressive panels in - `apps/web/app/board/evidence-panels.tsx`. -- [ ] T066 [US2] Every evidence-linked additional cause opens its own exact attempts/sessions/ - computed/derived support, prerequisite check, and conflicts; cross-skill scope, focus/scroll - restoration, and mobile queue/detail behavior live in `apps/web/app/board/`. +- [ ] T064 [P] [US2] URL-addressable Evidence Desk detail and deduplicated attempt/session list in + `apps/web/app/board/evidence-desk.tsx` and its `lib/teacher-report-view.ts` projection. +- [ ] T065 [P] [US2] Teacher-friendly baseline, prerequisite, and concise additional-cause context in + `apps/web/app/board/evidence-desk.tsx`. +- [ ] T066 [US2] Preserve each evidence-linked additional cause's exact bundle-backed traceability + while normal flow shows concise additional-cause context without duplicated attempt tables or + rule-quality internals; cross-skill scope, focus/scroll restoration, and mobile queue/detail + behavior live in `apps/web/app/board/`. ## Phase 4a — Secure quick-demo application shell (US7 + US1/US2) @@ -346,10 +348,10 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra ### Evidence Desk and automatic acknowledgment -- [ ] T128 [P] Playwright Evidence Desk acceptance in `apps/web/test/evidence-desk-navigation.test.ts`: - desktop master-detail, mobile queue/detail, URL deep link, three progressive evidence levels, - exact attempts/sessions, and every additional cause's own prerequisite check/conflicts/computed/ - derived evidence; no horizontal overflow and Back focus/scroll restore. +- [ ] T128 [P] Evidence Desk rendering acceptance in + `apps/web/test/evidence-desk-rendering.test.ts`: queue rank/priority/confidence, visible cause → + context → one deduplicated evidence list, concise additional-cause context, no normal-flow + internals, narrow-width usability, and visible-open behavior. - [ ] T129 **After T121** compose the existing framework-neutral `EvidenceReader` boundary with a forward migration and concrete guide-scoped storage: grant-free `readEntry`, fresh authorized `openEntry`, signed five-minute one-use grant, opening-bound renewal, `authorizeVisibleOpen`, @@ -577,7 +579,7 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra |---|---|---|---|---| | FR-052 / SC-012 sign-in and authorization | `guide_auth_scope`, synthetic guide/student flags in `packages/db` | server Auth adapter + all four interfaces | T113–T116 | `verify:guide-flow -- --case auth` | | FR-053 / SC-012 browser boundary | server env + revoked Data API grants; no browser data field | Next Auth-only adapter | T116 | `--case browser-boundary` | -| FR-054 exact progressive Evidence Desk | immutable run/entry/evidence fingerprints | `BoardReader`, `EvidenceReader` | T057, T060–T066, T128 | `--case evidence-desk` | +| FR-054 teacher-facing Evidence Desk with exact traceability | immutable run/entry/evidence fingerprints | `BoardReader`, `EvidenceReader` | T057, T060–T066, T128 | `--case evidence-desk` | | FR-055 / SC-013 automatic acknowledgment | immutable `report_acknowledgement` first-open row + per-grant consumption ledger | grant-free `readEntry`, fresh `openEntry` grant/renewal capability, then expiry-safe `acknowledgeVisibleOpen` | T129–T130 | `--case auto-ack` | | FR-056 / SC-015 refresh preservation | `board_refresh_request`, run-scoped rows, `board_head` | `BoardCompiler`, `BoardReader` | T121–T125, T108b | `--case refresh` | | FR-057 / SC-015 import results | `import_run`, `import_issue`, scoped FKs + `board_run_import` set | `Importer` | T117–T120/T121 | `--case import` |